Phase 9a: Card-Edit-Page für alle 3 CardTypes

Neue Route /cards/[id]/edit. Lädt die Karte, zeigt typ-spezifisches
Form (basic/basic-reverse: front+back, cloze: text+extra mit
Cluster-Counter und Live-Preview), PATCHt die fields. CardType ist
read-only — Type-Wechsel würde die Reviews-Tabelle brechen.

Cloze-Editor zeigt erkannte Cluster-IDs ("c1, c2 → 2 Reviews") und
warnt, wenn Text ohne {{cN::…}}-Markup gespeichert würde. Live-
Vorschau rendert den Prompt mit dem ersten Cluster maskiert.

Decks-Detail-Page: Karten-Eintrag verlinkt jetzt aufs Edit, Cloze-
Karten zeigen ihren `text`-Anschnitt statt "(leer) → (leer)". Delete-
Button bleibt am rechten Rand.

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:51:42 +02:00
parent 9da10b3252
commit 0a403679e3
2 changed files with 247 additions and 4 deletions

View file

@ -0,0 +1,236 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
extractClusterIds,
renderClozePrompt,
type Card,
type CardType,
} from '@cards/domain';
import { getCard, updateCard, deleteCard } from '$lib/api/cards.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
let card = $state<Card | null>(null);
let cardType = $state<CardType>('basic');
let front = $state('');
let back = $state('');
let text = $state('');
let extra = $state('');
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
const cardId = $derived(page.params.id ?? '');
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) {
goto('/');
return;
}
try {
const c = await getCard(cardId);
card = c;
cardType = c.type;
const fields = c.fields as Record<string, string>;
if (c.type === 'cloze') {
text = fields.text ?? '';
extra = fields.extra ?? '';
} else {
front = fields.front ?? '';
back = fields.back ?? '';
}
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
});
const canSave = $derived.by(() => {
if (saving) 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 (!card || !canSave) return;
saving = true;
try {
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 updated = await updateCard(card.id, { fields });
toasts.success('Karte aktualisiert');
goto(`/decks/${updated.deck_id}`);
} catch (e) {
toasts.error(`Speichern fehlgeschlagen: ${(e as Error).message}`);
saving = false;
}
}
async function onDelete() {
if (!card) return;
if (!confirm('Karte wirklich löschen? Reviews werden mit gelöscht.')) return;
try {
await deleteCard(card.id);
toasts.success('Karte gelöscht');
goto(`/decks/${card.deck_id}`);
} catch (e) {
toasts.error(`Löschen fehlgeschlagen: ${(e as Error).message}`);
}
}
</script>
<svelte:head>
<title>Karte bearbeiten · Cards</title>
</svelte:head>
<div class="mx-auto max-w-3xl px-4 py-6">
{#if loading}
<p class="text-[var(--color-muted)]">Lade…</p>
{:else if error}
<p class="text-[var(--color-danger)]">Fehler: {error}</p>
{:else if card}
<a
href="/decks/{card.deck_id}"
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">← Zurück zum Deck</a
>
<div class="mt-2 flex items-baseline justify-between gap-3">
<h1 class="text-2xl font-semibold">Karte bearbeiten</h1>
<span class="rounded bg-[var(--color-border)] px-2 py-0.5 text-xs text-[var(--color-muted)]">
{cardType}
</span>
</div>
<p class="mt-1 text-xs text-[var(--color-muted)]">
Der Card-Type kann nicht geändert werden — die Reviews-Tabelle hängt am Type.
</p>
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
{#if cardType === 'cloze'}
<div>
<label class="block">
<span class="text-sm font-medium">Text mit Lücken (Markdown)</span>
<textarea
bind:value={text}
required
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>
<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>
{#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>
<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"
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"
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>
{/if}
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<button
type="submit"
disabled={!canSave}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
<a
href="/decks/{card.deck_id}"
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">Abbrechen</a
>
</div>
<button
type="button"
onclick={onDelete}
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-danger)]"
>
Löschen
</button>
</div>
</form>
{/if}
</div>

View file

@ -121,17 +121,24 @@
<ul class="mt-6 divide-y divide-[var(--color-border)] rounded-lg border border-[var(--color-border)] bg-[var(--color-card)]">
{#each cards as card (card.id)}
<li class="group flex items-start justify-between gap-4 px-4 py-3">
<div class="min-w-0 flex-1">
<a
href="/cards/{card.id}/edit"
class="min-w-0 flex-1 hover:text-[var(--color-primary)]"
>
<div class="flex items-center gap-2">
<span
class="rounded bg-[var(--color-border)] px-2 py-0.5 text-xs text-[var(--color-muted)]"
>{card.type}</span>
</div>
<p class="mt-1 truncate text-sm">
<span class="font-medium">{card.fields.front ?? '(leer)'}</span>
<span class="text-[var(--color-muted)]">{card.fields.back ?? '(leer)'}</span>
{#if card.type === 'cloze'}
<span class="font-medium">{card.fields.text ?? '(leer)'}</span>
{:else}
<span class="font-medium">{card.fields.front ?? '(leer)'}</span>
<span class="text-[var(--color-muted)]">{card.fields.back ?? '(leer)'}</span>
{/if}
</p>
</div>
</a>
<button
class="opacity-0 group-hover:opacity-100 text-sm text-[var(--color-muted)] hover:text-[var(--color-danger)]"
onclick={() => onDeleteCard(card.id)}