From 39b1791fb92d0b7a2041699ceb5a59bfb3a0c36c Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 18:50:45 +0200 Subject: [PATCH] Phase 9l: Image-Occlusion als 4. MVP-CardType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain: CardTypeSchema öffnet 'image-occlusion'. Neues Modul @cards/domain/src/image-occlusion.ts mit MaskRegionSchema (zod-strict, 0..1-relative Coords + optionalem Label, max 100 Regionen), parseMaskRegions (parse + sort-by-id), maskRegionCount, maskForSubIndex. Field-Schema (cards.fields): image_ref: string — media_files.id (Phase 9k Storage) mask_regions: string — JSON-Array note?: string — optionale Bildunterschrift subIndexCount('image-occlusion') wirft analog zu cloze, weil die Anzahl text-abhängig ist. Card-POST-Handler ruft maskRegionCount und lehnt 422 ab, wenn das Mask-Array leer / kaputt ist (vor dem Deck-Lookup). UI-Komponenten: - ImageOcclusionEditor: File-Picker → uploadMedia (Phase 9k), SVG-Overlay über das Bild, Drag-to-create-Rectangle (mind. 2% Größe, sonst Klick-Filter), Mask-Liste mit Label-Input und Delete-Button. Pointer-Events für Touch-Mobile-Support. - ImageOcclusionView (Study-Render): Bild + SVG-Overlay; aktive Mask ist im Prompt opake schwarz, im Reveal transparent grün mit Label-Text; andere Masken bleiben dezent gelb-durchsichtig als Lern-Hinweis. /cards/new + /cards/[id]/edit: Type-Picker um Image-Occlusion erweitert, Branch-Logik schaltet auf den Editor um. canSave- Validierung: imageRef gesetzt + mind. 1 Mask. /study/[deckId] nutzt ImageOcclusionView statt Markdown-Render. /decks/[id]-Liste zeigt "🖼 image-occlusion · " statt "(leer) → (leer)". i18n DE/EN: type_image_occlusion, toast_image_occlusion, image_occlusion-Namespace (image_label, draw_hint, label_placeholder, delete_mask, no_image_selected, etc.). Tests: 11 neue Domain-Tests für MaskRegion-Schema/Parse/Mapping (66 Domain ges.), 3 neue API-Tests für 422-Branches und Validation-vor-Deck-Lookup-Pfad (56 API ges.). 129 Tests grün ges. (66 + 56 + 7), type-check 384 files 0 errors, prod-Build sauber. E2E-Smoke: Image-Occlusion-Card mit 2 Masken (image_ref auf das Sprint-9k-Test-PNG) → API legt content_hash + 2 Reviews mit sub_index 0+1 an, reviews/due returnt sie korrekt typisiert. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/routes/cards.ts | 18 +- apps/api/tests/cards.test.ts | 55 ++++- .../components/ImageOcclusionEditor.svelte | 216 ++++++++++++++++++ .../lib/components/ImageOcclusionView.svelte | 72 ++++++ apps/web/src/lib/i18n/de.ts | 13 ++ apps/web/src/lib/i18n/en.ts | 13 ++ .../src/routes/cards/[id]/edit/+page.svelte | 32 ++- apps/web/src/routes/cards/new/+page.svelte | 38 ++- apps/web/src/routes/decks/[id]/+page.svelte | 7 + .../src/routes/study/[deckId]/+page.svelte | 32 ++- packages/cards-domain/src/fsrs.ts | 4 +- packages/cards-domain/src/image-occlusion.ts | 61 +++++ packages/cards-domain/src/index.ts | 1 + packages/cards-domain/src/schemas/card.ts | 13 +- packages/cards-domain/tests/fsrs.test.ts | 4 + .../tests/image-occlusion.test.ts | 122 ++++++++++ packages/cards-domain/tests/schemas.test.ts | 15 +- 17 files changed, 682 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/lib/components/ImageOcclusionEditor.svelte create mode 100644 apps/web/src/lib/components/ImageOcclusionView.svelte create mode 100644 packages/cards-domain/src/image-occlusion.ts create mode 100644 packages/cards-domain/tests/image-occlusion.test.ts diff --git a/apps/api/src/routes/cards.ts b/apps/api/src/routes/cards.ts index 5fe9a9c..421e83d 100644 --- a/apps/api/src/routes/cards.ts +++ b/apps/api/src/routes/cards.ts @@ -5,6 +5,7 @@ import { CardCreateSchema, CardUpdateSchema, cardContentHash, + maskRegionCount, newReview, subIndexCount, subIndexCountForCloze, @@ -40,9 +41,9 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> } const userId = c.get('userId'); - // Cloze: Sub-Index-Anzahl hängt vom Cluster-Markup im Text ab. - // Eine Cloze-Karte ohne `{{cN::…}}` ist sinnlos — vor dem Deck-Lookup - // ablehnen, damit Validation-Errors konsistent 422 statt 404 sind. + // Text-abhängige Sub-Index-Counts (Cloze, Image-Occlusion) vor + // dem Deck-Lookup auflösen — Validation-Errors bleiben 422 statt + // versehentlich auf 404 zu fallen. let count: number; if (parsed.data.type === 'cloze') { count = subIndexCountForCloze(parsed.data.fields.text ?? ''); @@ -52,6 +53,17 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> 422 ); } + } else if (parsed.data.type === 'image-occlusion') { + count = maskRegionCount(parsed.data.fields.mask_regions ?? ''); + if (count === 0) { + return c.json( + { + error: 'invalid_input', + issues: ['image-occlusion.mask_regions must be a JSON array with >=1 valid region'], + }, + 422 + ); + } } else { count = subIndexCount(parsed.data.type); } diff --git a/apps/api/tests/cards.test.ts b/apps/api/tests/cards.test.ts index df637ef..0905bd1 100644 --- a/apps/api/tests/cards.test.ts +++ b/apps/api/tests/cards.test.ts @@ -68,13 +68,64 @@ describe('cardsRouter — Input-Validation', () => { headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', - type: 'image-occlusion', - fields: { image_ref: 'x', mask_regions: 'y' }, + type: 'audio', + fields: { audio_ref: 'x' }, }), }); expect(res.status).toBe(422); }); + it('POST mit image-occlusion ohne mask_regions ist 422', async () => { + const { app } = buildApp(); + const res = await app.request('/api/v1/cards', { + method: 'POST', + headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: 'd-1', + type: 'image-occlusion', + fields: { image_ref: 'm1' }, + }), + }); + expect(res.status).toBe(422); + }); + + it('POST mit image-occlusion mit kaputtem mask_regions ist 422', async () => { + const { app } = buildApp(); + const res = await app.request('/api/v1/cards', { + method: 'POST', + headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: 'd-1', + type: 'image-occlusion', + fields: { image_ref: 'm1', mask_regions: 'not json' }, + }), + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { issues: string[] }; + expect(body.issues[0]).toMatch(/mask_regions/); + }); + + it('POST mit gültiger image-occlusion erreicht Deck-Lookup (404 bei stub)', async () => { + const { app } = buildApp(); + const res = await app.request('/api/v1/cards', { + method: 'POST', + headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: 'd-1', + type: 'image-occlusion', + fields: { + image_ref: 'm1', + mask_regions: JSON.stringify([ + { id: 'r1', x: 0.1, y: 0.1, w: 0.1, h: 0.1 }, + ]), + }, + }), + }); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('deck_not_found'); + }); + it('POST mit cloze-Card ohne text-Feld ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { diff --git a/apps/web/src/lib/components/ImageOcclusionEditor.svelte b/apps/web/src/lib/components/ImageOcclusionEditor.svelte new file mode 100644 index 0000000..a349e6f --- /dev/null +++ b/apps/web/src/lib/components/ImageOcclusionEditor.svelte @@ -0,0 +1,216 @@ + + + +
+ + {#if uploading} +

{t('image_occlusion.uploading')}

+ {/if} + + {#if imageRef} +
+ + + {#each masks as m (m.id)} + + {/each} + {#if dragRect} + + {/if} + +
+ +

{t('image_occlusion.draw_hint')}

+ + {#if masks.length > 0} +
    + {#each masks as m, i (m.id)} +
  • + {i + 1} + setLabel(m.id, (e.currentTarget as HTMLInputElement).value)} + class="flex-1 rounded border bg-[var(--color-card)] border-[var(--color-border)] px-2 py-1 text-sm" + /> + +
  • + {/each} +
+ {/if} + {/if} +
diff --git a/apps/web/src/lib/components/ImageOcclusionView.svelte b/apps/web/src/lib/components/ImageOcclusionView.svelte new file mode 100644 index 0000000..53c2931 --- /dev/null +++ b/apps/web/src/lib/components/ImageOcclusionView.svelte @@ -0,0 +1,72 @@ + + + +
+ + + {#each masks as m (m.id)} + + {#if m.id === activeMaskId && revealed && m.label} + + {m.label} + + {/if} + {/each} + +
diff --git a/apps/web/src/lib/i18n/de.ts b/apps/web/src/lib/i18n/de.ts index 0db317d..5fac9e6 100644 --- a/apps/web/src/lib/i18n/de.ts +++ b/apps/web/src/lib/i18n/de.ts @@ -87,6 +87,8 @@ export const de: TranslationNode = { toast_basic: 'Karte angelegt', toast_basic_reverse: '2 Reviews initialisiert (front→back, back→front)', toast_cloze: '{n} Reviews initialisiert (1 pro Cluster)', + toast_image_occlusion: '{n} Reviews initialisiert (1 pro Maske)', + type_image_occlusion: 'Image-Occlusion (Bild + N Masken)', decks_load_failed: 'Decks konnten nicht geladen werden: {msg}', }, card_edit: { @@ -221,4 +223,15 @@ export const de: TranslationNode = { notifications: 'Benachrichtigungen', language_switcher: 'Sprache wechseln', }, + image_occlusion: { + image_label: 'Bild auswählen', + uploading: 'Lade Bild hoch…', + not_an_image: 'Datei ist kein Bild.', + canvas_aria: 'Bild-Canvas — ziehe mit der Maus, um Masken anzulegen', + draw_hint: 'Ziehe ein Rechteck auf dem Bild, um eine Maske anzulegen.', + label_placeholder: 'Beschriftung (optional)', + delete_mask: 'Maske löschen', + no_image_selected: 'Wähle zuerst ein Bild aus.', + no_masks: 'Lege mindestens eine Maske an.', + }, }; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index ba74452..92d7425 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -84,6 +84,8 @@ export const en: TranslationNode = { toast_basic: 'Card created', toast_basic_reverse: '2 reviews initialized (front→back, back→front)', toast_cloze: '{n} reviews initialized (1 per cluster)', + toast_image_occlusion: '{n} reviews initialized (1 per mask)', + type_image_occlusion: 'Image-Occlusion (image + N masks)', decks_load_failed: 'Could not load decks: {msg}', }, card_edit: { @@ -218,4 +220,15 @@ export const en: TranslationNode = { notifications: 'Notifications', language_switcher: 'Switch language', }, + image_occlusion: { + image_label: 'Choose image', + uploading: 'Uploading image…', + not_an_image: 'File is not an image.', + canvas_aria: 'Image canvas — drag to create masks', + draw_hint: 'Drag a rectangle on the image to create a mask.', + label_placeholder: 'Label (optional)', + delete_mask: 'Delete mask', + no_image_selected: 'Choose an image first.', + no_masks: 'Create at least one mask.', + }, }; diff --git a/apps/web/src/routes/cards/[id]/edit/+page.svelte b/apps/web/src/routes/cards/[id]/edit/+page.svelte index bb10b26..b4aed2a 100644 --- a/apps/web/src/routes/cards/[id]/edit/+page.svelte +++ b/apps/web/src/routes/cards/[id]/edit/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state'; import { extractClusterIds, + maskRegionCount, renderClozePrompt, type Card, type CardType, @@ -13,6 +14,7 @@ import { renderMarkdown } from '$lib/markdown.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts'; + import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; let card = $state(null); let cardType = $state('basic'); @@ -20,6 +22,8 @@ let back = $state(''); let text = $state(''); let extra = $state(''); + let imageRef = $state(''); + let maskRegionsJson = $state('[]'); let loading = $state(true); let saving = $state(false); let error = $state(null); @@ -48,6 +52,9 @@ if (c.type === 'cloze') { text = fields.text ?? ''; extra = fields.extra ?? ''; + } else if (c.type === 'image-occlusion') { + imageRef = fields.image_ref ?? ''; + maskRegionsJson = fields.mask_regions ?? '[]'; } else { front = fields.front ?? ''; back = fields.back ?? ''; @@ -59,11 +66,16 @@ } }); + const maskCount = $derived(maskRegionCount(maskRegionsJson)); + const canSave = $derived.by(() => { if (saving) return false; if (cardType === 'cloze') { return text.trim().length > 0 && clusterIds.length > 0; } + if (cardType === 'image-occlusion') { + return imageRef.length > 0 && maskCount > 0; + } return front.trim().length > 0 && back.trim().length > 0; }); @@ -72,12 +84,16 @@ if (!card || !canSave) return; saving = true; try { - const fields: Record = - cardType === 'cloze' - ? extra.trim() - ? { text: text.trim(), extra: extra.trim() } - : { text: text.trim() } - : { front: front.trim(), back: back.trim() }; + let fields: Record; + if (cardType === 'cloze') { + fields = extra.trim() + ? { text: text.trim(), extra: extra.trim() } + : { text: text.trim() }; + } else if (cardType === 'image-occlusion') { + fields = { image_ref: imageRef, mask_regions: maskRegionsJson }; + } else { + fields = { front: front.trim(), back: back.trim() }; + } const updated = await updateCard(card.id, { fields }); toasts.success(t('card_edit.updated')); goto(`/decks/${updated.deck_id}`); @@ -123,7 +139,9 @@

{t('card_edit.type_locked_help')}

- {#if cardType === 'cloze'} + {#if cardType === 'image-occlusion'} + + {:else if cardType === 'cloze'}
- {#if cardType === 'cloze'} + {#if cardType === 'image-occlusion'} + + {:else if cardType === 'cloze'}