Phase 9b: Cloze-Editor in /cards/new

Type-Picker um Cloze ergänzt. Formular schaltet auf Single-Textarea
(text + optionales extra) um, sobald Cloze gewählt wird. Cluster-
Counter, Inline-Hint und Live-Preview (erstes Cluster maskiert)
sind dieselben Helpers wie im Edit-Flow (@cards/domain/src/cloze.ts).

Submit-Validation: bei Cloze muss mindestens ein {{cN::…}}-Cluster
existieren — Submit-Button bleibt sonst disabled. Toast nennt nach
dem Anlegen die Anzahl initialisierter Reviews.

svelte-check 354 files 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 17:52:55 +02:00
parent 0a403679e3
commit 35366ed4f2

View file

@ -2,7 +2,11 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { CardType } from '@cards/domain';
import {
extractClusterIds,
renderClozePrompt,
type CardType,
} from '@cards/domain';
import { createCard } from '$lib/api/cards.ts';
import { listDecks, getDeck } from '$lib/api/decks.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
@ -16,10 +20,18 @@
let cardType = $state<CardType>('basic');
let front = $state('');
let back = $state('');
let text = $state('');
let extra = $state('');
let saving = $state(false);
const frontHtml = $derived(renderMarkdown(front));
const backHtml = $derived(renderMarkdown(back));
const clusterIds = $derived(extractClusterIds(text));
const clozePreviewHtml = $derived.by(() => {
const firstCluster = clusterIds[0];
if (firstCluster === undefined) return '';
return renderMarkdown(renderClozePrompt(text, firstCluster));
});
onMount(async () => {
if (!devUser.id) {
@ -44,19 +56,33 @@
}
});
const canSave = $derived.by(() => {
if (saving || !deckId) return false;
if (cardType === 'cloze') {
return text.trim().length > 0 && clusterIds.length > 0;
}
return front.trim().length > 0 && back.trim().length > 0;
});
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (!deckId || !front.trim() || !back.trim()) return;
if (!canSave) return;
saving = true;
try {
const card = await createCard({
deck_id: deckId,
type: cardType,
fields: { front: front.trim(), back: back.trim() },
});
toasts.success(
cardType === 'basic-reverse' ? '2 Reviews initialisiert (front→back, back→front)' : 'Karte angelegt'
);
const fields: Record<string, string> =
cardType === 'cloze'
? extra.trim()
? { text: text.trim(), extra: extra.trim() }
: { text: text.trim() }
: { front: front.trim(), back: back.trim() };
const card = await createCard({ deck_id: deckId, type: cardType, fields });
const msg =
cardType === 'cloze'
? `${clusterIds.length} Reviews initialisiert (1 pro Cluster)`
: cardType === 'basic-reverse'
? '2 Reviews initialisiert (front→back, back→front)'
: 'Karte angelegt';
toasts.success(msg);
goto(`/decks/${card.deck_id}`);
} catch (e) {
toasts.error(`Anlegen fehlgeschlagen: ${(e as Error).message}`);
@ -93,54 +119,103 @@
>
<option value="basic">Basic (front → back)</option>
<option value="basic-reverse">Basic + Reverse (front ↔ back, 2 Reviews)</option>
<option value="cloze">Cloze (Lückentext, 1 Review pro Cluster)</option>
</select>
</label>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#if cardType === 'cloze'}
<div>
<label class="block">
<span class="text-sm font-medium">Vorderseite (Markdown)</span>
<span class="text-sm font-medium">Text mit Lücken (Markdown)</span>
<textarea
bind:value={front}
bind:value={text}
required
rows="8"
placeholder="# Markdown ist erlaubt&#10;**fett**, _kursiv_, `code`"
rows="6"
placeholder="Die Hauptstadt von {'{{c1::Frankreich}}'} ist {'{{c2::Paris}}'}."
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if front.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">Vorschau</div>
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
</div>
<p class="mt-1 text-xs text-[var(--color-muted)]">
<code>{'{{c1::Antwort}}'}</code> definiert eine Lücke. Pro Cluster-ID
(<code>c1</code>, <code>c2</code>, …) entsteht ein eigenes Review.
</p>
{#if text.trim() && clusterIds.length === 0}
<p class="mt-1 text-xs text-[var(--color-danger)]">
Mindestens ein <code>{'{{cN::…}}'}</code>-Cluster wird gebraucht.
</p>
{:else if clusterIds.length > 0}
<p class="mt-1 text-xs text-[var(--color-muted)]">
{clusterIds.length} Cluster erkannt: c{clusterIds.join(', c')}{clusterIds.length}
Reviews.
</p>
{/if}
</div>
<div>
<label class="block">
<span class="text-sm font-medium">Rückseite (Markdown)</span>
<textarea
bind:value={back}
required
rows="8"
placeholder="Antwort"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if back.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">Vorschau</div>
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
{#if clozePreviewHtml}
<div class="rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">
Vorschau (c{clusterIds[0]} maskiert)
</div>
{/if}
<div class="prose prose-sm max-w-none">{@html clozePreviewHtml}</div>
</div>
{/if}
<label class="block">
<span class="text-sm font-medium">Extra (optional)</span>
<textarea
bind:value={extra}
rows="3"
placeholder="Zusätzlicher Kontext, wird unter der Antwort gezeigt."
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block">
<span class="text-sm font-medium">Vorderseite (Markdown)</span>
<textarea
bind:value={front}
required
rows="8"
placeholder="# Markdown ist erlaubt&#10;**fett**, _kursiv_, `code`"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if front.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">Vorschau</div>
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
</div>
{/if}
</div>
<div>
<label class="block">
<span class="text-sm font-medium">Rückseite (Markdown)</span>
<textarea
bind:value={back}
required
rows="8"
placeholder="Antwort"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if back.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">Vorschau</div>
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
disabled={saving || !deckId || !front.trim() || !back.trim()}
disabled={!canSave}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
>
{saving ? 'Speichere…' : 'Karte anlegen'}