From a612ad05d61f4cee562b1735c7501591a466c8bf Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 10 May 2026 15:36:17 +0200 Subject: [PATCH] feat(cards/new): typing, multiple-choice, audio-front im Erstellungs-UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropdown um 3 neue Types erweitert - typing/multiple-choice: front + answer Felder (korrekte field-Namen) - multiple-choice: optionaler distractor_pool (Fallback für kleine Decks) - audio-front: audio_ref Text-Input + back Antworttext - canSave + onSubmit korrekt pro Type - i18n de + en vollständig Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/lib/i18n/de.ts | 13 +++ apps/web/src/lib/i18n/en.ts | 13 +++ apps/web/src/routes/cards/new/+page.svelte | 108 +++++++++++++++++++-- 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/i18n/de.ts b/apps/web/src/lib/i18n/de.ts index f182f38..da90080 100644 --- a/apps/web/src/lib/i18n/de.ts +++ b/apps/web/src/lib/i18n/de.ts @@ -97,6 +97,19 @@ export const de: TranslationNode = { 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)', + type_typing: 'Typing (Texteingabe, Fuzzy-Match)', + type_multiple_choice: 'Multiple-Choice (4 Optionen, KI-Distractors)', + type_audio_front: 'Audio-Front (Hören + Antworten)', + answer_label: 'Antwort (Markdown)', + answer_placeholder: 'Richtige Antwort', + distractor_pool_label: 'Distractor-Pool (optional)', + distractor_pool_placeholder: 'Ein Eintrag pro Zeile — Fallback wenn das Deck zu klein für KI-Distractors ist', + audio_ref_label: 'Audio-Referenz (media_ref)', + audio_ref_placeholder: 'z. B. abc123.mp3', + back_audio_label: 'Antworttext (Markdown)', + toast_typing: 'Typing-Karte angelegt', + toast_multiple_choice: 'Multiple-Choice-Karte angelegt', + toast_audio_front: 'Audio-Front-Karte angelegt', decks_load_failed: 'Decks konnten nicht geladen werden: {msg}', }, card_edit: { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 8860bb3..eb99780 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -94,6 +94,19 @@ export const en: TranslationNode = { 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)', + type_typing: 'Typing (text input, fuzzy match)', + type_multiple_choice: 'Multiple-Choice (4 options, AI distractors)', + type_audio_front: 'Audio-Front (listen + answer)', + answer_label: 'Answer (Markdown)', + answer_placeholder: 'Correct answer', + distractor_pool_label: 'Distractor pool (optional)', + distractor_pool_placeholder: 'One entry per line — fallback when the deck is too small for AI distractors', + audio_ref_label: 'Audio reference (media_ref)', + audio_ref_placeholder: 'e.g. abc123.mp3', + back_audio_label: 'Answer text (Markdown)', + toast_typing: 'Typing card created', + toast_multiple_choice: 'Multiple-choice card created', + toast_audio_front: 'Audio-front card created', decks_load_failed: 'Could not load decks: {msg}', }, card_edit: { diff --git a/apps/web/src/routes/cards/new/+page.svelte b/apps/web/src/routes/cards/new/+page.svelte index 614f90e..fe4bfc1 100644 --- a/apps/web/src/routes/cards/new/+page.svelte +++ b/apps/web/src/routes/cards/new/+page.svelte @@ -27,6 +27,9 @@ let extra = $state(''); let imageRef = $state(''); let maskRegionsJson = $state('[]'); + let answer = $state(''); + let distractorPool = $state(''); + let audioFileRef = $state(''); let saving = $state(false); const frontHtml = $derived(renderMarkdown(front)); @@ -65,12 +68,10 @@ const canSave = $derived.by(() => { if (saving || !deckId) return false; - if (cardType === 'cloze') { - return text.trim().length > 0 && clusterIds.length > 0; - } - if (cardType === 'image-occlusion') { - return imageRef.length > 0 && maskCount > 0; - } + if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0; + if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 0; + if (cardType === 'typing' || cardType === 'multiple-choice') return front.trim().length > 0 && answer.trim().length > 0; + if (cardType === 'audio-front') return audioFileRef.trim().length > 0 && back.trim().length > 0; return front.trim().length > 0 && back.trim().length > 0; }); @@ -86,6 +87,13 @@ : { text: text.trim() }; } else if (cardType === 'image-occlusion') { fields = { image_ref: imageRef, mask_regions: maskRegionsJson }; + } else if (cardType === 'typing') { + fields = { front: front.trim(), answer: answer.trim() }; + } else if (cardType === 'multiple-choice') { + fields = { front: front.trim(), answer: answer.trim() }; + if (distractorPool.trim()) fields.distractor_pool = distractorPool.trim(); + } else if (cardType === 'audio-front') { + fields = { audio_ref: audioFileRef.trim(), back: back.trim() }; } else { fields = { front: front.trim(), back: back.trim() }; } @@ -95,9 +103,15 @@ ? t('card_new.toast_cloze', { n: clusterIds.length }) : cardType === 'image-occlusion' ? t('card_new.toast_image_occlusion', { n: maskCount }) - : cardType === 'basic-reverse' - ? t('card_new.toast_basic_reverse') - : t('card_new.toast_basic'); + : cardType === 'typing' + ? t('card_new.toast_typing') + : cardType === 'multiple-choice' + ? t('card_new.toast_multiple_choice') + : cardType === 'audio-front' + ? t('card_new.toast_audio_front') + : cardType === 'basic-reverse' + ? t('card_new.toast_basic_reverse') + : t('card_new.toast_basic'); toasts.success(msg); goto(`/decks/${card.deck_id}`); } catch (e) { @@ -137,6 +151,9 @@ + + + @@ -186,6 +203,79 @@ class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm" > + {:else if cardType === 'typing' || cardType === 'multiple-choice'} +
+
+ + {#if front.trim()} +
+
{t('card_new.preview_label')}
+
{@html frontHtml}
+
+ {/if} +
+ +
+ +
+
+ + {#if cardType === 'multiple-choice'} + + {/if} + {:else if cardType === 'audio-front'} + + + {#if back.trim()} +
+
{t('card_new.preview_label')}
+
{@html backHtml}
+
+ {/if} {:else}