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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue