feat(web): multiple-choice — explanation-Feld, Edit-Bug-Fix, State-Reset

- MultipleChoiceCardForm: optionales `explanation`-Feld (Erklärung wird
  nach Auswahl angezeigt); `field-optional`-Style ergänzt
- MultipleChoiceView: `explanation`-Prop; zeigt Erklärungsbox nach
  Auswahl (grün bei richtig, neutral bei falsch); `{#key card_id}`-Block
  erzwingt Remount bei Kartenwechsel — behebt State-Leak zwischen Karten
- edit/+page.svelte: MC-Edit-Bug behoben — Karten wurden fälschlich mit
  `{front, back}` gespeichert und haben `answer`/`distractor_pool`
  überschrieben; `MultipleChoiceCardForm` importiert und verdrahtet;
  `canSave` und `onSubmit` handhaben MC korrekt; lädt `answer` +
  `distractor_pool` beim Öffnen zurück in `mcOptions`-Array
- new/+page.svelte: `mcExplanation`-State an Form gebunden und beim
  Speichern als `fields.explanation` gesetzt
- study/+page.svelte: `explanation` aus Card-Fields extrahiert und
  an MultipleChoiceView durchgereicht
- scripts/migrate-factfulness-to-mc.ts: einmalige Migration — 13
  Factfulness-Quiz-Karten von `basic` (A/B/C in Freitext) auf
  `multiple-choice` mit strukturierten Feldern konvertiert; Deck auf
  `visibility=public` gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:24:18 +02:00
parent 41ecec16c3
commit 9839737049
6 changed files with 209 additions and 6 deletions

View file

@ -5,10 +5,12 @@
front = $bindable(),
mcOptions = $bindable(),
mcCorrectIdx = $bindable(),
mcExplanation = $bindable(''),
}: {
front: string;
mcOptions: string[];
mcCorrectIdx: number;
mcExplanation: string;
} = $props();
</script>
@ -59,6 +61,17 @@
</div>
</div>
<div class="field">
<span class="field-label">Erklärung <span class="field-optional">(optional)</span></span>
<span class="field-hint">Wird nach der Auswahl angezeigt — erklärt warum die Antwort richtig ist.</span>
<textarea
bind:value={mcExplanation}
rows="3"
placeholder="z.B. Weil … / Hintergrund: …"
class="input"
></textarea>
</div>
<style>
.field {
display: flex;
@ -73,6 +86,11 @@
letter-spacing: 0.01em;
}
.field-optional {
font-weight: 400;
color: hsl(var(--color-muted-foreground));
}
.field-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));

View file

@ -7,6 +7,7 @@
promptHtml,
answer,
distractorPool,
explanation,
deckId,
cardId,
ongrade,
@ -14,6 +15,7 @@
promptHtml: string;
answer: string;
distractorPool?: string;
explanation?: string;
deckId: string;
cardId: string;
ongrade: (r: Rating) => void;
@ -146,6 +148,13 @@
{/each}
</div>
{#if selected !== null && explanation}
<div class="explanation" class:explanation-correct={correct} class:explanation-wrong={!correct}>
<span class="explanation-label">{correct ? '✓' : ''}</span>
<p class="explanation-text">{explanation}</p>
</div>
{/if}
{#if selected !== null}
<button class="confirm-btn" onclick={confirm}>
{correct ? 'Weiter' : 'Verstanden'} <kbd>Space</kbd>
@ -295,6 +304,38 @@
.option-correct .option-icon { color: hsl(var(--color-success)); }
.option-wrong .option-icon { color: hsl(var(--color-error)); }
.explanation {
display: flex;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
.explanation-correct {
background: hsl(var(--color-success) / 0.08);
border-color: hsl(var(--color-success) / 0.4);
}
.explanation-wrong {
background: hsl(var(--color-muted) / 0.5);
}
.explanation-label {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
padding-top: 0.125rem;
}
.explanation-correct .explanation-label {
color: hsl(var(--color-success));
}
.explanation-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--color-foreground));
}
.confirm-btn {
align-self: center;
display: inline-flex;

View file

@ -16,6 +16,7 @@
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
let card = $state<Card | null>(null);
let cardType = $state<CardType>('basic');
@ -25,6 +26,9 @@
let extra = $state('');
let imageRef = $state('');
let maskRegionsJson = $state('[]');
let mcOptions = $state(['', '', '', '']);
let mcCorrectIdx = $state(0);
let mcExplanation = $state('');
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
@ -56,6 +60,14 @@
} else if (c.type === 'image-occlusion') {
imageRef = fields.image_ref ?? '';
maskRegionsJson = fields.mask_regions ?? '[]';
} else if (c.type === 'multiple-choice') {
front = fields.front ?? '';
const answer = fields.answer ?? '';
const pool = fields.distractor_pool ?? '';
const distractors = pool.split('\n').map((s) => s.trim()).filter((s) => s);
mcOptions = [answer, ...distractors, '', '', ''].slice(0, 4);
mcCorrectIdx = 0;
mcExplanation = fields.explanation ?? '';
} else {
front = fields.front ?? '';
back = fields.back ?? '';
@ -71,11 +83,11 @@
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;
if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0;
if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 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;
}
return front.trim().length > 0 && back.trim().length > 0;
});
@ -92,6 +104,14 @@
: { text: text.trim() };
} else if (cardType === 'image-occlusion') {
fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
} else if (cardType === 'multiple-choice') {
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;
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
} else {
fields = { front: front.trim(), back: back.trim() };
}
@ -142,6 +162,8 @@
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
{#if cardType === 'image-occlusion'}
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'multiple-choice'}
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx bind:mcExplanation />
{:else if cardType === 'cloze'}
<div>
<label class="block">

View file

@ -40,6 +40,7 @@
// Multiple-Choice builder state
let mcOptions = $state(['', '', '', '']);
let mcCorrectIdx = $state(0);
let mcExplanation = $state('');
const frontHtml = $derived(renderMarkdown(front));
const backHtml = $derived(renderMarkdown(back));
@ -125,6 +126,7 @@
.join('\n');
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
if (distractors) fields.distractor_pool = distractors;
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
} else if (cardType === 'audio-front') {
fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
} else {
@ -198,7 +200,7 @@
<ClozeCardForm bind:text bind:extra {clusterIds} />
{:else if cardType === 'multiple-choice'}
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx />
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx bind:mcExplanation />
{:else if cardType === 'typing'}
<div class="grid-2">

View file

@ -105,6 +105,7 @@
return {
answer: fields.answer ?? '',
distractorPool: fields.distractor_pool || undefined,
explanation: fields.explanation || undefined,
};
});
@ -256,14 +257,17 @@
{revealed}
/>
{:else if isMultipleChoice && multipleChoiceData}
{#key current?.card_id}
<MultipleChoiceView
promptHtml={promptHtml}
answer={multipleChoiceData.answer}
distractorPool={multipleChoiceData.distractorPool}
explanation={multipleChoiceData.explanation}
deckId={deckId}
cardId={current?.card_id ?? ''}
ongrade={grade}
/>
{/key}
{:else if isTyping && typingData}
<TypingView
promptHtml={promptHtml}