Phase 9g: i18n DE/EN über alle Routes
Schmale Eigen-Lösung statt svelte-i18n: $lib/i18n mit de.ts + en.ts (je ~150 Strings, flat-with-nesting), index.svelte.ts liefert reaktiven i18n-Store als Svelte-5-Rune plus t()/tn()-Helper. Locale wird in localStorage persistiert, Default = navigator.language (DE/EN-Erkennung), Fallback = de. Header bekommt einen DE/EN-Toggle-Switcher (role=group, aria-pressed). Alle Routes (decks, decks/[id], decks/new, cards/new, cards/[id]/edit, study, study/[deckId], import, account, stats, +page) und alle Komponenten (Header, AnkiImport, InboxBanner) ziehen jetzt durch t(). tn(key, n) wählt zwischen `<key>_one` (n=1) und `<key>` — minimaler Plural-Helper, reicht für DE/EN-MVP. Komplexere Pluralregeln (FR/IT) bräuchten Intl.PluralRules, kommen wenn die Locales dazukommen. Begleitend ein paar A11y-Vorbereitungen mitgenommen: aria-label am Locale-Switcher, role=group für Grade-Buttons, role=button + keyboard-handler für die Anki-Dropzone, role=progressbar mit aria-valuemin/max/now, role=alert für Error-States, aria-live=polite für Lade-Meldungen, sr-only-Heading im Study-Card-Render, aria-hidden für rein dekorative Elemente. svelte-check 379 files 0 errors, 94 Tests grün (41 Domain + 48 API + 5 Web). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a640594a24
commit
c25c1d0dc4
17 changed files with 826 additions and 270 deletions
|
|
@ -11,6 +11,7 @@
|
|||
<script lang="ts">
|
||||
import { parseApkg, type ParsedAnki } from '$lib/anki/parse.ts';
|
||||
import { importParsedAnki, type ImportResult, type ImportProgress } from '$lib/anki/import.ts';
|
||||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let stage = $state<'idle' | 'parsing' | 'preview' | 'importing' | 'done' | 'error'>('idle');
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
parsed = await parseApkg(file);
|
||||
stage = 'preview';
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Datei konnte nicht gelesen werden.';
|
||||
error = e instanceof Error ? e.message : t('import.error_label', { msg: '?' });
|
||||
stage = 'error';
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +70,7 @@
|
|||
});
|
||||
stage = 'done';
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Import fehlgeschlagen.';
|
||||
error = e instanceof Error ? e.message : t('import.error_label', { msg: '?' });
|
||||
stage = 'error';
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +85,7 @@
|
|||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="mb-2 text-sm font-medium">Aus Anki importieren</div>
|
||||
<div class="mb-2 text-sm font-medium">{t('import.anki_label')}</div>
|
||||
|
||||
{#if stage === 'idle'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
|
@ -94,11 +95,17 @@
|
|||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInput?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="mb-1">📦 .apkg-Datei hier ablegen oder klicken</div>
|
||||
<div class="text-xs">
|
||||
Basic, Basic + Reverse, Cloze · Bilder + Audio werden in dieser Phase nicht übernommen.
|
||||
</div>
|
||||
<div class="mb-1">{t('import.dropzone')}</div>
|
||||
<div class="text-xs">{t('import.dropzone_hint')}</div>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
|
|
@ -108,36 +115,41 @@
|
|||
onchange={onPick}
|
||||
/>
|
||||
{:else if stage === 'parsing'}
|
||||
<div class="py-6 text-center text-sm text-[var(--color-muted)]">Lese {fileName}…</div>
|
||||
<div class="py-6 text-center text-sm text-[var(--color-muted)]" aria-live="polite">
|
||||
{t('import.parsing', { file: fileName })}
|
||||
</div>
|
||||
{:else if stage === 'preview' && parsed}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--color-muted)]">Gefunden in</span>
|
||||
<span class="text-[var(--color-muted)]">{t('import.preview_found')}</span>
|
||||
<code class="rounded bg-[var(--color-border)]/40 px-1 text-xs">{fileName}</code>:
|
||||
</div>
|
||||
<ul class="ml-4 list-disc">
|
||||
<li>{parsed.decks.length} {parsed.decks.length === 1 ? 'Deck' : 'Decks'}</li>
|
||||
<li>{tn('import.preview_decks', parsed.decks.length)}</li>
|
||||
<li>
|
||||
{parsed.cards.length} {parsed.cards.length === 1 ? 'Karte' : 'Karten'}
|
||||
{tn('import.preview_cards', parsed.cards.length)}
|
||||
{#if parsed.cards.length > 0}
|
||||
<span class="text-[var(--color-muted)]">
|
||||
({typeBreakdown.basic} basic, {typeBreakdown.basicReverse} basic-reverse,
|
||||
{typeBreakdown.cloze} cloze)
|
||||
{t('import.preview_breakdown', {
|
||||
basic: typeBreakdown.basic,
|
||||
basic_reverse: typeBreakdown.basicReverse,
|
||||
cloze: typeBreakdown.cloze,
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{#if parsed.mediaByFilename.size > 0}
|
||||
<li class="text-[var(--color-muted)]">
|
||||
{parsed.mediaByFilename.size} Medien (werden in dieser Phase NICHT übernommen)
|
||||
{t('import.preview_media', { n: parsed.mediaByFilename.size })}
|
||||
</li>
|
||||
{/if}
|
||||
{#if parsed.skipped > 0}
|
||||
<li>{parsed.skipped} übersprungen (unbekannter Typ)</li>
|
||||
<li>{t('import.preview_skipped', { n: parsed.skipped })}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{#if parsed.warnings.length > 0}
|
||||
<details class="text-xs text-[var(--color-muted)]">
|
||||
<summary class="cursor-pointer">Hinweise ({parsed.warnings.length})</summary>
|
||||
<summary class="cursor-pointer">{t('import.preview_warnings', { n: parsed.warnings.length })}</summary>
|
||||
<ul class="mt-1 list-disc pl-4">
|
||||
{#each parsed.warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
|
||||
</ul>
|
||||
|
|
@ -148,26 +160,32 @@
|
|||
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
onclick={reset}
|
||||
>
|
||||
Abbrechen
|
||||
{t('import.cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] hover:opacity-90"
|
||||
onclick={confirmImport}
|
||||
>
|
||||
Importieren
|
||||
{t('import.import_now')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if stage === 'importing'}
|
||||
<div class="py-6 text-center text-sm text-[var(--color-muted)]">
|
||||
<div class="py-6 text-center text-sm text-[var(--color-muted)]" aria-live="polite">
|
||||
{#if progress.stage === 'decks'}
|
||||
Lege Decks an · {progress.current} / {progress.total}
|
||||
{t('import.stage_decks', { current: progress.current, total: progress.total })}
|
||||
{:else if progress.stage === 'cards'}
|
||||
Importiere Karten · {progress.current} / {progress.total}
|
||||
{t('import.stage_cards', { current: progress.current, total: progress.total })}
|
||||
{:else}
|
||||
Fertig.
|
||||
{t('import.stage_done')}
|
||||
{/if}
|
||||
<div class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[var(--color-border)]/40">
|
||||
<div
|
||||
class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[var(--color-border)]/40"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={progress.total}
|
||||
aria-valuenow={progress.current}
|
||||
>
|
||||
<div
|
||||
class="h-full bg-[var(--color-primary)] transition-all"
|
||||
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
|
||||
|
|
@ -177,12 +195,13 @@
|
|||
{:else if stage === 'done' && result}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="text-[var(--color-success,#16a34a)]">
|
||||
✓ {result.cardsCreated} Karten in {result.decksCreated}
|
||||
{result.decksCreated === 1 ? 'Deck' : 'Decks'} angelegt.
|
||||
{result.decksCreated === 1
|
||||
? t('import.done_summary_one', { cards: result.cardsCreated })
|
||||
: t('import.done_summary', { cards: result.cardsCreated, decks: result.decksCreated })}
|
||||
</div>
|
||||
{#if result.failed > 0}
|
||||
<details class="text-[var(--color-danger)]">
|
||||
<summary class="cursor-pointer">{result.failed} Fehler</summary>
|
||||
<summary class="cursor-pointer">{t('import.done_failures', { n: result.failed })}</summary>
|
||||
<ul class="mt-1 list-disc pl-4 text-xs">
|
||||
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
|
||||
</ul>
|
||||
|
|
@ -192,17 +211,17 @@
|
|||
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
onclick={reset}
|
||||
>
|
||||
Weitere Datei
|
||||
{t('import.done_more')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if stage === 'error'}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="text-[var(--color-danger)]">Fehler: {error}</div>
|
||||
<div class="space-y-2 text-sm" role="alert">
|
||||
<div class="text-[var(--color-danger)]">{t('import.error_label', { msg: error ?? '?' })}</div>
|
||||
<button
|
||||
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
onclick={reset}
|
||||
>
|
||||
Erneut versuchen
|
||||
{t('import.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte.ts';
|
||||
</script>
|
||||
|
||||
<header
|
||||
|
|
@ -10,36 +11,60 @@
|
|||
<a href="/" class="flex items-center gap-2 font-semibold">
|
||||
<span
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded bg-[var(--color-primary)] text-[var(--color-primary-fg)] text-sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
C
|
||||
</span>
|
||||
<span>Cards</span>
|
||||
<span>{t('app.name')}</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex items-center gap-6 text-sm">
|
||||
<nav class="flex items-center gap-6 text-sm" aria-label={t('app.name')}>
|
||||
<a
|
||||
href="/decks"
|
||||
class="hover:text-[var(--color-primary)]"
|
||||
class:font-medium={page.url.pathname.startsWith('/decks')}>Decks</a
|
||||
class:font-medium={page.url.pathname.startsWith('/decks')}>{t('nav.decks')}</a
|
||||
>
|
||||
<a
|
||||
href="/study"
|
||||
class="hover:text-[var(--color-primary)]"
|
||||
class:font-medium={page.url.pathname.startsWith('/study')}>Lernen</a
|
||||
class:font-medium={page.url.pathname.startsWith('/study')}>{t('nav.study')}</a
|
||||
>
|
||||
<a
|
||||
href="/import"
|
||||
class="hover:text-[var(--color-primary)]"
|
||||
class:font-medium={page.url.pathname.startsWith('/import')}>Import</a
|
||||
class:font-medium={page.url.pathname.startsWith('/import')}>{t('nav.import')}</a
|
||||
>
|
||||
<a
|
||||
href="/stats"
|
||||
class="hover:text-[var(--color-primary)]"
|
||||
class:font-medium={page.url.pathname.startsWith('/stats')}>Statistik</a
|
||||
class:font-medium={page.url.pathname.startsWith('/stats')}>{t('nav.stats')}</a
|
||||
>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex overflow-hidden rounded border border-[var(--color-border)] text-xs"
|
||||
role="group"
|
||||
aria-label="Sprache / Language"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 transition-colors"
|
||||
class:bg-primary-active={i18n.current === 'de'}
|
||||
style:background-color={i18n.current === 'de' ? 'var(--color-primary)' : 'transparent'}
|
||||
style:color={i18n.current === 'de' ? 'var(--color-primary-fg)' : 'var(--color-muted)'}
|
||||
aria-pressed={i18n.current === 'de'}
|
||||
onclick={() => i18n.set('de')}>DE</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 transition-colors"
|
||||
style:background-color={i18n.current === 'en' ? 'var(--color-primary)' : 'transparent'}
|
||||
style:color={i18n.current === 'en' ? 'var(--color-primary-fg)' : 'var(--color-muted)'}
|
||||
aria-pressed={i18n.current === 'en'}
|
||||
onclick={() => i18n.set('en')}>EN</button
|
||||
>
|
||||
</div>
|
||||
{#if devUser.id}
|
||||
<a
|
||||
href="/account"
|
||||
|
|
@ -51,9 +76,9 @@
|
|||
{:else}
|
||||
<button
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1 text-[var(--color-primary-fg)]"
|
||||
onclick={() => devUser.set(prompt('User-ID (dev):') ?? '')}
|
||||
onclick={() => devUser.set(prompt(t('landing.dev_user_prompt')) ?? '')}
|
||||
>
|
||||
Login (dev)
|
||||
{t('nav.login_dev')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { loadInboxStats, type InboxStats } from '$lib/api/inbox.ts';
|
||||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let stats = $state<InboxStats | null>(null);
|
||||
|
||||
|
|
@ -19,12 +20,9 @@
|
|||
href="/decks/{stats.deck.id}"
|
||||
class="block rounded-lg border border-[var(--color-primary)]/40 bg-[var(--color-primary)]/10 px-4 py-3 text-sm hover:bg-[var(--color-primary)]/15"
|
||||
>
|
||||
<span class="font-medium">📥 Inbox</span>
|
||||
<span class="text-[var(--color-muted)]">·</span>
|
||||
<span>
|
||||
{stats.cardCount} eingegangene
|
||||
{stats.cardCount === 1 ? 'Karte' : 'Karten'} aus anderen Apps
|
||||
</span>
|
||||
<span class="ml-1 text-[var(--color-muted)]">— sortieren →</span>
|
||||
<span class="font-medium">{t('inbox_banner.label')}</span>
|
||||
<span class="text-[var(--color-muted)]" aria-hidden="true">·</span>
|
||||
<span>{tn('inbox_banner.count', stats.cardCount)}</span>
|
||||
<span class="ml-1 text-[var(--color-muted)]">{t('inbox_banner.cta')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
|
|
|||
216
apps/web/src/lib/i18n/de.ts
Normal file
216
apps/web/src/lib/i18n/de.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Default-Locale. Schlüssel sind Punkt-getrennt nach Bereich.
|
||||
// Interpolation: {n}, {name}, {count} → simple replace.
|
||||
|
||||
export type TranslationNode = { [key: string]: string | TranslationNode };
|
||||
|
||||
export const de: TranslationNode = {
|
||||
app: {
|
||||
name: 'Cards',
|
||||
title_suffix: 'Cards',
|
||||
},
|
||||
nav: {
|
||||
decks: 'Decks',
|
||||
study: 'Lernen',
|
||||
import: 'Import',
|
||||
stats: 'Statistik',
|
||||
login_dev: 'Login (dev)',
|
||||
account: 'Account',
|
||||
},
|
||||
landing: {
|
||||
welcome: 'Lernkarten mit Spaced-Repetition.',
|
||||
intro:
|
||||
'Cardecky ist die föderierte Karteikarten-App des Vereins mana e.V. — FSRS-Scheduler, Cloze-Karten, Anki-Import.',
|
||||
cta_login: 'Login (dev)',
|
||||
dev_user_prompt: 'User-ID (dev):',
|
||||
},
|
||||
decks: {
|
||||
title: 'Decks',
|
||||
new: 'Neues Deck',
|
||||
empty: 'Noch keine Decks.',
|
||||
empty_cta: 'Erstes Deck anlegen',
|
||||
loading: 'Lade…',
|
||||
error: 'Fehler: {msg}',
|
||||
card_count: '{n} Karten',
|
||||
card_count_one: '1 Karte',
|
||||
due_count: '{n} fällig',
|
||||
delete_confirm:
|
||||
'Deck "{name}" wirklich löschen? Alle Karten + Review-Daten gehen verloren.',
|
||||
deleted: 'Deck "{name}" gelöscht',
|
||||
delete_failed: 'Löschen fehlgeschlagen: {msg}',
|
||||
},
|
||||
deck_detail: {
|
||||
back: '← Zurück zu Decks',
|
||||
study_button: 'Lernen',
|
||||
new_card: 'Neue Karte',
|
||||
empty: 'Keine Karten in diesem Deck.',
|
||||
empty_cta: 'Erste Karte anlegen →',
|
||||
card_summary_due: '{cards} · {due} fällig',
|
||||
card_delete_aria: 'Karte löschen',
|
||||
card_delete_label: 'Löschen',
|
||||
card_delete_confirm: 'Karte wirklich löschen? Reviews werden mit gelöscht.',
|
||||
},
|
||||
deck_new: {
|
||||
title: 'Neues Deck',
|
||||
name_label: 'Name',
|
||||
description_label: 'Beschreibung (optional)',
|
||||
color_label: 'Farbe (optional)',
|
||||
create: 'Deck anlegen',
|
||||
creating: 'Lege an…',
|
||||
cancel: 'Abbrechen',
|
||||
create_failed: 'Anlegen fehlgeschlagen: {msg}',
|
||||
},
|
||||
card_new: {
|
||||
title: 'Neue Karte',
|
||||
back: '← Zurück',
|
||||
deck_label: 'Deck',
|
||||
type_label: 'Typ',
|
||||
type_basic: 'Basic (front → back)',
|
||||
type_basic_reverse: 'Basic + Reverse (front ↔ back, 2 Reviews)',
|
||||
type_cloze: 'Cloze (Lückentext, 1 Review pro Cluster)',
|
||||
front_label: 'Vorderseite (Markdown)',
|
||||
back_label: 'Rückseite (Markdown)',
|
||||
back_placeholder: 'Antwort',
|
||||
front_placeholder: '# Markdown ist erlaubt\n**fett**, _kursiv_, `code`',
|
||||
preview_label: 'Vorschau',
|
||||
cloze_text_label: 'Text mit Lücken (Markdown)',
|
||||
cloze_text_placeholder: 'Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.',
|
||||
cloze_help: '{{c1::Antwort}} definiert eine Lücke. Pro Cluster-ID (c1, c2, …) entsteht ein eigenes Review.',
|
||||
cloze_no_clusters: 'Mindestens ein {{cN::…}}-Cluster wird gebraucht.',
|
||||
cloze_clusters_detected: '{n} Cluster erkannt: c{ids} → {n} Reviews.',
|
||||
cloze_preview_label: 'Vorschau (c{first} maskiert)',
|
||||
cloze_extra_label: 'Extra (optional)',
|
||||
cloze_extra_placeholder: 'Zusätzlicher Kontext, wird unter der Antwort gezeigt.',
|
||||
create: 'Karte anlegen',
|
||||
creating: 'Speichere…',
|
||||
cancel: 'Abbrechen',
|
||||
create_failed: 'Anlegen fehlgeschlagen: {msg}',
|
||||
toast_basic: 'Karte angelegt',
|
||||
toast_basic_reverse: '2 Reviews initialisiert (front→back, back→front)',
|
||||
toast_cloze: '{n} Reviews initialisiert (1 pro Cluster)',
|
||||
decks_load_failed: 'Decks konnten nicht geladen werden: {msg}',
|
||||
},
|
||||
card_edit: {
|
||||
title: 'Karte bearbeiten',
|
||||
back: '← Zurück zum Deck',
|
||||
type_locked_help: 'Der Card-Type kann nicht geändert werden — die Reviews-Tabelle hängt am Type.',
|
||||
save: 'Speichern',
|
||||
saving: 'Speichere…',
|
||||
cancel: 'Abbrechen',
|
||||
delete: 'Löschen',
|
||||
deleting: 'Lösche…',
|
||||
delete_confirm: 'Karte wirklich löschen? Reviews werden mit gelöscht.',
|
||||
updated: 'Karte aktualisiert',
|
||||
save_failed: 'Speichern fehlgeschlagen: {msg}',
|
||||
delete_failed: 'Löschen fehlgeschlagen: {msg}',
|
||||
deleted: 'Karte gelöscht',
|
||||
},
|
||||
study: {
|
||||
title: 'Lernen',
|
||||
empty: 'Keine Decks.',
|
||||
none_due: 'Aktuell nichts fällig.',
|
||||
study_now: 'Jetzt lernen',
|
||||
due_count: '{n} fällig',
|
||||
},
|
||||
study_session: {
|
||||
back: '← Übersicht',
|
||||
all_done: 'Geschafft! Alle fälligen Karten gemacht.',
|
||||
stats: 'Reviews: {reviewed} · Wieder: {again}',
|
||||
reveal: 'Lösung zeigen',
|
||||
reveal_hint: 'Leertaste / Enter zum Aufdecken',
|
||||
grade_again: 'Wieder',
|
||||
grade_hard: 'Schwer',
|
||||
grade_good: 'Gut',
|
||||
grade_easy: 'Leicht',
|
||||
grade_hint: '1=Wieder · 2=Schwer · 3=Gut · 4=Leicht',
|
||||
loading: 'Lade…',
|
||||
error: 'Fehler: {msg}',
|
||||
},
|
||||
import: {
|
||||
title: 'Importieren',
|
||||
intro: 'Übernimm Decks und Karten aus einer Anki-Datei (.apkg oder .colpkg). FSRS-Verlauf wird nicht übernommen — alle Karten starten als „neu".',
|
||||
what_works_title: 'Was wird übernommen',
|
||||
what_works_decks: 'Decks (Anki-Hierarchie Foo::Bar wird zu Foo / Bar).',
|
||||
what_works_basic: 'Basic + Basic-Reverse: Front/Back direkt.',
|
||||
what_works_cloze: 'Cloze: {{c1::…}} wird mit Sub-Index pro Cluster angelegt.',
|
||||
what_skipped_title: 'Was nicht übernommen wird',
|
||||
what_skipped_media: 'Bilder + Audio (kommen mit der Plattform-Anbindung in einer späteren Phase).',
|
||||
what_skipped_history: 'FSRS-Lernverlauf (Anki-Reviews werden bewusst neu aufgesetzt).',
|
||||
what_skipped_addons: 'Add-on-spezifische Card-Types (image-occlusion etc.).',
|
||||
anki_label: 'Aus Anki importieren',
|
||||
dropzone: '📦 .apkg-Datei hier ablegen oder klicken',
|
||||
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Bilder + Audio werden in dieser Phase nicht übernommen.',
|
||||
parsing: 'Lese {file}…',
|
||||
preview_found: 'Gefunden in',
|
||||
preview_decks_one: '1 Deck',
|
||||
preview_decks: '{n} Decks',
|
||||
preview_cards_one: '1 Karte',
|
||||
preview_cards: '{n} Karten',
|
||||
preview_breakdown: '({basic} basic, {basic_reverse} basic-reverse, {cloze} cloze)',
|
||||
preview_media: '{n} Medien (werden in dieser Phase NICHT übernommen)',
|
||||
preview_skipped: '{n} übersprungen (unbekannter Typ)',
|
||||
preview_warnings: 'Hinweise ({n})',
|
||||
cancel: 'Abbrechen',
|
||||
import_now: 'Importieren',
|
||||
stage_decks: 'Lege Decks an · {current} / {total}',
|
||||
stage_cards: 'Importiere Karten · {current} / {total}',
|
||||
stage_done: 'Fertig.',
|
||||
done_summary_one: '✓ {cards} Karten in 1 Deck angelegt.',
|
||||
done_summary: '✓ {cards} Karten in {decks} Decks angelegt.',
|
||||
done_failures: '{n} Fehler',
|
||||
done_more: 'Weitere Datei',
|
||||
error_label: 'Fehler: {msg}',
|
||||
retry: 'Erneut versuchen',
|
||||
},
|
||||
inbox_banner: {
|
||||
label: '📥 Inbox',
|
||||
count_one: '1 eingegangene Karte aus anderen Apps',
|
||||
count: '{n} eingegangene Karten aus anderen Apps',
|
||||
cta: '— sortieren →',
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
user_id_label: 'User-ID',
|
||||
logout: 'Abmelden',
|
||||
phase2_hint:
|
||||
'Phase-2-Hinweis: aktuell ist die Identität ein Dev-Stub (sessionStorage). Mit Auth-Föderation wechselt das auf einen mana-auth-Login gegen auth.mana.how.',
|
||||
export_title: 'Daten-Export',
|
||||
export_intro:
|
||||
'Lade alle deine Cards-Daten als JSON herunter — Decks, Karten, Reviews, Study-Sessions, Tags, Media-Refs, Import-Jobs. DSGVO Art. 15/20.',
|
||||
export_button: 'Daten exportieren',
|
||||
export_loading: 'Lade…',
|
||||
export_done: 'Export geladen: {decks} Decks, {cards} Karten, {reviews} Reviews.',
|
||||
export_failed: 'Export fehlgeschlagen: {msg}',
|
||||
delete_title: 'Konto löschen',
|
||||
delete_intro:
|
||||
'Löscht unwiderruflich alle deine Cards-Daten. DSGVO Art. 17. Andere mana-Apps (Memoro, Who, …) behalten ihre Daten unabhängig — wenn du dort auch löschen willst, mach das jeweils dort oder nutze die Verein-DSGVO-Sammelanfrage über mana-admin.',
|
||||
delete_button: 'Alle Cards-Daten löschen',
|
||||
delete_loading: 'Lösche…',
|
||||
delete_confirm:
|
||||
'ALLE deine Cards-Daten werden unwiderruflich gelöscht. Tippe LÖSCHEN zur Bestätigung.',
|
||||
delete_confirm_word: 'LÖSCHEN',
|
||||
delete_done: 'Gelöscht: {decks} Decks, {imports} Import-Jobs.',
|
||||
delete_failed: 'Löschen fehlgeschlagen: {msg}',
|
||||
},
|
||||
stats: {
|
||||
title: 'Statistik',
|
||||
generated_at: 'Stand {date}',
|
||||
decks: 'Decks',
|
||||
cards: 'Karten',
|
||||
reviews: 'Reviews',
|
||||
due_now: 'Fällig jetzt',
|
||||
days_title: 'Lerntage',
|
||||
streak: 'Streak: {n} Tage · letzte 7 Tage: {total} Reviews',
|
||||
streak_one: 'Streak: 1 Tag · letzte 7 Tage: {total} Reviews',
|
||||
fsrs_title: 'FSRS-Status',
|
||||
fsrs_intro: 'Verteilung deiner Karten-Reviews über die FSRS-Zustände.',
|
||||
fsrs_new: 'Neu',
|
||||
fsrs_learning: 'Lernend',
|
||||
fsrs_review: 'Review',
|
||||
fsrs_relearning: 'Relearning',
|
||||
loading: 'Lade…',
|
||||
error: 'Fehler: {msg}',
|
||||
},
|
||||
common: {
|
||||
empty: '(leer)',
|
||||
},
|
||||
};
|
||||
213
apps/web/src/lib/i18n/en.ts
Normal file
213
apps/web/src/lib/i18n/en.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import type { TranslationNode } from './de.ts';
|
||||
|
||||
export const en: TranslationNode = {
|
||||
app: {
|
||||
name: 'Cards',
|
||||
title_suffix: 'Cards',
|
||||
},
|
||||
nav: {
|
||||
decks: 'Decks',
|
||||
study: 'Study',
|
||||
import: 'Import',
|
||||
stats: 'Stats',
|
||||
login_dev: 'Login (dev)',
|
||||
account: 'Account',
|
||||
},
|
||||
landing: {
|
||||
welcome: 'Spaced-repetition flashcards.',
|
||||
intro:
|
||||
'Cardecky is the federated flashcard app of mana e.V. — FSRS scheduler, cloze cards, Anki import.',
|
||||
cta_login: 'Login (dev)',
|
||||
dev_user_prompt: 'User ID (dev):',
|
||||
},
|
||||
decks: {
|
||||
title: 'Decks',
|
||||
new: 'New deck',
|
||||
empty: 'No decks yet.',
|
||||
empty_cta: 'Create first deck',
|
||||
loading: 'Loading…',
|
||||
error: 'Error: {msg}',
|
||||
card_count: '{n} cards',
|
||||
card_count_one: '1 card',
|
||||
due_count: '{n} due',
|
||||
delete_confirm:
|
||||
'Really delete deck "{name}"? All cards + review data will be lost.',
|
||||
deleted: 'Deck "{name}" deleted',
|
||||
delete_failed: 'Delete failed: {msg}',
|
||||
},
|
||||
deck_detail: {
|
||||
back: '← Back to decks',
|
||||
study_button: 'Study',
|
||||
new_card: 'New card',
|
||||
empty: 'No cards in this deck.',
|
||||
empty_cta: 'Create first card →',
|
||||
card_summary_due: '{cards} · {due} due',
|
||||
card_delete_aria: 'Delete card',
|
||||
card_delete_label: 'Delete',
|
||||
card_delete_confirm: 'Really delete card? Reviews will be deleted with it.',
|
||||
},
|
||||
deck_new: {
|
||||
title: 'New deck',
|
||||
name_label: 'Name',
|
||||
description_label: 'Description (optional)',
|
||||
color_label: 'Color (optional)',
|
||||
create: 'Create deck',
|
||||
creating: 'Creating…',
|
||||
cancel: 'Cancel',
|
||||
create_failed: 'Create failed: {msg}',
|
||||
},
|
||||
card_new: {
|
||||
title: 'New card',
|
||||
back: '← Back',
|
||||
deck_label: 'Deck',
|
||||
type_label: 'Type',
|
||||
type_basic: 'Basic (front → back)',
|
||||
type_basic_reverse: 'Basic + Reverse (front ↔ back, 2 reviews)',
|
||||
type_cloze: 'Cloze (fill-in-the-blank, 1 review per cluster)',
|
||||
front_label: 'Front (Markdown)',
|
||||
back_label: 'Back (Markdown)',
|
||||
back_placeholder: 'Answer',
|
||||
front_placeholder: '# Markdown is allowed\n**bold**, _italic_, `code`',
|
||||
preview_label: 'Preview',
|
||||
cloze_text_label: 'Text with blanks (Markdown)',
|
||||
cloze_text_placeholder: 'The capital of {{c1::France}} is {{c2::Paris}}.',
|
||||
cloze_help: '{{c1::Answer}} defines a blank. Each cluster ID (c1, c2, …) becomes its own review.',
|
||||
cloze_no_clusters: 'At least one {{cN::…}} cluster is required.',
|
||||
cloze_clusters_detected: '{n} clusters detected: c{ids} → {n} reviews.',
|
||||
cloze_preview_label: 'Preview (c{first} masked)',
|
||||
cloze_extra_label: 'Extra (optional)',
|
||||
cloze_extra_placeholder: 'Additional context, shown below the answer.',
|
||||
create: 'Create card',
|
||||
creating: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
create_failed: 'Create failed: {msg}',
|
||||
toast_basic: 'Card created',
|
||||
toast_basic_reverse: '2 reviews initialized (front→back, back→front)',
|
||||
toast_cloze: '{n} reviews initialized (1 per cluster)',
|
||||
decks_load_failed: 'Could not load decks: {msg}',
|
||||
},
|
||||
card_edit: {
|
||||
title: 'Edit card',
|
||||
back: '← Back to deck',
|
||||
type_locked_help: 'The card type cannot be changed — the reviews table depends on it.',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
delete_confirm: 'Really delete card? Reviews will be deleted with it.',
|
||||
updated: 'Card updated',
|
||||
save_failed: 'Save failed: {msg}',
|
||||
delete_failed: 'Delete failed: {msg}',
|
||||
deleted: 'Card deleted',
|
||||
},
|
||||
study: {
|
||||
title: 'Study',
|
||||
empty: 'No decks.',
|
||||
none_due: 'Nothing due right now.',
|
||||
study_now: 'Study now',
|
||||
due_count: '{n} due',
|
||||
},
|
||||
study_session: {
|
||||
back: '← Overview',
|
||||
all_done: 'Done! All due cards reviewed.',
|
||||
stats: 'Reviews: {reviewed} · Again: {again}',
|
||||
reveal: 'Show answer',
|
||||
reveal_hint: 'Space / Enter to reveal',
|
||||
grade_again: 'Again',
|
||||
grade_hard: 'Hard',
|
||||
grade_good: 'Good',
|
||||
grade_easy: 'Easy',
|
||||
grade_hint: '1=Again · 2=Hard · 3=Good · 4=Easy',
|
||||
loading: 'Loading…',
|
||||
error: 'Error: {msg}',
|
||||
},
|
||||
import: {
|
||||
title: 'Import',
|
||||
intro: 'Import decks and cards from an Anki file (.apkg or .colpkg). FSRS history is not carried over — all cards start as "new".',
|
||||
what_works_title: 'What is imported',
|
||||
what_works_decks: 'Decks (Anki hierarchy Foo::Bar becomes Foo / Bar).',
|
||||
what_works_basic: 'Basic + Basic-Reverse: front/back directly.',
|
||||
what_works_cloze: 'Cloze: {{c1::…}} is created with sub-index per cluster.',
|
||||
what_skipped_title: 'What is not imported',
|
||||
what_skipped_media: 'Images + audio (will arrive with the platform integration in a later phase).',
|
||||
what_skipped_history: 'FSRS learning history (Anki reviews are deliberately reset).',
|
||||
what_skipped_addons: 'Add-on specific card types (image-occlusion etc.).',
|
||||
anki_label: 'Import from Anki',
|
||||
dropzone: '📦 Drop .apkg file here or click',
|
||||
dropzone_hint: 'Basic, Basic + Reverse, Cloze · Images + audio are not imported in this phase.',
|
||||
parsing: 'Reading {file}…',
|
||||
preview_found: 'Found in',
|
||||
preview_decks_one: '1 deck',
|
||||
preview_decks: '{n} decks',
|
||||
preview_cards_one: '1 card',
|
||||
preview_cards: '{n} cards',
|
||||
preview_breakdown: '({basic} basic, {basic_reverse} basic-reverse, {cloze} cloze)',
|
||||
preview_media: '{n} media files (will NOT be imported in this phase)',
|
||||
preview_skipped: '{n} skipped (unknown type)',
|
||||
preview_warnings: 'Notes ({n})',
|
||||
cancel: 'Cancel',
|
||||
import_now: 'Import',
|
||||
stage_decks: 'Creating decks · {current} / {total}',
|
||||
stage_cards: 'Importing cards · {current} / {total}',
|
||||
stage_done: 'Done.',
|
||||
done_summary_one: '✓ {cards} cards in 1 deck.',
|
||||
done_summary: '✓ {cards} cards in {decks} decks.',
|
||||
done_failures: '{n} errors',
|
||||
done_more: 'Another file',
|
||||
error_label: 'Error: {msg}',
|
||||
retry: 'Try again',
|
||||
},
|
||||
inbox_banner: {
|
||||
label: '📥 Inbox',
|
||||
count_one: '1 incoming card from other apps',
|
||||
count: '{n} incoming cards from other apps',
|
||||
cta: '— sort →',
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
user_id_label: 'User ID',
|
||||
logout: 'Sign out',
|
||||
phase2_hint:
|
||||
'Phase 2 note: identity is currently a dev stub (sessionStorage). With auth federation, this switches to a mana-auth login against auth.mana.how.',
|
||||
export_title: 'Data export',
|
||||
export_intro:
|
||||
'Download all your Cards data as JSON — decks, cards, reviews, study sessions, tags, media refs, import jobs. GDPR Art. 15/20.',
|
||||
export_button: 'Export data',
|
||||
export_loading: 'Loading…',
|
||||
export_done: 'Export downloaded: {decks} decks, {cards} cards, {reviews} reviews.',
|
||||
export_failed: 'Export failed: {msg}',
|
||||
delete_title: 'Delete account',
|
||||
delete_intro:
|
||||
'Irreversibly deletes all your Cards data. GDPR Art. 17. Other mana apps (Memoro, Who, …) keep their data independently — if you want to delete there too, do it there or use the Verein-wide GDPR collective request via mana-admin.',
|
||||
delete_button: 'Delete all Cards data',
|
||||
delete_loading: 'Deleting…',
|
||||
delete_confirm:
|
||||
'ALL your Cards data will be deleted irreversibly. Type DELETE to confirm.',
|
||||
delete_confirm_word: 'DELETE',
|
||||
delete_done: 'Deleted: {decks} decks, {imports} import jobs.',
|
||||
delete_failed: 'Delete failed: {msg}',
|
||||
},
|
||||
stats: {
|
||||
title: 'Stats',
|
||||
generated_at: 'As of {date}',
|
||||
decks: 'Decks',
|
||||
cards: 'Cards',
|
||||
reviews: 'Reviews',
|
||||
due_now: 'Due now',
|
||||
days_title: 'Active days',
|
||||
streak: 'Streak: {n} days · last 7 days: {total} reviews',
|
||||
streak_one: 'Streak: 1 day · last 7 days: {total} reviews',
|
||||
fsrs_title: 'FSRS status',
|
||||
fsrs_intro: 'Distribution of your card reviews across FSRS states.',
|
||||
fsrs_new: 'New',
|
||||
fsrs_learning: 'Learning',
|
||||
fsrs_review: 'Review',
|
||||
fsrs_relearning: 'Relearning',
|
||||
loading: 'Loading…',
|
||||
error: 'Error: {msg}',
|
||||
},
|
||||
common: {
|
||||
empty: '(empty)',
|
||||
},
|
||||
};
|
||||
91
apps/web/src/lib/i18n/index.svelte.ts
Normal file
91
apps/web/src/lib/i18n/index.svelte.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Schmaler i18n-Core. Eigenbau statt svelte-i18n, weil:
|
||||
* - 2 Sprachen, ~150 Strings → keine Compile-Time-Type-Safety nötig
|
||||
* - keine Pluralregeln (`_one` als manueller Fallback reicht)
|
||||
* - keine Server-Side-Lade-Komplexität (alles eager, ~3kB)
|
||||
*
|
||||
* Locale wird in localStorage persistiert, Default = navigator.language
|
||||
* (DE/EN), Fallback = de.
|
||||
*/
|
||||
|
||||
import { de, type TranslationNode } from './de.ts';
|
||||
import { en } from './en.ts';
|
||||
|
||||
export type Locale = 'de' | 'en';
|
||||
|
||||
const TRANSLATIONS: Record<Locale, TranslationNode> = { de, en };
|
||||
const STORAGE_KEY = 'cards.locale';
|
||||
|
||||
function detectInitial(): Locale {
|
||||
if (typeof window === 'undefined') return 'de';
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'de' || stored === 'en') return stored;
|
||||
const nav = window.navigator?.language ?? '';
|
||||
return nav.toLowerCase().startsWith('en') ? 'en' : 'de';
|
||||
}
|
||||
|
||||
class I18nState {
|
||||
current = $state<Locale>('de');
|
||||
|
||||
constructor() {
|
||||
// onMount-äquivalent: nur in der Browser-Phase initialisieren.
|
||||
if (typeof window !== 'undefined') {
|
||||
this.current = detectInitial();
|
||||
}
|
||||
}
|
||||
|
||||
set(locale: Locale) {
|
||||
this.current = locale;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = new I18nState();
|
||||
|
||||
/**
|
||||
* Resolve dotted key against the current locale tree. Returns the key
|
||||
* itself on miss so missing strings are obvious in the UI rather than
|
||||
* silently empty.
|
||||
*/
|
||||
function resolve(locale: Locale, key: string): string {
|
||||
const parts = key.split('.');
|
||||
let cur: unknown = TRANSLATIONS[locale];
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === 'object' && p in (cur as Record<string, unknown>)) {
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return typeof cur === 'string' ? cur : key;
|
||||
}
|
||||
|
||||
function interpolate(template: string, vars: Record<string, string | number> = {}): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, k: string) => {
|
||||
const v = vars[k];
|
||||
return v === undefined ? `{${k}}` : String(v);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation helper. Use as `t('decks.title')` for plain strings or
|
||||
* `t('decks.error', { msg })` for interpolation. Reads `i18n.current`
|
||||
* reactively — Svelte runes recompute when the locale changes.
|
||||
*/
|
||||
export function t(key: string, vars?: Record<string, string | number>): string {
|
||||
const raw = resolve(i18n.current, key);
|
||||
return vars ? interpolate(raw, vars) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plural-Helper — minimal: liefert `<key>_one` für n=1, sonst `<key>`.
|
||||
* Beide Schlüssel müssen im Locale existieren. Reicht für DE/EN-MVP,
|
||||
* komplexere Pluralregeln (FR/IT/PL) brauchen später Intl.PluralRules.
|
||||
*/
|
||||
export function tn(baseKey: string, n: number, vars?: Record<string, string | number>): string {
|
||||
const key = n === 1 ? `${baseKey}_one` : baseKey;
|
||||
return t(key, { ...(vars ?? {}), n });
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
onMount(() => {
|
||||
if (devUser.id) goto('/decks');
|
||||
|
|
@ -9,20 +10,15 @@
|
|||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl py-12 text-center">
|
||||
<h1 class="text-3xl font-semibold">Cards</h1>
|
||||
<p class="mt-2 text-[var(--color-muted)]">
|
||||
Karteikarten-App des Vereins <strong>mana e.V.</strong>
|
||||
</p>
|
||||
<h1 class="text-3xl font-semibold">{t('app.name')}</h1>
|
||||
<p class="mt-2 text-[var(--color-muted)]">{t('landing.welcome')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('landing.intro')}</p>
|
||||
|
||||
{#if !devUser.id}
|
||||
<div
|
||||
class="mt-8 rounded-lg border bg-[var(--color-card)] border-[var(--color-border)] p-6 text-left"
|
||||
>
|
||||
<h2 class="text-lg font-medium">Phase 0 — Dev-Login</h2>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Bevor mana-auth-Föderation steht (Phase 2), schaltet ein Dev-Stub den Login frei.
|
||||
Trag eine User-ID ein (z.B. <code>u-test-1</code>).
|
||||
</p>
|
||||
<h2 class="text-lg font-medium">{t('landing.cta_login')}</h2>
|
||||
<form
|
||||
class="mt-4 flex gap-2"
|
||||
onsubmit={(e) => {
|
||||
|
|
@ -38,12 +34,13 @@
|
|||
<input
|
||||
name="user_id"
|
||||
placeholder="u-test-1"
|
||||
aria-label={t('landing.dev_user_prompt')}
|
||||
class="flex-1 rounded border bg-[var(--color-bg)] border-[var(--color-border)] px-3 py-2 text-sm"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
||||
type="submit">Weiter</button
|
||||
type="submit">→</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,25 +3,14 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { exportMe, deleteMe } from '$lib/api/me.ts';
|
||||
import { listDecks } from '$lib/api/decks.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let stats = $state<{ decks: number; cards: number } | null>(null);
|
||||
let exporting = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await listDecks();
|
||||
const cards = r.decks.reduce((sum) => sum, 0); // listDecks returns total decks; cards via separate calls
|
||||
stats = { decks: r.total, cards };
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
onMount(() => {
|
||||
if (!devUser.id) goto('/');
|
||||
});
|
||||
|
||||
async function onExport() {
|
||||
|
|
@ -38,28 +27,30 @@
|
|||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toasts.success(
|
||||
`Export geladen: ${data.data.decks.length} Decks, ${data.data.cards.length} Karten, ${data.data.reviews.length} Reviews.`
|
||||
t('account.export_done', {
|
||||
decks: data.data.decks.length,
|
||||
cards: data.data.cards.length,
|
||||
reviews: data.data.reviews.length,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
toasts.error(`Export fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('account.export_failed', { msg: (e as Error).message }));
|
||||
} finally {
|
||||
exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
const confirmation = prompt(
|
||||
'ALLE deine Cards-Daten werden unwiderruflich gelöscht. Tippe LÖSCHEN zur Bestätigung.'
|
||||
);
|
||||
if (confirmation !== 'LÖSCHEN') return;
|
||||
const confirmation = prompt(t('account.delete_confirm'));
|
||||
if (confirmation !== t('account.delete_confirm_word')) return;
|
||||
deleting = true;
|
||||
try {
|
||||
const r = await deleteMe();
|
||||
toasts.success(`Gelöscht: ${r.counts.decks} Decks, ${r.counts.import_jobs} Import-Jobs.`);
|
||||
toasts.success(t('account.delete_done', { decks: r.counts.decks, imports: r.counts.import_jobs }));
|
||||
devUser.clear();
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
toasts.error(`Löschen fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('account.delete_failed', { msg: (e as Error).message }));
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -71,15 +62,15 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Account · Cards</title>
|
||||
<title>{t('account.title')} · {t('app.title_suffix')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 class="text-2xl font-semibold">Account</h1>
|
||||
<h1 class="text-2xl font-semibold">{t('account.title')}</h1>
|
||||
|
||||
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="text-sm">
|
||||
<div class="text-[var(--color-muted)]">User-ID</div>
|
||||
<div class="text-[var(--color-muted)]">{t('account.user_id_label')}</div>
|
||||
<code class="mt-1 block break-all text-sm">{devUser.id ?? '—'}</code>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-3">
|
||||
|
|
@ -88,45 +79,35 @@
|
|||
onclick={logout}
|
||||
class="rounded border border-[var(--color-border)] px-3 py-1.5 text-sm hover:bg-[var(--color-border)]/40"
|
||||
>
|
||||
Abmelden
|
||||
{t('account.logout')}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-[var(--color-muted)]">
|
||||
Phase-2-Hinweis: aktuell ist die Identität ein Dev-Stub (sessionStorage). Mit Auth-Föderation
|
||||
wechselt das auf einen mana-auth-Login gegen <code>auth.mana.how</code>.
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-[var(--color-muted)]">{t('account.phase2_hint')}</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<h2 class="text-lg font-medium">Daten-Export</h2>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Lade alle deine Cards-Daten als JSON herunter — Decks, Karten, Reviews, Study-Sessions, Tags,
|
||||
Media-Refs, Import-Jobs. DSGVO Art. 15/20.
|
||||
</p>
|
||||
<h2 class="text-lg font-medium">{t('account.export_title')}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('account.export_intro')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onExport}
|
||||
disabled={exporting}
|
||||
class="mt-3 rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Lade…' : 'Daten exportieren'}
|
||||
{exporting ? t('account.export_loading') : t('account.export_button')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-lg border border-[var(--color-danger)]/30 bg-[var(--color-card)] p-4">
|
||||
<h2 class="text-lg font-medium text-[var(--color-danger)]">Konto löschen</h2>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Löscht unwiderruflich alle deine Cards-Daten. DSGVO Art. 17. Andere mana-Apps (Memoro, Who, …)
|
||||
behalten ihre Daten unabhängig — wenn du dort auch löschen willst, mach das jeweils dort oder
|
||||
nutze die Verein-DSGVO-Sammelanfrage über mana-admin.
|
||||
</p>
|
||||
<h2 class="text-lg font-medium text-[var(--color-danger)]">{t('account.delete_title')}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('account.delete_intro')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDelete}
|
||||
disabled={deleting}
|
||||
class="mt-3 rounded border border-[var(--color-danger)] px-4 py-1.5 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Lösche…' : 'Alle Cards-Daten löschen'}
|
||||
{deleting ? t('account.delete_loading') : t('account.delete_button')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { renderMarkdown } from '$lib/markdown.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let card = $state<Card | null>(null);
|
||||
let cardType = $state<CardType>('basic');
|
||||
|
|
@ -78,76 +79,71 @@
|
|||
: { text: text.trim() }
|
||||
: { front: front.trim(), back: back.trim() };
|
||||
const updated = await updateCard(card.id, { fields });
|
||||
toasts.success('Karte aktualisiert');
|
||||
toasts.success(t('card_edit.updated'));
|
||||
goto(`/decks/${updated.deck_id}`);
|
||||
} catch (e) {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('card_edit.save_failed', { msg: (e as Error).message }));
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!card) return;
|
||||
if (!confirm('Karte wirklich löschen? Reviews werden mit gelöscht.')) return;
|
||||
if (!confirm(t('card_edit.delete_confirm'))) return;
|
||||
try {
|
||||
await deleteCard(card.id);
|
||||
toasts.success('Karte gelöscht');
|
||||
toasts.success(t('card_edit.deleted'));
|
||||
goto(`/decks/${card.deck_id}`);
|
||||
} catch (e) {
|
||||
toasts.error(`Löschen fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('card_edit.delete_failed', { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Karte bearbeiten · Cards</title>
|
||||
<title>{t('card_edit.title')} · {t('app.title_suffix')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6">
|
||||
{#if loading}
|
||||
<p class="text-[var(--color-muted)]">Lade…</p>
|
||||
<p class="text-[var(--color-muted)]">{t('decks.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="text-[var(--color-danger)]">Fehler: {error}</p>
|
||||
<p class="text-[var(--color-danger)]">{t('decks.error', { msg: 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
|
||||
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">{t('card_edit.back')}</a
|
||||
>
|
||||
<div class="mt-2 flex items-baseline justify-between gap-3">
|
||||
<h1 class="text-2xl font-semibold">Karte bearbeiten</h1>
|
||||
<h1 class="text-2xl font-semibold">{t('card_edit.title')}</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>
|
||||
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_edit.type_locked_help')}</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>
|
||||
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
required
|
||||
rows="6"
|
||||
placeholder="Die Hauptstadt von {'{{c1::Frankreich}}'} ist {'{{c2::Paris}}'}."
|
||||
placeholder={t('card_new.cloze_text_placeholder')}
|
||||
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>
|
||||
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_new.cloze_help')}</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>
|
||||
<p class="mt-1 text-xs text-[var(--color-danger)]">{t('card_new.cloze_no_clusters')}</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.
|
||||
{t('card_new.cloze_clusters_detected', {
|
||||
n: clusterIds.length,
|
||||
ids: clusterIds.join(', c'),
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -155,18 +151,18 @@
|
|||
{#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)
|
||||
{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}
|
||||
</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>
|
||||
<span class="text-sm font-medium">{t('card_new.cloze_extra_label')}</span>
|
||||
<textarea
|
||||
bind:value={extra}
|
||||
rows="3"
|
||||
placeholder="Zusätzlicher Kontext, wird unter der Antwort gezeigt."
|
||||
placeholder={t('card_new.cloze_extra_placeholder')}
|
||||
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>
|
||||
|
|
@ -174,7 +170,7 @@
|
|||
<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>
|
||||
<span class="text-sm font-medium">{t('card_new.front_label')}</span>
|
||||
<textarea
|
||||
bind:value={front}
|
||||
required
|
||||
|
|
@ -184,14 +180,14 @@
|
|||
</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="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</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>
|
||||
<span class="text-sm font-medium">{t('card_new.back_label')}</span>
|
||||
<textarea
|
||||
bind:value={back}
|
||||
required
|
||||
|
|
@ -201,7 +197,7 @@
|
|||
</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="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -216,11 +212,11 @@
|
|||
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'}
|
||||
{saving ? t('card_edit.saving') : t('card_edit.save')}
|
||||
</button>
|
||||
<a
|
||||
href="/decks/{card.deck_id}"
|
||||
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">Abbrechen</a
|
||||
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">{t('card_edit.cancel')}</a
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -228,7 +224,7 @@
|
|||
onclick={onDelete}
|
||||
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-danger)]"
|
||||
>
|
||||
Löschen
|
||||
{t('card_edit.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { renderMarkdown } from '$lib/markdown.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
type DeckLite = { id: string; name: string };
|
||||
|
||||
|
|
@ -52,7 +53,7 @@
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
toasts.error(`Decks konnten nicht geladen werden: ${(e as Error).message}`);
|
||||
toasts.error(t('card_new.decks_load_failed', { msg: (e as Error).message }));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -78,28 +79,28 @@
|
|||
const card = await createCard({ deck_id: deckId, type: cardType, fields });
|
||||
const msg =
|
||||
cardType === 'cloze'
|
||||
? `${clusterIds.length} Reviews initialisiert (1 pro Cluster)`
|
||||
? t('card_new.toast_cloze', { n: clusterIds.length })
|
||||
: cardType === 'basic-reverse'
|
||||
? '2 Reviews initialisiert (front→back, back→front)'
|
||||
: 'Karte angelegt';
|
||||
? t('card_new.toast_basic_reverse')
|
||||
: t('card_new.toast_basic');
|
||||
toasts.success(msg);
|
||||
goto(`/decks/${card.deck_id}`);
|
||||
} catch (e) {
|
||||
toasts.error(`Anlegen fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('card_new.create_failed', { msg: (e as Error).message }));
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
>← Zurück</a
|
||||
>{t('card_new.back')}</a
|
||||
>
|
||||
<h1 class="mt-2 text-2xl font-semibold">Neue Karte</h1>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{t('card_new.title')}</h1>
|
||||
|
||||
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Deck</span>
|
||||
<span class="text-sm font-medium">{t('card_new.deck_label')}</span>
|
||||
<select
|
||||
bind:value={deckId}
|
||||
required
|
||||
|
|
@ -112,14 +113,14 @@
|
|||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Typ</span>
|
||||
<span class="text-sm font-medium">{t('card_new.type_label')}</span>
|
||||
<select
|
||||
bind:value={cardType}
|
||||
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
|
||||
>
|
||||
<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>
|
||||
<option value="basic">{t('card_new.type_basic')}</option>
|
||||
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
|
||||
<option value="cloze">{t('card_new.type_cloze')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -127,27 +128,24 @@
|
|||
{#if cardType === 'cloze'}
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Text mit Lücken (Markdown)</span>
|
||||
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
required
|
||||
rows="6"
|
||||
placeholder="Die Hauptstadt von {'{{c1::Frankreich}}'} ist {'{{c2::Paris}}'}."
|
||||
placeholder={t('card_new.cloze_text_placeholder')}
|
||||
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>
|
||||
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_new.cloze_help')}</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>
|
||||
<p class="mt-1 text-xs text-[var(--color-danger)]">{t('card_new.cloze_no_clusters')}</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.
|
||||
{t('card_new.cloze_clusters_detected', {
|
||||
n: clusterIds.length,
|
||||
ids: clusterIds.join(', c'),
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -155,18 +153,18 @@
|
|||
{#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)
|
||||
{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}
|
||||
</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>
|
||||
<span class="text-sm font-medium">{t('card_new.cloze_extra_label')}</span>
|
||||
<textarea
|
||||
bind:value={extra}
|
||||
rows="3"
|
||||
placeholder="Zusätzlicher Kontext, wird unter der Antwort gezeigt."
|
||||
placeholder={t('card_new.cloze_extra_placeholder')}
|
||||
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>
|
||||
|
|
@ -174,18 +172,18 @@
|
|||
<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>
|
||||
<span class="text-sm font-medium">{t('card_new.front_label')}</span>
|
||||
<textarea
|
||||
bind:value={front}
|
||||
required
|
||||
rows="8"
|
||||
placeholder="# Markdown ist erlaubt **fett**, _kursiv_, `code`"
|
||||
placeholder={t('card_new.front_placeholder')}
|
||||
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="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -193,18 +191,18 @@
|
|||
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Rückseite (Markdown)</span>
|
||||
<span class="text-sm font-medium">{t('card_new.back_label')}</span>
|
||||
<textarea
|
||||
bind:value={back}
|
||||
required
|
||||
rows="8"
|
||||
placeholder="Antwort"
|
||||
placeholder={t('card_new.back_placeholder')}
|
||||
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="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -218,11 +216,11 @@
|
|||
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'}
|
||||
{saving ? t('card_new.creating') : t('card_new.create')}
|
||||
</button>
|
||||
<a
|
||||
href={deckId ? `/decks/${deckId}` : '/decks'}
|
||||
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">Abbrechen</a
|
||||
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">{t('card_new.cancel')}</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import InboxBanner from '$lib/components/InboxBanner.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let decks = $state<Deck[]>([]);
|
||||
let loading = $state(true);
|
||||
|
|
@ -33,25 +34,23 @@
|
|||
}
|
||||
|
||||
async function onDelete(id: string, name: string) {
|
||||
if (!confirm(`Deck "${name}" wirklich löschen? Alle Karten + Review-Daten gehen verloren.`)) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(t('decks.delete_confirm', { name }))) return;
|
||||
try {
|
||||
await deleteDeck(id);
|
||||
toasts.success(`Deck "${name}" gelöscht`);
|
||||
toasts.success(t('decks.deleted', { name }));
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error(`Löschen fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('decks.delete_failed', { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Decks</h1>
|
||||
<h1 class="text-2xl font-semibold">{t('decks.title')}</h1>
|
||||
<a
|
||||
href="/decks/new"
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
||||
>Neues Deck</a
|
||||
>{t('decks.new')}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
@ -60,16 +59,16 @@
|
|||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="mt-8 text-[var(--color-muted)]">Lade…</p>
|
||||
<p class="mt-8 text-[var(--color-muted)]">{t('decks.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="mt-8 text-[var(--color-danger)]">Fehler: {error}</p>
|
||||
<p class="mt-8 text-[var(--color-danger)]">{t('decks.error', { msg: error })}</p>
|
||||
{:else if decks.length === 0}
|
||||
<div
|
||||
class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-12 text-center"
|
||||
>
|
||||
<p class="text-[var(--color-muted)]">Noch keine Decks.</p>
|
||||
<p class="text-[var(--color-muted)]">{t('decks.empty')}</p>
|
||||
<a href="/decks/new" class="mt-4 inline-block text-[var(--color-primary)] hover:underline"
|
||||
>Erstes Deck anlegen →</a
|
||||
>{t('decks.empty_cta')} →</a
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -99,8 +98,8 @@
|
|||
<button
|
||||
class="absolute right-2 top-2 hidden rounded p-1 text-[var(--color-muted)] hover:bg-[var(--color-border)] hover:text-[var(--color-danger)] group-hover:block"
|
||||
onclick={() => onDelete(deck.id, deck.name)}
|
||||
aria-label="Deck löschen"
|
||||
title="Deck löschen"
|
||||
aria-label={t('decks.delete_confirm', { name: deck.name })}
|
||||
title={t('decks.delete_confirm', { name: deck.name })}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { listDueReviews } from '$lib/api/reviews.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t, tn } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let deck = $state<Deck | null>(null);
|
||||
let cards = $state<Card[]>([]);
|
||||
|
|
@ -45,30 +46,34 @@
|
|||
}
|
||||
|
||||
async function onDeleteCard(id: string) {
|
||||
if (!confirm('Karte wirklich löschen?')) return;
|
||||
if (!confirm(t('deck_detail.card_delete_confirm'))) return;
|
||||
try {
|
||||
await deleteCard(id);
|
||||
toasts.success('Karte gelöscht');
|
||||
toasts.success(t('card_edit.deleted'));
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toasts.error(`Löschen fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('card_edit.delete_failed', { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-[var(--color-muted)]">Lade…</p>
|
||||
<p class="text-[var(--color-muted)]">{t('decks.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="text-[var(--color-danger)]">Fehler: {error}</p>
|
||||
<p class="text-[var(--color-danger)]">{t('decks.error', { msg: error })}</p>
|
||||
{:else if deck}
|
||||
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
>← Decks</a
|
||||
>← {t('nav.decks')}</a
|
||||
>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if deck.color}
|
||||
<span class="h-4 w-4 rounded-full" style="background:{deck.color}"></span>
|
||||
<span
|
||||
class="h-4 w-4 rounded-full"
|
||||
style="background:{deck.color}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
<h1 class="text-2xl font-semibold">{deck.name}</h1>
|
||||
</div>
|
||||
|
|
@ -77,22 +82,22 @@
|
|||
href="/cards/new?deck={deck.id}"
|
||||
class="rounded border border-[var(--color-border)] px-3 py-2 text-sm hover:border-[var(--color-primary)]"
|
||||
>
|
||||
+ Karte
|
||||
+ {t('deck_detail.new_card')}
|
||||
</a>
|
||||
{#if dueCount > 0}
|
||||
<a
|
||||
href="/study/{deck.id}"
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
||||
>
|
||||
Lernen ({dueCount} fällig)
|
||||
{t('deck_detail.study_button')} ({t('study.due_count', { n: dueCount })})
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
disabled
|
||||
class="rounded bg-[var(--color-muted)] px-4 py-2 text-sm text-[var(--color-bg)] opacity-50"
|
||||
title="Keine Karten fällig"
|
||||
title={t('study.none_due')}
|
||||
>
|
||||
Lernen
|
||||
{t('deck_detail.study_button')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -103,18 +108,18 @@
|
|||
{/if}
|
||||
|
||||
<div class="mt-2 text-sm text-[var(--color-muted)]">
|
||||
{cards.length} Karte{cards.length === 1 ? '' : 'n'} · {dueCount} fällig
|
||||
{tn('decks.card_count', cards.length)} · {t('study.due_count', { n: dueCount })}
|
||||
</div>
|
||||
|
||||
{#if cards.length === 0}
|
||||
<div
|
||||
class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-12 text-center"
|
||||
>
|
||||
<p class="text-[var(--color-muted)]">Noch keine Karten in diesem Deck.</p>
|
||||
<p class="text-[var(--color-muted)]">{t('deck_detail.empty')}</p>
|
||||
<a
|
||||
href="/cards/new?deck={deck.id}"
|
||||
class="mt-4 inline-block text-[var(--color-primary)] hover:underline"
|
||||
>Erste Karte anlegen →</a
|
||||
>{t('deck_detail.empty_cta')}</a
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -132,19 +137,19 @@
|
|||
</div>
|
||||
<p class="mt-1 truncate text-sm">
|
||||
{#if card.type === 'cloze'}
|
||||
<span class="font-medium">{card.fields.text ?? '(leer)'}</span>
|
||||
<span class="font-medium">{card.fields.text ?? t('common.empty')}</span>
|
||||
{:else}
|
||||
<span class="font-medium">{card.fields.front ?? '(leer)'}</span>
|
||||
<span class="text-[var(--color-muted)]"> → {card.fields.back ?? '(leer)'}</span>
|
||||
<span class="font-medium">{card.fields.front ?? t('common.empty')}</span>
|
||||
<span class="text-[var(--color-muted)]"> → {card.fields.back ?? t('common.empty')}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</a>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 text-sm text-[var(--color-muted)] hover:text-[var(--color-danger)]"
|
||||
onclick={() => onDeleteCard(card.id)}
|
||||
aria-label="Karte löschen"
|
||||
aria-label={t('deck_detail.card_delete_aria')}
|
||||
>
|
||||
Löschen
|
||||
{t('deck_detail.card_delete_label')}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { createDeck } from '$lib/api/decks.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
|
|
@ -18,10 +19,10 @@
|
|||
description: description.trim() || undefined,
|
||||
color,
|
||||
});
|
||||
toasts.success(`Deck "${deck.name}" angelegt`);
|
||||
toasts.success(`${deck.name} ✓`);
|
||||
goto(`/decks/${deck.id}`);
|
||||
} catch (e) {
|
||||
toasts.error(`Anlegen fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('deck_new.create_failed', { msg: (e as Error).message }));
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,35 +30,33 @@
|
|||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
>← Zurück</a
|
||||
>{t('deck_detail.back')}</a
|
||||
>
|
||||
<h1 class="mt-2 text-2xl font-semibold">Neues Deck</h1>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{t('deck_new.title')}</h1>
|
||||
|
||||
<form class="mt-6 space-y-4" onsubmit={onSubmit}>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Name</span>
|
||||
<span class="text-sm font-medium">{t('deck_new.name_label')}</span>
|
||||
<input
|
||||
bind:value={name}
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="z.B. Konfuzius-Zitate"
|
||||
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Beschreibung</span>
|
||||
<span class="text-sm font-medium">{t('deck_new.description_label')}</span>
|
||||
<textarea
|
||||
bind:value={description}
|
||||
maxlength="2000"
|
||||
rows="3"
|
||||
placeholder="Optional"
|
||||
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">Farbe</span>
|
||||
<span class="text-sm font-medium">{t('deck_new.color_label')}</span>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={color}
|
||||
|
|
@ -71,10 +70,10 @@
|
|||
disabled={saving || !name.trim()}
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Deck anlegen'}
|
||||
{saving ? t('deck_new.creating') : t('deck_new.create')}
|
||||
</button>
|
||||
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
>Abbrechen</a
|
||||
>{t('deck_new.cancel')}</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import AnkiImport from '$lib/components/AnkiImport.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
onMount(() => {
|
||||
if (!devUser.id) {
|
||||
|
|
@ -12,32 +13,29 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Import · Cards</title>
|
||||
<title>{t('import.title')} · {t('app.title_suffix')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<h1 class="text-2xl font-semibold">Importieren</h1>
|
||||
<p class="mt-2 text-sm text-[var(--color-muted)]">
|
||||
Übernimm Decks und Karten aus einer Anki-Datei (<code>.apkg</code> oder <code>.colpkg</code>).
|
||||
FSRS-Verlauf wird nicht übernommen — alle Karten starten als „neu".
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold">{t('import.title')}</h1>
|
||||
<p class="mt-2 text-sm text-[var(--color-muted)]">{t('import.intro')}</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<AnkiImport />
|
||||
</div>
|
||||
|
||||
<aside class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-4 text-xs text-[var(--color-muted)]">
|
||||
<div class="mb-1 font-medium text-[var(--color-fg)]">Was wird übernommen</div>
|
||||
<div class="mb-1 font-medium text-[var(--color-fg)]">{t('import.what_works_title')}</div>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>Decks (Anki-Hierarchie <code>Foo::Bar</code> wird zu <code>Foo / Bar</code>).</li>
|
||||
<li>Basic + Basic-Reverse: Front/Back direkt.</li>
|
||||
<li>Cloze: <code>{'{{c1::…}}'}</code> wird mit Sub-Index pro Cluster angelegt.</li>
|
||||
<li>{t('import.what_works_decks')}</li>
|
||||
<li>{t('import.what_works_basic')}</li>
|
||||
<li>{t('import.what_works_cloze')}</li>
|
||||
</ul>
|
||||
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">Was nicht übernommen wird</div>
|
||||
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">{t('import.what_skipped_title')}</div>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>Bilder + Audio (kommen mit der Plattform-Anbindung in einer späteren Phase).</li>
|
||||
<li>FSRS-Lernverlauf (Anki-Reviews werden bewusst neu aufgesetzt).</li>
|
||||
<li>Add-on-spezifische Card-Types (image-occlusion etc.).</li>
|
||||
<li>{t('import.what_skipped_media')}</li>
|
||||
<li>{t('import.what_skipped_history')}</li>
|
||||
<li>{t('import.what_skipped_addons')}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { loadStats, type UserStats } from '$lib/api/me.ts';
|
||||
import { i18n, t, tn } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
let stats = $state<UserStats | null>(null);
|
||||
let loading = $state(true);
|
||||
|
|
@ -34,61 +35,71 @@
|
|||
|
||||
function dayLabel(iso: string): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||
const lang = i18n.current === 'en' ? 'en-US' : 'de-DE';
|
||||
return d.toLocaleDateString(lang, { weekday: 'short' });
|
||||
}
|
||||
|
||||
function fullDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const lang = i18n.current === 'en' ? 'en-US' : 'de-DE';
|
||||
return d.toLocaleString(lang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Statistik · Cards</title>
|
||||
<title>{t('stats.title')} · {t('app.title_suffix')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-6">
|
||||
<h1 class="text-2xl font-semibold">Statistik</h1>
|
||||
<h1 class="text-2xl font-semibold">{t('stats.title')}</h1>
|
||||
|
||||
{#if loading}
|
||||
<p class="mt-6 text-[var(--color-muted)]">Lade…</p>
|
||||
<p class="mt-6 text-[var(--color-muted)]">{t('stats.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="mt-6 text-[var(--color-danger)]">Fehler: {error}</p>
|
||||
<p class="mt-6 text-[var(--color-danger)]">{t('stats.error', { msg: error })}</p>
|
||||
{:else if stats}
|
||||
<p class="mt-1 text-xs text-[var(--color-muted)]">
|
||||
Stand {new Date(stats.generated_at).toLocaleString('de-DE')}
|
||||
{t('stats.generated_at', { date: fullDate(stats.generated_at) })}
|
||||
</p>
|
||||
|
||||
<section class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="text-xs text-[var(--color-muted)]">Decks</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">{t('stats.decks')}</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{stats.total_decks}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="text-xs text-[var(--color-muted)]">Karten</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">{t('stats.cards')}</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{stats.total_cards}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="text-xs text-[var(--color-muted)]">Reviews</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">{t('stats.reviews')}</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{stats.total_reviews}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="text-xs text-[var(--color-muted)]">Fällig jetzt</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">{t('stats.due_now')}</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{stats.due_now}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-medium">Lerntage</h2>
|
||||
<h2 class="text-lg font-medium">{t('stats.days_title')}</h2>
|
||||
<span class="text-xs text-[var(--color-muted)]">
|
||||
Streak: <strong class="text-[var(--color-fg)]">{stats.streak_days}</strong>
|
||||
{stats.streak_days === 1 ? 'Tag' : 'Tage'} · letzte 7 Tage:
|
||||
{reviewedTotal7} Reviews
|
||||
{tn('stats.streak', stats.streak_days, { total: reviewedTotal7 })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex h-32 items-end gap-2">
|
||||
<div class="mt-3 flex h-32 items-end gap-2" role="list">
|
||||
{#each stats.reviewed_per_day as d (d.day)}
|
||||
<div class="flex flex-1 flex-col items-center justify-end gap-1">
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-end gap-1"
|
||||
role="listitem"
|
||||
aria-label="{dayLabel(d.day)}: {d.n}"
|
||||
>
|
||||
<div class="text-xs tabular-nums">{d.n || ''}</div>
|
||||
<div
|
||||
class="w-full rounded-t bg-[var(--color-primary)]/80"
|
||||
style="height: {(d.n / peakDay) * 100}%; min-height: {d.n > 0 ? '4px' : '0'};"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="text-xs text-[var(--color-muted)]">{dayLabel(d.day)}</div>
|
||||
</div>
|
||||
|
|
@ -97,25 +108,23 @@
|
|||
</section>
|
||||
|
||||
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||
<h2 class="text-lg font-medium">FSRS-Status</h2>
|
||||
<p class="mt-1 text-xs text-[var(--color-muted)]">
|
||||
Verteilung deiner Karten-Reviews über die FSRS-Zustände.
|
||||
</p>
|
||||
<h2 class="text-lg font-medium">{t('stats.fsrs_title')}</h2>
|
||||
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('stats.fsrs_intro')}</p>
|
||||
<dl class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs text-[var(--color-muted)]">Neu</dt>
|
||||
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_new')}</dt>
|
||||
<dd class="text-xl font-semibold">{stats.state_counts.new}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-[var(--color-muted)]">Lernend</dt>
|
||||
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_learning')}</dt>
|
||||
<dd class="text-xl font-semibold">{stats.state_counts.learning}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-[var(--color-muted)]">Review</dt>
|
||||
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_review')}</dt>
|
||||
<dd class="text-xl font-semibold">{stats.state_counts.review}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-[var(--color-muted)]">Relearning</dt>
|
||||
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_relearning')}</dt>
|
||||
<dd class="text-xl font-semibold">{stats.state_counts.relearning}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { listDueReviews } from '$lib/api/reviews.ts';
|
||||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import InboxBanner from '$lib/components/InboxBanner.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
type Item = { deck: Deck; due: number };
|
||||
|
||||
|
|
@ -36,14 +37,14 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<h1 class="text-2xl font-semibold">Lernen</h1>
|
||||
<h1 class="text-2xl font-semibold">{t('study.title')}</h1>
|
||||
|
||||
<div class="mt-4">
|
||||
<InboxBanner />
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="mt-8 text-[var(--color-muted)]">Lade…</p>
|
||||
<p class="mt-8 text-[var(--color-muted)]">{t('study_session.loading')}</p>
|
||||
{:else}
|
||||
<ul class="mt-6 space-y-2">
|
||||
{#each items as it (it.deck.id)}
|
||||
|
|
@ -52,11 +53,15 @@
|
|||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{#if it.deck.color}
|
||||
<span class="h-3 w-3 rounded-full" style="background:{it.deck.color}"></span>
|
||||
<span
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background:{it.deck.color}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
<span class="truncate font-medium">{it.deck.name}</span>
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
{it.due} fällig
|
||||
{t('study.due_count', { n: it.due })}
|
||||
</span>
|
||||
</div>
|
||||
{#if it.due > 0}
|
||||
|
|
@ -64,10 +69,10 @@
|
|||
href="/study/{it.deck.id}"
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-[var(--color-primary-fg)]"
|
||||
>
|
||||
Starten
|
||||
{t('study.study_now')}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm text-[var(--color-muted)]">—</span>
|
||||
<span class="text-sm text-[var(--color-muted)]" aria-label={t('study.none_due')}>—</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||
import { renderMarkdown } from '$lib/markdown.ts';
|
||||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
|
||||
const deckId = $derived(page.params.deckId ?? '');
|
||||
|
||||
|
|
@ -129,7 +130,7 @@
|
|||
queueIndex += 1;
|
||||
revealed = false;
|
||||
} catch (e) {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(e as Error).message}`);
|
||||
toasts.error(t('card_edit.save_failed', { msg: (e as Error).message }));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
|
|
@ -137,45 +138,51 @@
|
|||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<a href="/study" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">← Lernen</a>
|
||||
<a href="/study" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||
>{t('study_session.back')}</a
|
||||
>
|
||||
<h1 class="mt-2 text-xl font-semibold">{deckName}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]">
|
||||
<p class="mt-1 text-sm text-[var(--color-muted)]" aria-live="polite">
|
||||
{#if !loading && !isDone}
|
||||
{queueIndex + 1} / {queue.length}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="mt-12 text-center text-[var(--color-muted)]">Lade Sitzung…</p>
|
||||
<p class="mt-12 text-center text-[var(--color-muted)]">{t('study_session.loading')}</p>
|
||||
{:else if queue.length === 0}
|
||||
<div class="mt-12 rounded-lg border border-dashed border-[var(--color-border)] p-12 text-center">
|
||||
<p>Keine Karten fällig in diesem Deck. 🎉</p>
|
||||
<p>{t('study.none_due')} 🎉</p>
|
||||
<a href="/decks/{deckId}" class="mt-4 inline-block text-[var(--color-primary)] hover:underline">
|
||||
Zurück zum Deck →
|
||||
{t('card_edit.back')}
|
||||
</a>
|
||||
</div>
|
||||
{:else if isDone}
|
||||
<div class="mt-12 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-12 text-center">
|
||||
<h2 class="text-xl">Sitzung abgeschlossen</h2>
|
||||
<h2 class="text-xl">{t('study_session.all_done')}</h2>
|
||||
<p class="mt-2 text-[var(--color-muted)]">
|
||||
{stats.reviewed} Reviews erledigt · {stats.again}× nochmal
|
||||
{t('study_session.stats', { reviewed: stats.reviewed, again: stats.again })}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-center gap-3">
|
||||
<a href="/decks/{deckId}" class="rounded border border-[var(--color-border)] px-4 py-2 text-sm">
|
||||
Zurück zum Deck
|
||||
{t('card_edit.back')}
|
||||
</a>
|
||||
<a
|
||||
href="/study/{deckId}"
|
||||
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
|
||||
>
|
||||
Erneut prüfen
|
||||
{t('study.study_now')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<article
|
||||
class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-8"
|
||||
aria-labelledby="study-prompt-heading"
|
||||
>
|
||||
<h2 id="study-prompt-heading" class="sr-only">
|
||||
{revealed ? t('card_new.preview_label') : t('study_session.reveal')}
|
||||
</h2>
|
||||
<div class="prose prose-lg max-w-none">{@html promptHtml}</div>
|
||||
|
||||
{#if revealed}
|
||||
|
|
@ -190,17 +197,17 @@
|
|||
onclick={() => (revealed = true)}
|
||||
class="rounded bg-[var(--color-primary)] px-6 py-3 text-sm text-[var(--color-primary-fg)]"
|
||||
>
|
||||
Antwort zeigen <kbd class="ml-2 text-xs opacity-70">Space</kbd>
|
||||
{t('study_session.reveal')} <kbd class="ml-2 text-xs opacity-70">Space</kbd>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 grid grid-cols-4 gap-2">
|
||||
<div class="mt-6 grid grid-cols-4 gap-2" role="group" aria-label={t('study_session.grade_hint')}>
|
||||
<button
|
||||
onclick={() => grade('again')}
|
||||
disabled={busy}
|
||||
class="flex flex-col items-center gap-1 rounded border border-[var(--color-danger)] bg-[var(--color-card)] px-3 py-3 text-sm disabled:opacity-50"
|
||||
>
|
||||
<span>Nochmal</span>
|
||||
<span>{t('study_session.grade_again')}</span>
|
||||
<kbd class="text-xs text-[var(--color-muted)]">1</kbd>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -208,7 +215,7 @@
|
|||
disabled={busy}
|
||||
class="flex flex-col items-center gap-1 rounded border border-[var(--color-border)] bg-[var(--color-card)] px-3 py-3 text-sm disabled:opacity-50"
|
||||
>
|
||||
<span>Schwer</span>
|
||||
<span>{t('study_session.grade_hard')}</span>
|
||||
<kbd class="text-xs text-[var(--color-muted)]">2</kbd>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -216,7 +223,7 @@
|
|||
disabled={busy}
|
||||
class="flex flex-col items-center gap-1 rounded border border-[var(--color-primary)] bg-[var(--color-primary)] px-3 py-3 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
|
||||
>
|
||||
<span>Gut</span>
|
||||
<span>{t('study_session.grade_good')}</span>
|
||||
<kbd class="text-xs opacity-70">3</kbd>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -224,14 +231,14 @@
|
|||
disabled={busy}
|
||||
class="flex flex-col items-center gap-1 rounded border border-[var(--color-success)] bg-[var(--color-card)] px-3 py-3 text-sm disabled:opacity-50"
|
||||
>
|
||||
<span>Leicht</span>
|
||||
<span>{t('study_session.grade_easy')}</span>
|
||||
<kbd class="text-xs text-[var(--color-muted)]">4</kbd>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="mt-6 text-center text-xs text-[var(--color-muted)]">
|
||||
Hotkeys: <kbd>Space</kbd>/<kbd>Enter</kbd> = aufdecken & gut · <kbd>1</kbd>–<kbd>4</kbd> = bewerten
|
||||
{t('study_session.grade_hint')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue