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:
parent
0a403679e3
commit
35366ed4f2
1 changed files with 113 additions and 38 deletions
|
|
@ -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 **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 **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'}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue