refactor(cards/new): UI-Redesign + Multiple-Choice Option-Builder

- Section-Cards statt flaches Form-Layout
- Typ-Beschreibungszeile unter dem Dropdown
- Multiple-Choice: 4-Options-Builder mit Radio-Auswahl für richtige
  Antwort; Distractors werden aus den anderen Optionen extrahiert
- Typing: Alias-Hinweis im Formular
- Audio-Front: Hinweis zum media_ref-Flow
- Einheitliche Input-/Button-Styles mit Theme-Tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:45:21 +02:00
parent a612ad05d6
commit b5d3a29335

View file

@ -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<CardType, string> = {
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,32 +141,26 @@
}
</script>
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>{t('card_new.back')}</a
>
<h1 class="mt-2 text-2xl font-semibold">{t('card_new.title')}</h1>
<div class="page-shell">
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="back-link">{t('card_new.back')}</a>
<h1 class="page-title">{t('card_new.title')}</h1>
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-medium">{t('card_new.deck_label')}</span>
<select
bind:value={deckId}
required
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<form class="card-form" onsubmit={onSubmit}>
<!-- Deck + Typ -->
<section class="form-section">
<div class="grid-2">
<label class="field">
<span class="field-label">{t('card_new.deck_label')}</span>
<select bind:value={deckId} required class="input">
{#each decks as d}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<label class="block">
<span class="text-sm font-medium">{t('card_new.type_label')}</span>
<select
bind:value={cardType}
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<label class="field">
<span class="field-label">{t('card_new.type_label')}</span>
<select bind:value={cardType} class="input">
<option value="basic">{t('card_new.type_basic')}</option>
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
<option value="cloze">{t('card_new.type_cloze')}</option>
@ -157,178 +171,460 @@
</select>
</label>
</div>
<p class="type-hint">{TYPE_DESCRIPTIONS[cardType]}</p>
</section>
<!-- Type-specific content -->
<section class="form-section">
{#if cardType === 'image-occlusion'}
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'cloze'}
<div>
<label class="block">
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
<label class="field">
<span class="field-label">{t('card_new.cloze_text_label')}</span>
<textarea
bind:value={text}
required
rows="6"
placeholder={t('card_new.cloze_text_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"
class="input mono"
></textarea>
</label>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_new.cloze_help')}</p>
<span class="field-hint">{t('card_new.cloze_help')}</span>
{#if text.trim() && clusterIds.length === 0}
<p class="mt-1 text-xs text-[hsl(var(--color-error))]">{t('card_new.cloze_no_clusters')}</p>
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
{:else if clusterIds.length > 0}
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{t('card_new.cloze_clusters_detected', {
n: clusterIds.length,
ids: clusterIds.join(', c'),
})}
</p>
<span class="field-hint">
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
</span>
{/if}
</div>
</label>
{#if clozePreviewHtml}
<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.cloze_preview_label', { first: clusterIds[0] ?? 1 })}
</div>
<div class="prose prose-sm max-w-none">{@html clozePreviewHtml}</div>
<div class="preview-box">
<span class="preview-label">{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}</span>
<div class="prose prose-sm">{@html clozePreviewHtml}</div>
</div>
{/if}
<label class="block">
<span class="text-sm font-medium">{t('card_new.cloze_extra_label')}</span>
<label class="field">
<span class="field-label">{t('card_new.cloze_extra_label')}</span>
<textarea
bind:value={extra}
rows="3"
placeholder={t('card_new.cloze_extra_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"
class="input mono"
></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>
{:else if cardType === 'multiple-choice'}
<label class="field">
<span class="field-label">{t('card_new.front_label')}</span>
<textarea
bind:value={front}
required
rows="4"
placeholder={t('card_new.front_placeholder')}
class="input mono"
></textarea>
{#if front.trim()}
<div class="preview-box mt-2">
<span class="preview-label">{t('card_new.preview_label')}</span>
<div class="prose prose-sm">{@html frontHtml}</div>
</div>
{/if}
</label>
<div class="field">
<span class="field-label">Antwortoptionen</span>
<span class="field-hint">Markiere die richtige Antwort. Nicht befüllte Optionen werden ignoriert — KI ergänzt fehlende Distractors beim Lernen.</span>
<div class="mc-options">
{#each mcOptions as opt, i}
{@const letter = ['A', 'B', 'C', 'D'][i]}
{@const isCorrect = mcCorrectIdx === i}
<div class="mc-option" class:mc-option-correct={isCorrect}>
<button
type="button"
class="mc-radio"
class:mc-radio-selected={isCorrect}
onclick={() => { mcCorrectIdx = i; }}
title="Als richtige Antwort markieren"
aria-label="Option {letter} als richtig markieren"
aria-pressed={isCorrect}
>
{#if isCorrect}
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="3.5" />
</svg>
{/if}
</button>
<span class="mc-letter" class:mc-letter-correct={isCorrect}>{letter}</span>
<input
type="text"
bind:value={mcOptions[i]}
placeholder="Option {letter}"
class="mc-input"
/>
{#if isCorrect}
<span class="mc-badge">✓ Richtig</span>
{/if}
</div>
{/each}
</div>
</div>
{:else if cardType === 'typing'}
<div class="grid-2">
<label class="field">
<span class="field-label">{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"
class="input mono"
></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 class="preview-box mt-2">
<span class="preview-label">{t('card_new.preview_label')}</span>
<div class="prose prose-sm">{@html frontHtml}</div>
</div>
{/if}
</div>
</label>
<div>
<label class="block">
<span class="text-sm font-medium">{t('card_new.answer_label')}</span>
<label class="field">
<span class="field-label">{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"
class="input mono"
></textarea>
<span class="field-hint">Kommagetrennte Aliase sind erlaubt: <code>Paris, Paris (Frankreich)</code></span>
</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>
<label class="field">
<span class="field-label">{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"
class="input mono"
/>
<span class="field-hint">Die media_ref-ID aus dem Media-Upload-Endpoint.</span>
</label>
<label class="block">
<span class="text-sm font-medium">{t('card_new.back_audio_label')}</span>
<label class="field">
<span class="field-label">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="6"
rows="5"
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"
class="input mono"
></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 class="preview-box">
<span class="preview-label">{t('card_new.preview_label')}</span>
<div class="prose prose-sm">{@html backHtml}</div>
</div>
{/if}
{:else}
<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>
<!-- basic / basic-reverse -->
<div class="grid-2">
<label class="field">
<span class="field-label">{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"
class="input mono"
></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 class="preview-box mt-2">
<span class="preview-label">{t('card_new.preview_label')}</span>
<div class="prose prose-sm">{@html frontHtml}</div>
</div>
{/if}
</div>
</label>
<div>
<label class="block">
<span class="text-sm font-medium">{t('card_new.back_label')}</span>
<label class="field">
<span class="field-label">{t('card_new.back_label')}</span>
<textarea
bind:value={back}
required
rows="8"
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"
class="input mono"
></textarea>
</label>
{#if back.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 backHtml}</div>
<div class="preview-box mt-2">
<span class="preview-label">{t('card_new.preview_label')}</span>
<div class="prose prose-sm">{@html backHtml}</div>
</div>
{/if}
</div>
</label>
</div>
{/if}
</section>
<div class="flex items-center gap-3">
<button
type="submit"
disabled={!canSave}
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
<!-- Actions -->
<div class="actions">
<button type="submit" disabled={!canSave} class="btn-primary">
{saving ? t('card_new.creating') : t('card_new.create')}
</button>
<a
href={deckId ? `/decks/${deckId}` : '/decks'}
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">{t('card_new.cancel')}</a
>
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="btn-ghost">{t('card_new.cancel')}</a>
</div>
</form>
</form>
</div>
<style>
.page-shell {
max-width: 52rem;
margin: 0 auto;
}
.back-link {
display: inline-block;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
transition: color 0.12s;
}
.back-link:hover { color: hsl(var(--color-foreground)); }
.page-title {
margin-top: 0.5rem;
margin-bottom: 1.5rem;
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.card-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-section {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) {
.grid-2 { grid-template-columns: 1fr; }
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
letter-spacing: 0.01em;
}
.field-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.field-error {
font-size: 0.75rem;
color: hsl(var(--color-error));
}
.input {
display: block;
width: 100%;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
line-height: 1.5;
transition: border-color 0.12s;
resize: vertical;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.6);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input.mono { font-family: ui-monospace, 'Cascadia Code', monospace; }
.type-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
padding-top: 0.125rem;
}
.preview-box {
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
padding: 0.625rem 0.75rem;
}
.preview-label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.375rem;
}
/* Multiple-Choice option builder */
.mc-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.mc-option {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
transition: border-color 0.12s, background-color 0.12s;
}
.mc-option-correct {
border-color: hsl(var(--color-success) / 0.5);
background: hsl(var(--color-success) / 0.06);
}
.mc-radio {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 2px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-primary));
transition: border-color 0.12s, background-color 0.12s;
padding: 0;
}
.mc-radio:hover { border-color: hsl(var(--color-primary) / 0.6); }
.mc-radio-selected {
border-color: hsl(var(--color-success));
background: hsl(var(--color-success) / 0.12);
color: hsl(var(--color-success));
}
.mc-letter {
flex-shrink: 0;
width: 1.375rem;
height: 1.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
color: hsl(var(--color-muted-foreground));
}
.mc-letter-correct {
background: hsl(var(--color-success) / 0.15);
border-color: hsl(var(--color-success) / 0.4);
color: hsl(var(--color-success));
}
.mc-input {
flex: 1;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
padding: 0;
min-width: 0;
}
.mc-input:focus { outline: none; }
.mc-input::placeholder { color: hsl(var(--color-muted-foreground)); }
.mc-badge {
flex-shrink: 0;
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-success));
white-space: nowrap;
}
/* Actions */
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.5rem 1.125rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font: inherit;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: background-color 0.12s;
}
.btn-primary:hover:not(:disabled) { background: hsl(var(--color-primary) / 0.88); }
.btn-primary:disabled { opacity: 0.45; cursor: default; }
.btn-ghost {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
transition: color 0.12s;
}
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
/* prose reset inside preview-box */
.prose :global(p) { margin: 0 0 0.5em; font-size: 0.875rem; }
.prose :global(p:last-child) { margin-bottom: 0; }
.prose :global(code) {
font-size: 0.8125rem;
background: hsl(var(--color-muted) / 0.5);
padding: 0.1em 0.3em;
border-radius: 0.25rem;
}
</style>