feat(cards/new): typing, multiple-choice, audio-front im Erstellungs-UI

- 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 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:36:17 +02:00
parent 2b36990e43
commit a612ad05d6
3 changed files with 125 additions and 9 deletions

View file

@ -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: {

View file

@ -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: {

View file

@ -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 @@
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
<option value="cloze">{t('card_new.type_cloze')}</option>
<option value="image-occlusion">{t('card_new.type_image_occlusion')}</option>
<option value="typing">{t('card_new.type_typing')}</option>
<option value="multiple-choice">{t('card_new.type_multiple_choice')}</option>
<option value="audio-front">{t('card_new.type_audio_front')}</option>
</select>
</label>
</div>
@ -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"
></textarea>
</label>
{:else if cardType === 'typing' || cardType === 'multiple-choice'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block">
<span class="text-sm font-medium">{t('card_new.front_label')}</span>
<textarea
bind:value={front}
required
rows="8"
placeholder={t('card_new.front_placeholder')}
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"
></textarea>
</label>
{#if front.trim()}
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
</div>
{/if}
</div>
<div>
<label class="block">
<span class="text-sm font-medium">{t('card_new.answer_label')}</span>
<textarea
bind:value={answer}
required
rows="8"
placeholder={t('card_new.answer_placeholder')}
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"
></textarea>
</label>
</div>
</div>
{#if cardType === 'multiple-choice'}
<label class="block">
<span class="text-sm font-medium">{t('card_new.distractor_pool_label')}</span>
<textarea
bind:value={distractorPool}
rows="4"
placeholder={t('card_new.distractor_pool_placeholder')}
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"
></textarea>
</label>
{/if}
{:else if cardType === 'audio-front'}
<label class="block">
<span class="text-sm font-medium">{t('card_new.audio_ref_label')}</span>
<input
type="text"
bind:value={audioFileRef}
required
placeholder={t('card_new.audio_ref_placeholder')}
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"
/>
</label>
<label class="block">
<span class="text-sm font-medium">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="6"
placeholder={t('card_new.back_placeholder')}
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"
></textarea>
</label>
{#if back.trim()}
<div class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
</div>
{/if}
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>