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:
parent
9da10b3252
commit
0a403679e3
2 changed files with 247 additions and 4 deletions
236
apps/web/src/routes/cards/[id]/edit/+page.svelte
Normal file
236
apps/web/src/routes/cards/[id]/edit/+page.svelte
Normal 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>
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue