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:
parent
b5d3a29335
commit
9754718157
1 changed files with 609 additions and 246 deletions
|
|
@ -15,6 +15,7 @@
|
||||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
|
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
|
||||||
|
import CardSurface from '$lib/components/CardSurface.svelte';
|
||||||
|
|
||||||
type DeckLite = { id: string; name: string };
|
type DeckLite = { id: string; name: string };
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@
|
||||||
let answer = $state('');
|
let answer = $state('');
|
||||||
let audioFileRef = $state('');
|
let audioFileRef = $state('');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let previewFlipped = $state(false);
|
||||||
|
|
||||||
// Multiple-Choice builder state
|
// Multiple-Choice builder state
|
||||||
let mcOptions = $state(['', '', '', '']);
|
let mcOptions = $state(['', '', '', '']);
|
||||||
|
|
@ -37,13 +39,20 @@
|
||||||
|
|
||||||
const frontHtml = $derived(renderMarkdown(front));
|
const frontHtml = $derived(renderMarkdown(front));
|
||||||
const backHtml = $derived(renderMarkdown(back));
|
const backHtml = $derived(renderMarkdown(back));
|
||||||
|
const answerHtml = $derived(renderMarkdown(answer));
|
||||||
const clusterIds = $derived(extractClusterIds(text));
|
const clusterIds = $derived(extractClusterIds(text));
|
||||||
const clozePreviewHtml = $derived.by(() => {
|
const clozePreviewHtml = $derived.by(() => {
|
||||||
const firstCluster = clusterIds[0];
|
const firstCluster = clusterIds[0];
|
||||||
if (firstCluster === undefined) return '';
|
if (firstCluster === undefined) return renderMarkdown(text);
|
||||||
return renderMarkdown(renderClozePrompt(text, firstCluster));
|
return renderMarkdown(renderClozePrompt(text, firstCluster));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset flip state when type changes
|
||||||
|
$effect(() => {
|
||||||
|
cardType;
|
||||||
|
previewFlipped = false;
|
||||||
|
});
|
||||||
|
|
||||||
const TYPE_DESCRIPTIONS: Record<CardType, string> = {
|
const TYPE_DESCRIPTIONS: Record<CardType, string> = {
|
||||||
basic: 'Klassische Karteikarte: Vorderseite → Rückseite.',
|
basic: 'Klassische Karteikarte: Vorderseite → Rückseite.',
|
||||||
'basic-reverse': 'Wie Basic, aber beide Richtungen werden abgefragt (2 Reviews).',
|
'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>
|
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="back-link">{t('card_new.back')}</a>
|
||||||
<h1 class="page-title">{t('card_new.title')}</h1>
|
<h1 class="page-title">{t('card_new.title')}</h1>
|
||||||
|
|
||||||
<form class="card-form" onsubmit={onSubmit}>
|
<div class="page-layout">
|
||||||
<!-- Deck + Typ -->
|
<!-- LEFT: Form -->
|
||||||
<section class="form-section">
|
<form class="card-form" onsubmit={onSubmit}>
|
||||||
<div class="grid-2">
|
<!-- Deck + Typ -->
|
||||||
<label class="field">
|
<section class="form-section">
|
||||||
<span class="field-label">{t('card_new.deck_label')}</span>
|
<div class="grid-2">
|
||||||
<select bind:value={deckId} required class="input">
|
<label class="field">
|
||||||
{#each decks as d}
|
<span class="field-label">{t('card_new.deck_label')}</span>
|
||||||
<option value={d.id}>{d.name}</option>
|
<select bind:value={deckId} required class="input">
|
||||||
{/each}
|
{#each decks as d}
|
||||||
</select>
|
<option value={d.id}>{d.name}</option>
|
||||||
</label>
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">{t('card_new.type_label')}</span>
|
<span class="field-label">{t('card_new.type_label')}</span>
|
||||||
<select bind:value={cardType} class="input">
|
<select bind:value={cardType} class="input">
|
||||||
<option value="basic">{t('card_new.type_basic')}</option>
|
<option value="basic">{t('card_new.type_basic')}</option>
|
||||||
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
|
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
|
||||||
<option value="cloze">{t('card_new.type_cloze')}</option>
|
<option value="cloze">{t('card_new.type_cloze')}</option>
|
||||||
<option value="image-occlusion">{t('card_new.type_image_occlusion')}</option>
|
<option value="image-occlusion">{t('card_new.type_image_occlusion')}</option>
|
||||||
<option value="typing">{t('card_new.type_typing')}</option>
|
<option value="typing">{t('card_new.type_typing')}</option>
|
||||||
<option value="multiple-choice">{t('card_new.type_multiple_choice')}</option>
|
<option value="multiple-choice">{t('card_new.type_multiple_choice')}</option>
|
||||||
<option value="audio-front">{t('card_new.type_audio_front')}</option>
|
<option value="audio-front">{t('card_new.type_audio_front')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="type-hint">{TYPE_DESCRIPTIONS[cardType]}</p>
|
<p class="type-hint">{TYPE_DESCRIPTIONS[cardType]}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Type-specific content -->
|
<!-- Type-specific fields -->
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
{#if cardType === 'image-occlusion'}
|
{#if cardType === 'image-occlusion'}
|
||||||
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
|
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
|
||||||
|
|
||||||
{:else if cardType === 'cloze'}
|
{:else if cardType === 'cloze'}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">{t('card_new.cloze_text_label')}</span>
|
<span class="field-label">{t('card_new.cloze_text_label')}</span>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
required
|
required
|
||||||
rows="6"
|
rows="6"
|
||||||
placeholder={t('card_new.cloze_text_placeholder')}
|
placeholder={t('card_new.cloze_text_placeholder')}
|
||||||
class="input mono"
|
class="input mono"
|
||||||
></textarea>
|
></textarea>
|
||||||
<span class="field-hint">{t('card_new.cloze_help')}</span>
|
<span class="field-hint">{t('card_new.cloze_help')}</span>
|
||||||
{#if text.trim() && clusterIds.length === 0}
|
{#if text.trim() && clusterIds.length === 0}
|
||||||
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
|
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
|
||||||
{:else if clusterIds.length > 0}
|
{:else if clusterIds.length > 0}
|
||||||
<span class="field-hint">
|
<span class="field-hint">
|
||||||
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
|
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</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}
|
{:else if cardType === 'multiple-choice'}
|
||||||
<div class="preview-box">
|
<label class="field">
|
||||||
<span class="preview-label">{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}</span>
|
<span class="field-label">{t('card_new.front_label')}</span>
|
||||||
<div class="prose prose-sm">{@html clozePreviewHtml}</div>
|
<textarea
|
||||||
</div>
|
bind:value={front}
|
||||||
{/if}
|
required
|
||||||
|
rows="4"
|
||||||
<label class="field">
|
placeholder={t('card_new.front_placeholder')}
|
||||||
<span class="field-label">{t('card_new.cloze_extra_label')}</span>
|
class="input mono"
|
||||||
<textarea
|
></textarea>
|
||||||
bind:value={extra}
|
</label>
|
||||||
rows="3"
|
<div class="field">
|
||||||
placeholder={t('card_new.cloze_extra_placeholder')}
|
<span class="field-label">Antwortoptionen</span>
|
||||||
class="input mono"
|
<span class="field-hint">Markiere die richtige Antwort. Leere Optionen werden ignoriert — KI ergänzt fehlende Distractors automatisch.</span>
|
||||||
></textarea>
|
<div class="mc-options">
|
||||||
</label>
|
{#each mcOptions as opt, i}
|
||||||
|
{@const letter = ['A', 'B', 'C', 'D'][i]}
|
||||||
{:else if cardType === 'multiple-choice'}
|
{@const isCorrect = mcCorrectIdx === i}
|
||||||
<label class="field">
|
<div class="mc-option" class:mc-option-correct={isCorrect}>
|
||||||
<span class="field-label">{t('card_new.front_label')}</span>
|
<button
|
||||||
<textarea
|
type="button"
|
||||||
bind:value={front}
|
class="mc-radio"
|
||||||
required
|
class:mc-radio-selected={isCorrect}
|
||||||
rows="4"
|
onclick={() => { mcCorrectIdx = i; }}
|
||||||
placeholder={t('card_new.front_placeholder')}
|
aria-label="Option {letter} als richtig markieren"
|
||||||
class="input mono"
|
aria-pressed={isCorrect}
|
||||||
></textarea>
|
>
|
||||||
{#if front.trim()}
|
{#if isCorrect}
|
||||||
<div class="preview-box mt-2">
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
<circle cx="5" cy="5" r="3.5" />
|
||||||
<div class="prose prose-sm">{@html frontHtml}</div>
|
</svg>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</button>
|
||||||
</label>
|
<span class="mc-letter" class:mc-letter-correct={isCorrect}>{letter}</span>
|
||||||
|
<input
|
||||||
<div class="field">
|
type="text"
|
||||||
<span class="field-label">Antwortoptionen</span>
|
bind:value={mcOptions[i]}
|
||||||
<span class="field-hint">Markiere die richtige Antwort. Nicht befüllte Optionen werden ignoriert — KI ergänzt fehlende Distractors beim Lernen.</span>
|
placeholder="Option {letter}"
|
||||||
<div class="mc-options">
|
class="mc-input"
|
||||||
{#each mcOptions as opt, i}
|
/>
|
||||||
{@const letter = ['A', 'B', 'C', 'D'][i]}
|
{#if isCorrect && opt.trim()}
|
||||||
{@const isCorrect = mcCorrectIdx === i}
|
<span class="mc-badge">✓ Richtig</span>
|
||||||
<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}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
<span class="mc-letter" class:mc-letter-correct={isCorrect}>{letter}</span>
|
{/each}
|
||||||
<input
|
</div>
|
||||||
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if cardType === 'typing'}
|
{:else if cardType === 'typing'}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">{t('card_new.front_label')}</span>
|
<span class="field-label">{t('card_new.front_label')}</span>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={front}
|
bind:value={front}
|
||||||
required
|
required
|
||||||
rows="8"
|
rows="8"
|
||||||
placeholder={t('card_new.front_placeholder')}
|
placeholder={t('card_new.front_placeholder')}
|
||||||
class="input mono"
|
class="input mono"
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if front.trim()}
|
</label>
|
||||||
<div class="preview-box mt-2">
|
<label class="field">
|
||||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
<span class="field-label">{t('card_new.answer_label')}</span>
|
||||||
<div class="prose prose-sm">{@html frontHtml}</div>
|
<textarea
|
||||||
</div>
|
bind:value={answer}
|
||||||
{/if}
|
required
|
||||||
</label>
|
rows="8"
|
||||||
|
placeholder={t('card_new.answer_placeholder')}
|
||||||
<label class="field">
|
class="input mono"
|
||||||
<span class="field-label">{t('card_new.answer_label')}</span>
|
></textarea>
|
||||||
<textarea
|
<span class="field-hint">Kommagetrennte Aliase erlaubt: <code>Paris, Paris (Frankreich)</code></span>
|
||||||
bind:value={answer}
|
</label>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{:else}
|
{:else if cardType === 'audio-front'}
|
||||||
<!-- basic / basic-reverse -->
|
|
||||||
<div class="grid-2">
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">{t('card_new.front_label')}</span>
|
<span class="field-label">{t('card_new.audio_ref_label')}</span>
|
||||||
<textarea
|
<input
|
||||||
bind:value={front}
|
type="text"
|
||||||
|
bind:value={audioFileRef}
|
||||||
required
|
required
|
||||||
rows="8"
|
placeholder={t('card_new.audio_ref_placeholder')}
|
||||||
placeholder={t('card_new.front_placeholder')}
|
|
||||||
class="input mono"
|
class="input mono"
|
||||||
></textarea>
|
/>
|
||||||
{#if front.trim()}
|
<span class="field-hint">Die media_ref-ID aus dem Media-Upload-Endpoint.</span>
|
||||||
<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>
|
||||||
|
|
||||||
<label class="field">
|
<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
|
<textarea
|
||||||
bind:value={back}
|
bind:value={back}
|
||||||
required
|
required
|
||||||
rows="8"
|
rows="5"
|
||||||
placeholder={t('card_new.back_placeholder')}
|
placeholder={t('card_new.back_placeholder')}
|
||||||
class="input mono"
|
class="input mono"
|
||||||
></textarea>
|
></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>
|
</label>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
{:else}
|
||||||
<div class="actions">
|
<!-- basic / basic-reverse -->
|
||||||
<button type="submit" disabled={!canSave} class="btn-primary">
|
<div class="grid-2">
|
||||||
{saving ? t('card_new.creating') : t('card_new.create')}
|
<label class="field">
|
||||||
</button>
|
<span class="field-label">{t('card_new.front_label')}</span>
|
||||||
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="btn-ghost">{t('card_new.cancel')}</a>
|
<textarea
|
||||||
</div>
|
bind:value={front}
|
||||||
</form>
|
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 A–D 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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-shell {
|
.page-shell {
|
||||||
max-width: 52rem;
|
max-width: 72rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -406,6 +522,22 @@
|
||||||
color: hsl(var(--color-foreground));
|
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 {
|
.card-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -449,6 +581,12 @@
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
line-height: 1.4;
|
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 {
|
.field-error {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
@ -465,7 +603,7 @@
|
||||||
padding: 0.5rem 0.625rem;
|
padding: 0.5rem 0.625rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
transition: border-color 0.12s;
|
transition: border-color 0.12s, box-shadow 0.12s;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.input:focus {
|
.input:focus {
|
||||||
|
|
@ -479,27 +617,9 @@
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--color-muted-foreground));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-top: 0.125rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-box {
|
/* MC option builder */
|
||||||
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 {
|
.mc-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -533,15 +653,14 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: hsl(var(--color-primary));
|
transition: border-color 0.12s;
|
||||||
transition: border-color 0.12s, background-color 0.12s;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
color: hsl(var(--color-success));
|
||||||
}
|
}
|
||||||
.mc-radio:hover { border-color: hsl(var(--color-primary) / 0.6); }
|
.mc-radio:hover { border-color: hsl(var(--color-primary) / 0.6); }
|
||||||
.mc-radio-selected {
|
.mc-radio-selected {
|
||||||
border-color: hsl(var(--color-success));
|
border-color: hsl(var(--color-success));
|
||||||
background: hsl(var(--color-success) / 0.12);
|
background: hsl(var(--color-success) / 0.12);
|
||||||
color: hsl(var(--color-success));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-letter {
|
.mc-letter {
|
||||||
|
|
@ -618,13 +737,257 @@
|
||||||
}
|
}
|
||||||
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
|
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
|
||||||
|
|
||||||
/* prose reset inside preview-box */
|
/* Preview column */
|
||||||
.prose :global(p) { margin: 0 0 0.5em; font-size: 0.875rem; }
|
.preview-col {
|
||||||
.prose :global(p:last-child) { margin-bottom: 0; }
|
min-width: 0;
|
||||||
.prose :global(code) {
|
}
|
||||||
|
|
||||||
|
.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;
|
font-size: 0.8125rem;
|
||||||
background: hsl(var(--color-muted) / 0.5);
|
font-weight: 500;
|
||||||
padding: 0.1em 0.3em;
|
cursor: pointer;
|
||||||
border-radius: 0.25rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue