feat(cards/new): Live-Kartenvorschau neben dem Formular

- 2-Spalten-Layout: Formular links, sticky Preview rechts
- CardSurface (hero, raised) als Preview-Container
- Typ-spezifische Vorschau:
  - basic/basic-reverse: Flip-Toggle Vorderseite ↔ Rückseite
  - multiple-choice: Frage + Option-Buttons mit grüner Markierung
  - typing: Frage + deaktiviertes Eingabefeld
  - audio-front: Play-Button-Mockup + Rückseite nach Flip
  - cloze: Erste-Cluster-Vorschau live
  - image-occlusion: Platzhalter-Hinweis
- Preview aktualisiert sich reaktiv beim Tippen
- Responsive: auf < 800px Preview über Formular

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

View file

@ -15,6 +15,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 CardSurface from '$lib/components/CardSurface.svelte';
type DeckLite = { id: string; name: string };
@ -30,6 +31,7 @@
let answer = $state('');
let audioFileRef = $state('');
let saving = $state(false);
let previewFlipped = $state(false);
// Multiple-Choice builder state
let mcOptions = $state(['', '', '', '']);
@ -37,13 +39,20 @@
const frontHtml = $derived(renderMarkdown(front));
const backHtml = $derived(renderMarkdown(back));
const answerHtml = $derived(renderMarkdown(answer));
const clusterIds = $derived(extractClusterIds(text));
const clozePreviewHtml = $derived.by(() => {
const firstCluster = clusterIds[0];
if (firstCluster === undefined) return '';
if (firstCluster === undefined) return renderMarkdown(text);
return renderMarkdown(renderClozePrompt(text, firstCluster));
});
// Reset flip state when type changes
$effect(() => {
cardType;
previewFlipped = false;
});
const TYPE_DESCRIPTIONS: Record<CardType, string> = {
basic: 'Klassische Karteikarte: Vorderseite → Rückseite.',
'basic-reverse': 'Wie Basic, aber beide Richtungen werden abgefragt (2 Reviews).',
@ -145,247 +154,354 @@
<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="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>
<div class="page-layout">
<!-- LEFT: Form -->
<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="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>
<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>
<p class="type-hint">{TYPE_DESCRIPTIONS[cardType]}</p>
</section>
<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>
<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>
<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 />
<!-- Type-specific fields -->
<section class="form-section">
{#if cardType === 'image-occlusion'}
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'cloze'}
<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="input mono"
></textarea>
<span class="field-hint">{t('card_new.cloze_help')}</span>
{#if text.trim() && clusterIds.length === 0}
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
{:else if clusterIds.length > 0}
<span class="field-hint">
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
</span>
{/if}
</label>
{:else if cardType === 'cloze'}
<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="input mono"
></textarea>
<span class="field-hint">{t('card_new.cloze_help')}</span>
{#if text.trim() && clusterIds.length === 0}
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
{:else if clusterIds.length > 0}
<span class="field-hint">
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
</span>
{/if}
</label>
<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="input mono"
></textarea>
</label>
{#if clozePreviewHtml}
<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="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="input mono"
></textarea>
</label>
{: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>
{: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>
</label>
<div class="field">
<span class="field-label">Antwortoptionen</span>
<span class="field-hint">Markiere die richtige Antwort. Leere Optionen werden ignoriert — KI ergänzt fehlende Distractors automatisch.</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; }}
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 && opt.trim()}
<span class="mc-badge">✓ Richtig</span>
{/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>
{/each}
</div>
</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="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>
<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="input mono"
></textarea>
<span class="field-hint">Kommagetrennte Aliase sind erlaubt: <code>Paris, Paris (Frankreich)</code></span>
</label>
</div>
{:else if cardType === 'audio-front'}
<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="input mono"
/>
<span class="field-hint">Die media_ref-ID aus dem Media-Upload-Endpoint.</span>
</label>
<label class="field">
<span class="field-label">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="5"
placeholder={t('card_new.back_placeholder')}
class="input mono"
></textarea>
</label>
{#if back.trim()}
<div class="preview-box">
<span class="preview-label">{t('card_new.preview_label')}</span>
<div class="prose prose-sm">{@html backHtml}</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="input mono"
></textarea>
</label>
<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="input mono"
></textarea>
<span class="field-hint">Kommagetrennte Aliase erlaubt: <code>Paris, Paris (Frankreich)</code></span>
</label>
</div>
{/if}
{:else}
<!-- basic / basic-reverse -->
<div class="grid-2">
{:else if cardType === 'audio-front'}
<label class="field">
<span class="field-label">{t('card_new.front_label')}</span>
<textarea
bind:value={front}
<span class="field-label">{t('card_new.audio_ref_label')}</span>
<input
type="text"
bind:value={audioFileRef}
required
rows="8"
placeholder={t('card_new.front_placeholder')}
placeholder={t('card_new.audio_ref_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}
/>
<span class="field-hint">Die media_ref-ID aus dem Media-Upload-Endpoint.</span>
</label>
<label class="field">
<span class="field-label">{t('card_new.back_label')}</span>
<span class="field-label">{t('card_new.back_audio_label')}</span>
<textarea
bind:value={back}
required
rows="8"
rows="5"
placeholder={t('card_new.back_placeholder')}
class="input mono"
></textarea>
{#if back.trim()}
<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}
</label>
</div>
{/if}
</section>
<!-- 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="btn-ghost">{t('card_new.cancel')}</a>
</div>
</form>
{:else}
<!-- 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="9"
placeholder={t('card_new.front_placeholder')}
class="input mono"
></textarea>
</label>
<label class="field">
<span class="field-label">{t('card_new.back_label')}</span>
<textarea
bind:value={back}
required
rows="9"
placeholder={t('card_new.back_placeholder')}
class="input mono"
></textarea>
</label>
</div>
{/if}
</section>
<!-- 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="btn-ghost">{t('card_new.cancel')}</a>
</div>
</form>
<!-- RIGHT: Live-Vorschau -->
<aside class="preview-col">
<div class="preview-sticky">
<p class="preview-pane-label">Live-Vorschau</p>
<CardSurface size="hero" raised class="preview-card-surface">
{#snippet children()}
<div class="preview-inner">
{#if cardType === 'image-occlusion'}
<div class="preview-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
<span>Bildvorschau nicht verfügbar</span>
</div>
{:else if cardType === 'audio-front'}
<div class="preview-audio">
<div class="preview-audio-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
{#if audioFileRef}
<span class="preview-audio-ref">{audioFileRef}</span>
{:else}
<span class="preview-empty-hint">Audio-Datei</span>
{/if}
</div>
{#if previewFlipped}
<div class="preview-divider"></div>
<div class="preview-prose">
{#if back.trim()}
{@html backHtml}
{:else}
<span class="preview-empty-hint">Antworttext…</span>
{/if}
</div>
{:else}
<button type="button" class="preview-reveal-btn" onclick={() => { previewFlipped = true; }}>
Lösung zeigen
</button>
{/if}
{:else if cardType === 'cloze'}
<div class="preview-prose">
{#if text.trim()}
{@html clozePreviewHtml}
{:else}
<span class="preview-empty-hint">Lückentext…</span>
{/if}
</div>
{:else if cardType === 'multiple-choice'}
<div class="preview-prose preview-prompt-mc">
{#if front.trim()}
{@html frontHtml}
{:else}
<span class="preview-empty-hint">Frage…</span>
{/if}
</div>
<div class="preview-mc">
{#each mcOptions as opt, i}
{@const letter = ['A', 'B', 'C', 'D'][i]}
{@const isCorrect = mcCorrectIdx === i}
{#if opt.trim()}
<div class="preview-mc-opt" class:preview-mc-opt-correct={isCorrect}>
<span class="preview-mc-key">{letter}</span>
<span class="preview-mc-text">{opt}</span>
</div>
{/if}
{/each}
{#if mcOptions.every((o) => !o.trim())}
<span class="preview-empty-hint" style="font-size:0.8125rem">Optionen AD eintragen…</span>
{/if}
</div>
{:else if cardType === 'typing'}
<div class="preview-prose">
{#if front.trim()}
{@html frontHtml}
{:else}
<span class="preview-empty-hint">Frage…</span>
{/if}
</div>
<div class="preview-typing">
<input
type="text"
disabled
placeholder="Antwort eingeben…"
class="preview-typing-input"
/>
<button type="button" class="preview-typing-btn" disabled></button>
</div>
{#if previewFlipped && answer.trim()}
<div class="preview-divider"></div>
<div class="preview-answer-badge preview-answer-badge-correct">✓ Richtig</div>
<div class="preview-prose" style="font-size:0.875rem">
{@html answerHtml}
</div>
{/if}
{:else}
<!-- basic / basic-reverse -->
{#if previewFlipped}
<div class="preview-side-label">Rückseite</div>
<div class="preview-prose">
{#if back.trim()}
{@html backHtml}
{:else}
<span class="preview-empty-hint">Rückseite…</span>
{/if}
</div>
<button type="button" class="preview-flip-link" onclick={() => { previewFlipped = false; }}>
← Vorderseite
</button>
{:else}
<div class="preview-side-label">Vorderseite</div>
<div class="preview-prose">
{#if front.trim()}
{@html frontHtml}
{:else}
<span class="preview-empty-hint">Vorderseite…</span>
{/if}
</div>
<button type="button" class="preview-reveal-btn" onclick={() => { previewFlipped = true; }}>
Lösung zeigen
</button>
{/if}
{/if}
</div>
{/snippet}
</CardSurface>
{#if cardType === 'basic' || cardType === 'basic-reverse' || cardType === 'audio-front'}
<p class="preview-flip-hint">Klicke „Lösung zeigen" um zu flippen</p>
{:else if cardType === 'multiple-choice'}
<p class="preview-flip-hint">Vorschau aktualisiert sich live</p>
{:else if cardType === 'typing'}
<p class="preview-flip-hint">Eingabe-Feld ist im Lern-Modus aktiv</p>
{/if}
</div>
</aside>
</div>
</div>
<style>
.page-shell {
max-width: 52rem;
max-width: 72rem;
margin: 0 auto;
}
@ -406,6 +522,22 @@
color: hsl(var(--color-foreground));
}
/* 2-column layout: form | preview */
.page-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 22rem;
gap: 1.75rem;
align-items: start;
}
@media (max-width: 800px) {
.page-layout {
grid-template-columns: 1fr;
}
.preview-col {
order: -1;
}
}
.card-form {
display: flex;
flex-direction: column;
@ -449,6 +581,12 @@
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.field-hint code {
font-size: 0.75rem;
background: hsl(var(--color-muted) / 0.5);
padding: 0.1em 0.3em;
border-radius: 0.2rem;
}
.field-error {
font-size: 0.75rem;
@ -465,7 +603,7 @@
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
line-height: 1.5;
transition: border-color 0.12s;
transition: border-color 0.12s, box-shadow 0.12s;
resize: vertical;
}
.input:focus {
@ -479,27 +617,9 @@
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 option builder */
.mc-options {
display: flex;
flex-direction: column;
@ -533,15 +653,14 @@
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-primary));
transition: border-color 0.12s, background-color 0.12s;
transition: border-color 0.12s;
padding: 0;
color: hsl(var(--color-success));
}
.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 {
@ -618,13 +737,257 @@
}
.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) {
/* Preview column */
.preview-col {
min-width: 0;
}
.preview-sticky {
position: sticky;
top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.preview-pane-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.preview-flip-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
margin: 0;
}
/* CardSurface override: full width, auto height */
:global(.preview-card-surface) {
max-width: none !important;
aspect-ratio: unset !important;
width: 100% !important;
}
.preview-inner {
padding: 1.375rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
min-height: 14rem;
}
.preview-prose {
font-size: 1rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
flex: 1;
}
.preview-prose :global(p) { margin: 0 0 0.5em; }
.preview-prose :global(p:last-child) { margin-bottom: 0; }
.preview-prose :global(h1) {
font-size: 2.5rem;
font-weight: 700;
text-align: center;
margin: 0;
line-height: 1;
letter-spacing: -0.02em;
}
.preview-prose :global(table) {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.preview-prose :global(td) {
padding: 0.25rem 0.375rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.preview-prose :global(thead) { display: none; }
.preview-prompt-mc {
flex: 0 0 auto;
}
.preview-side-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: hsl(var(--color-muted-foreground));
}
.preview-empty-hint {
color: hsl(var(--color-muted-foreground));
font-style: italic;
font-size: 0.9375rem;
}
.preview-divider {
height: 1px;
background: hsl(var(--color-border));
margin: 0.125rem 0;
}
.preview-reveal-btn {
align-self: flex-start;
padding: 0.4rem 0.875rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border: none;
font: inherit;
font-size: 0.8125rem;
background: hsl(var(--color-muted) / 0.5);
padding: 0.1em 0.3em;
border-radius: 0.25rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.12s;
margin-top: auto;
}
.preview-reveal-btn:hover { background: hsl(var(--color-primary) / 0.88); }
.preview-flip-link {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
text-decoration: underline;
align-self: flex-start;
margin-top: auto;
}
.preview-flip-link:hover { color: hsl(var(--color-foreground)); }
/* Multiple-choice preview */
.preview-mc {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.preview-mc-opt {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
font-size: 0.875rem;
}
.preview-mc-opt-correct {
background: hsl(var(--color-success) / 0.1);
border-color: hsl(var(--color-success) / 0.5);
}
.preview-mc-key {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
border-radius: 0.2rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
color: hsl(var(--color-muted-foreground));
}
.preview-mc-opt-correct .preview-mc-key {
border-color: hsl(var(--color-success) / 0.5);
color: hsl(var(--color-success));
background: hsl(var(--color-success) / 0.12);
}
.preview-mc-text {
flex: 1;
color: hsl(var(--color-foreground));
}
/* Typing preview */
.preview-typing {
display: flex;
gap: 0.375rem;
align-items: center;
}
.preview-typing-input {
flex: 1;
padding: 0.4rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.875rem;
opacity: 0.7;
}
.preview-typing-btn {
padding: 0.4rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
font: inherit;
font-size: 0.875rem;
cursor: default;
opacity: 0.6;
}
.preview-answer-badge {
display: inline-block;
padding: 0.1875rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.preview-answer-badge-correct {
background: hsl(var(--color-success) / 0.15);
color: hsl(var(--color-success));
}
/* Audio preview */
.preview-audio {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1rem 0;
}
.preview-audio-btn {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;
justify-content: center;
}
.preview-audio-ref {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-family: ui-monospace, monospace;
}
/* Placeholder */
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
flex: 1;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
text-align: center;
}
</style>