diff --git a/apps/web/src/routes/cards/new/+page.svelte b/apps/web/src/routes/cards/new/+page.svelte index fe4bfc1..f384baf 100644 --- a/apps/web/src/routes/cards/new/+page.svelte +++ b/apps/web/src/routes/cards/new/+page.svelte @@ -28,10 +28,13 @@ let imageRef = $state(''); let maskRegionsJson = $state('[]'); let answer = $state(''); - let distractorPool = $state(''); let audioFileRef = $state(''); let saving = $state(false); + // Multiple-Choice builder state + let mcOptions = $state(['', '', '', '']); + let mcCorrectIdx = $state(0); + const frontHtml = $derived(renderMarkdown(front)); const backHtml = $derived(renderMarkdown(back)); const clusterIds = $derived(extractClusterIds(text)); @@ -41,6 +44,16 @@ return renderMarkdown(renderClozePrompt(text, firstCluster)); }); + const TYPE_DESCRIPTIONS: Record = { + basic: 'Klassische Karteikarte: Vorderseite → Rückseite.', + 'basic-reverse': 'Wie Basic, aber beide Richtungen werden abgefragt (2 Reviews).', + cloze: 'Lückentext — jeder {{cN::…}}-Cluster wird einzeln abgefragt.', + 'image-occlusion': 'Bild mit N ausgeblendeten Bereichen — 1 Review pro Maske.', + typing: 'Du tippst die Antwort; Fuzzy-Match erlaubt kleine Tippfehler.', + 'multiple-choice': '4 Antwortoptionen, KI generiert fehlende Distractors beim Lernen.', + 'audio-front': 'Audioclip als Vorderseite, Text als Antwort.', + }; + onMount(async () => { if (!devUser.id) { goto('/'); @@ -52,7 +65,6 @@ if (!deckId && decks.length > 0) { deckId = decks[0].id; } else if (deckId) { - // Verify deck exists for current user try { await getDeck(deckId); } catch { @@ -70,7 +82,11 @@ 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 === 'typing' || cardType === 'multiple-choice') return front.trim().length > 0 && answer.trim().length > 0; + if (cardType === 'typing') return front.trim().length > 0 && answer.trim().length > 0; + if (cardType === 'multiple-choice') { + const filledCount = mcOptions.filter((o) => o.trim()).length; + return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2; + } if (cardType === 'audio-front') return audioFileRef.trim().length > 0 && back.trim().length > 0; return front.trim().length > 0 && back.trim().length > 0; }); @@ -90,8 +106,12 @@ } 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(); + const distractors = mcOptions + .filter((_, i) => i !== mcCorrectIdx) + .filter((o) => o.trim()) + .join('\n'); + fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() }; + if (distractors) fields.distractor_pool = distractors; } else if (cardType === 'audio-front') { fields = { audio_ref: audioFileRef.trim(), back: back.trim() }; } else { @@ -121,214 +141,490 @@ } -{t('card_new.back')} -

{t('card_new.title')}

+
+ {t('card_new.back')} +

{t('card_new.title')}

-
-
- + + +
+
+ - -
+ +
+

{TYPE_DESCRIPTIONS[cardType]}

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

{t('card_new.cloze_help')}

- {#if text.trim() && clusterIds.length === 0} -

{t('card_new.cloze_no_clusters')}

- {:else if clusterIds.length > 0} -

- {t('card_new.cloze_clusters_detected', { - n: clusterIds.length, - ids: clusterIds.join(', c'), - })} -

- {/if} -
+ +
+ {#if cardType === 'image-occlusion'} + - {#if clozePreviewHtml} -
-
- {t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })} + {:else if cardType === 'cloze'} + + + {#if clozePreviewHtml} +
+ {t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })} +
{@html clozePreviewHtml}
+
+ {/if} + + + + {:else if cardType === 'multiple-choice'} + + +
+ Antwortoptionen + Markiere die richtige Antwort. Nicht befüllte Optionen werden ignoriert — KI ergänzt fehlende Distractors beim Lernen. +
+ {#each mcOptions as opt, i} + {@const letter = ['A', 'B', 'C', 'D'][i]} + {@const isCorrect = mcCorrectIdx === i} +
+ + {letter} + + {#if isCorrect} + ✓ Richtig + {/if} +
+ {/each} +
-
{@html clozePreviewHtml}
-
- {/if} - - {:else if cardType === 'typing' || cardType === 'multiple-choice'} -
-
- + + +
+ + {:else if cardType === 'audio-front'} + - {#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} -
-
- - {#if front.trim()} -
-
{t('card_new.preview_label')}
-
{@html frontHtml}
-
- {/if} -
- -
-
- {/if} -
- - {t('card_new.cancel')} -
- + {:else} + +
+ + + +
+ {/if} +
+ + +
+ + {t('card_new.cancel')} +
+ +
+ +