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>
80 lines
2.3 KiB
Svelte
80 lines
2.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import type { Deck } from '@cards/domain';
|
|
import { listDecks } from '$lib/api/decks.ts';
|
|
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 };
|
|
|
|
let items = $state<Item[]>([]);
|
|
let loading = $state(true);
|
|
|
|
onMount(async () => {
|
|
if (!devUser.id) {
|
|
goto('/');
|
|
return;
|
|
}
|
|
const r = await listDecks();
|
|
const counts = await Promise.all(
|
|
r.decks.map(async (d) => {
|
|
try {
|
|
const due = await listDueReviews({ deckId: d.id, limit: 1 });
|
|
// limit=1 gibt total zurück (alle fälligen, gecappt nur an results-Größe)
|
|
// für korrekte Counts müssen wir ohne Limit fragen — pragmatisch:
|
|
const all = await listDueReviews({ deckId: d.id, limit: 500 });
|
|
return { deck: d, due: all.total };
|
|
} catch {
|
|
return { deck: d, due: 0 };
|
|
}
|
|
})
|
|
);
|
|
items = counts;
|
|
loading = false;
|
|
});
|
|
</script>
|
|
|
|
<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)]">{t('study_session.loading')}</p>
|
|
{:else}
|
|
<ul class="mt-6 space-y-2">
|
|
{#each items as it (it.deck.id)}
|
|
<li
|
|
class="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3"
|
|
>
|
|
<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}"
|
|
aria-hidden="true"
|
|
></span>
|
|
{/if}
|
|
<span class="truncate font-medium">{it.deck.name}</span>
|
|
<span class="text-sm text-[var(--color-muted)]">
|
|
{t('study.due_count', { n: it.due })}
|
|
</span>
|
|
</div>
|
|
{#if it.due > 0}
|
|
<a
|
|
href="/study/{it.deck.id}"
|
|
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-[var(--color-primary-fg)]"
|
|
>
|
|
{t('study.study_now')}
|
|
</a>
|
|
{:else}
|
|
<span class="text-sm text-[var(--color-muted)]" aria-label={t('study.none_due')}>—</span>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|