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:
Till JS 2026-05-08 18:22:00 +02:00
parent a640594a24
commit c25c1d0dc4
17 changed files with 826 additions and 270 deletions

View file

@ -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}

View file

@ -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>

View file

@ -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
View 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
View 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)',
},
};

View 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 });
}