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:
parent
41ecec16c3
commit
9839737049
6 changed files with 209 additions and 6 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue