feat(web): UI-Overhaul — Mobile-Nav, Sprachauswahl, 5 Sprachen, Stats-Karten

Mobile-Nav scrollt horizontal und ist auf der Login-Seite ausgeblendet.
Nav-Innere Container entfernt (PillTabGroup → flache Buttons). Sprachauswahl
von der Nav auf die Account-Page verschoben (eigene Karte mit Vollnamen,
vertikales Layout). 5 Locales: DE, EN, FR, IT, ES mit vollständigen
Übersetzungen. Account-Karte erlaubt Namensbearbeitung. Stats-Page komplett
auf Card-Aesthetic umgebaut (ChartBar, Fire, Brain, CalendarDots, Target,
CalendarCheck — keine Emojis). Zwei neue Stats-Karten: Retention-Rate
(lapses/reps) und Fälligkeitsvorschau (nächste 7 Tage). API um
retention_rate, retention_reps, retention_lapses, due_forecast erweitert.
84-Tage-Activity-Grid hinzugefügt. TS-Fehler aus Locale-Erweiterung behoben
(ClozeCardForm number[], decks/new + NewDeckCard Locale-Typ).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 14:20:01 +02:00
parent 578a0a41f7
commit 3a4523da3e
20 changed files with 1778 additions and 273 deletions

View file

@ -40,7 +40,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
const userId = c.get('userId');
const db = dbOf();
const now = new Date();
const thirtyAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const ninetyAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000);
const [deckCountRow] = await db
.select({ n: sql<number>`count(*)::int` })
@ -76,7 +76,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
};
for (const row of stateRows) stateCounts[row.state] = row.n;
// Reviews pro Tag — gruppiert auf UTC-Tag.
// Reviews pro Tag — 91-Tage-Fenster für Grid + Streak + 7-Tage-Chart.
const dayRows = await db
.select({
day: sql<string>`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`,
@ -84,7 +84,7 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
})
.from(reviews)
.where(
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, thirtyAgo))
and(eq(reviews.userId, userId), isNotNull(reviews.lastReview), gte(reviews.lastReview, ninetyAgo))
)
.groupBy(sql`to_char(${reviews.lastReview}, 'YYYY-MM-DD')`);
@ -96,15 +96,53 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
return { day: key, n: byDay.get(key) ?? 0 };
});
// Streak: rückwärts ab heute (UTC) bis zum ersten Tag ohne Reviews.
// 84 Tage (12 Wochen) für das Activity-Grid, ältester Tag zuerst.
const activity84 = Array.from({ length: 84 }, (_, i) => {
const d = new Date(Date.now() - (83 - i) * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
return { day: key, n: byDay.get(key) ?? 0 };
});
// Streak: rückwärts ab heute bis zum ersten Tag ohne Reviews.
let streak = 0;
for (let i = 0; i < 30; i++) {
for (let i = 0; i < 91; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
if ((byDay.get(key) ?? 0) > 0) streak++;
else break;
}
// Retention: Verhältnis (reps - lapses) / reps über alle Reviews.
const [retentionRow] = await db
.select({
totalReps: sql<number>`coalesce(sum(${reviews.reps}), 0)::int`,
totalLapses: sql<number>`coalesce(sum(${reviews.lapses}), 0)::int`,
})
.from(reviews)
.where(eq(reviews.userId, userId));
const totalReps = retentionRow?.totalReps ?? 0;
const totalLapses = retentionRow?.totalLapses ?? 0;
const retentionRate = totalReps > 0 ? (totalReps - totalLapses) / totalReps : null;
// Fälligkeitsvorschau: nächste 7 Tage (ab jetzt).
const sevenAhead = new Date(Date.now() + 8 * 24 * 60 * 60 * 1000);
const forecastRows = await db
.select({
day: sql<string>`to_char(${reviews.due}, 'YYYY-MM-DD')`,
n: sql<number>`count(*)::int`,
})
.from(reviews)
.where(and(eq(reviews.userId, userId), gte(reviews.due, now), lte(reviews.due, sevenAhead)))
.groupBy(sql`to_char(${reviews.due}, 'YYYY-MM-DD')`);
const forecastMap = new Map(forecastRows.map((r) => [r.day, r.n]));
const dueForecast = Array.from({ length: 7 }, (_, i) => {
const d = new Date(Date.now() + i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
return { day: key, n: forecastMap.get(key) ?? 0 };
});
return c.json({
user_id: userId,
generated_at: now.toISOString(),
@ -114,7 +152,12 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
due_now: dueCountRow?.n ?? 0,
state_counts: stateCounts,
reviewed_per_day: reviewed7,
activity_days: activity84,
streak_days: streak,
retention_rate: retentionRate,
retention_reps: totalReps,
retention_lapses: totalLapses,
due_forecast: dueForecast,
});
});

View file

@ -30,7 +30,7 @@ export function getMarketplaceSource(id: string) {
return api<{ slug: string } | null>(`/api/v1/decks/${id}/marketplace-source`);
}
export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; count?: number; url?: string }) {
export function generateDeck(input: { prompt: string; language?: string; count?: number; url?: string }) {
return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', {
method: 'POST',
body: input,
@ -51,7 +51,7 @@ export function fetchDistractors(
export function generateDeckFromImage(
files: File | File[],
opts: { language?: 'de' | 'en'; count?: number; url?: string },
opts: { language?: string; count?: number; url?: string },
) {
const arr = Array.isArray(files) ? files : [files];

View file

@ -38,7 +38,12 @@ export interface UserStats {
due_now: number;
state_counts: { new: number; learning: number; review: number; relearning: number };
reviewed_per_day: { day: string; n: number }[];
activity_days: { day: string; n: number }[];
streak_days: number;
retention_rate: number | null;
retention_reps: number;
retention_lapses: number;
due_forecast: { day: string; n: number }[];
}
export function loadStats() {

View file

@ -1,18 +1,13 @@
/**
* Auth-Session für cards-web (Phase 10c).
* Auth-Session für cards-web.
*
* Echte SSO gegen mana-auth: `accessToken` (EdDSA-JWT von
* `auth.mana.how`) lebt in localStorage, wird als
* `Authorization: Bearer <jwt>` an cards-api geschickt.
* Login-Flow: cards-web redirectet zu auth.mana.how (mana-auth-web),
* dort setzt mana-auth den SSO-Cookie, dann Redirect zurück zu
* /auth/callback, welcher via `tryRefresh()` + `loadUserFromToken()`
* den JWT holt und in localStorage speichert.
*
* Der Datei-Name `dev-stub.svelte.ts` ist Legacy aus Phase 4 alle
* Importer nutzen `devUser`-Symbol, wir behalten den Namen, damit der
* Sprint nicht fünfzig Imports umschreiben muss. Inhalt ist jetzt die
* echte Session.
*
* Token-Refresh: aktuell nicht implementiert wenn der Token abläuft
* (15 min Default in mana-auth), gibt cards-api 401 und der User wird
* auf `/` zurückgeworfen. Refresh-Token-Pfad ist Phase-10d-Polish.
* Der Datei-Name `dev-stub.svelte.ts` ist Legacy alle Importer
* nutzen `devUser`-Symbol, Umbenennen würde ~50 Imports erfordern.
*/
const TOKEN_KEY = 'cards.auth.accessToken';
@ -100,46 +95,18 @@ class Session {
}
/**
* Login gegen mana-auth zwei Calls in einem Flow:
*
* 1. Better-Auth-Native `POST /api/auth/sign-in/email` setzt das
* SSO-Cookie (__Secure-mana.session_token) auf Domain `.mana.how`
* und liefert das User-Profil. Dieser Endpoint erzeugt im
* Gegensatz zu `/api/v1/auth/login` ein gültiges Set-Cookie auf
* der Response.
* 2. `POST /api/v1/auth/refresh` mit Cookie-Auth liefert den
* EdDSA-JWT-accessToken aus der frischen Session.
*
* Damit funktioniert auch der spätere Background-Refresh (gleicher
* Endpoint) und User-Sessions überleben einen Tab-Close, wenn das
* localStorage geleert wurde aber das HttpOnly-Cookie noch lebt.
* User-Profil aus dem aktuell gespeicherten JWT-Token laden.
* Wird vom /auth/callback nach erfolgreichem tryRefresh() aufgerufen.
*/
async login(email: string, password: string): Promise<void> {
const signIn = await fetch(`${authBaseUrl()}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (!signIn.ok) {
const body = await signIn.text().catch(() => '');
throw new Error(`Login fehlgeschlagen (${signIn.status}): ${body.slice(0, 120)}`);
}
const signInData = (await signIn.json()) as {
user: { id: string; email: string; name?: string; accessTier?: string };
};
// JWT aus der Cookie-Session ziehen.
const refreshOk = await this.tryRefresh();
if (!refreshOk || !this.token) {
throw new Error('Auth-Server lieferte kein gültiges Token nach Login.');
}
loadUserFromToken(): void {
if (!this.token) return;
const claims = decodeJwt(this.token);
if (!claims) return;
this.user = {
id: signInData.user.id,
email: signInData.user.email,
name: signInData.user.name ?? null,
tier: signInData.user.accessTier ?? 'public',
id: claims.sub,
email: claims.email ?? '',
name: claims.name ?? null,
tier: claims.tier ?? 'public',
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(USER_KEY, JSON.stringify(this.user));
@ -205,6 +172,15 @@ class Session {
}
}
/** Aktualisiert den lokal gecachten Anzeigenamen. Schreibt in localStorage. */
patchProfile(patch: { name?: string }) {
if (!this.user) return;
if (patch.name !== undefined) this.user = { ...this.user, name: patch.name.trim() || null };
if (typeof window !== 'undefined') {
window.localStorage.setItem(USER_KEY, JSON.stringify(this.user));
}
}
clear() {
// Auch SSO-Cookie auf .mana.how aufräumen — best-effort, schlägt
// bei abgelaufener Session ohne Drama fehl.

View file

@ -8,7 +8,7 @@
}: {
text: string;
extra: string;
clusterIds: string[];
clusterIds: number[];
} = $props();
</script>

View file

@ -2,15 +2,9 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import { PillTabGroup } from '@mana/shared-ui-2';
import { t } from '$lib/i18n/index.svelte.ts';
const langOptions = [
{ id: 'de', label: 'DE', title: 'Deutsch' },
{ id: 'en', label: 'EN', title: 'English' },
];
const navOptions = $derived([
const navItems = $derived([
{ id: 'decks', label: t('nav.decks') },
{ id: 'explore', label: t('nav.explore') },
{ id: 'import', label: t('nav.import') },
@ -36,10 +30,6 @@
devUser.user?.email?.charAt(0).toUpperCase() ??
'?'
);
function navTo(id: string) {
goto('/' + id);
}
</script>
<div class="bottom-bar" role="navigation" aria-label={t('common.main_nav')}>
@ -49,17 +39,16 @@
<div class="divider" aria-hidden="true"></div>
<!-- Hauptnavigation -->
<PillTabGroup options={navOptions} value={activeNav} onChange={navTo} />
<div class="divider" aria-hidden="true"></div>
<!-- Sprache -->
<PillTabGroup
options={langOptions}
value={i18n.current}
onChange={(id: string) => i18n.set(id as 'de' | 'en')}
sectionLabel={t('common.language_switcher')}
/>
{#each navItems as item (item.id)}
<button
class="nav-item"
class:active={activeNav === item.id}
onclick={() => goto('/' + item.id)}
aria-current={activeNav === item.id ? 'page' : undefined}
>
{item.label}
</button>
{/each}
<!-- Account -->
{#if devUser.id}
@ -98,6 +87,22 @@
white-space: nowrap;
}
@media (max-width: 640px) {
.bottom-bar {
left: 0.75rem;
right: 0.75rem;
bottom: 0.75rem;
transform: none;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.bottom-bar::-webkit-scrollbar {
display: none;
}
}
.divider {
width: 1px;
height: 1.25rem;
@ -106,6 +111,37 @@
margin: 0 0.125rem;
}
.nav-item {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s ease, background-color 0.15s ease;
}
.nav-item:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface-hover));
}
.nav-item.active {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.nav-item:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.logo-badge {
display: inline-flex;
align-items: center;

View file

@ -4,7 +4,7 @@
import { createDeck, generateDeck, generateDeckFromImage } from '$lib/api/decks.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import { i18n, t, type Locale } from '$lib/i18n/index.svelte.ts';
import CardSurface from './CardSurface.svelte';
import DeckCategoryIcon from './DeckCategoryIcon.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
@ -17,7 +17,7 @@
let color = $state('#0088ff');
let category = $state<DeckCategoryId | undefined>(undefined);
let count = $state(15);
let language = $state<'de' | 'en'>(i18n.current);
let language = $state<Locale>(i18n.current);
let saving = $state(false);
let generating = $state(false);
let aiError = $state<string | null>(null);

View file

@ -211,6 +211,12 @@ export const de: TranslationNode = {
title: 'Account',
user_id_label: 'User-ID',
logout: 'Abmelden',
edit_profile: 'Profil bearbeiten',
name_label: 'Name',
name_placeholder: 'Dein Name',
save: 'Speichern',
cancel: 'Abbrechen',
profile_saved: 'Profil gespeichert',
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',
@ -247,6 +253,16 @@ export const de: TranslationNode = {
fsrs_learning: 'Lernend',
fsrs_review: 'Review',
fsrs_relearning: 'Relearning',
activity_title: 'Aktivitätsverlauf',
activity_desc: '{weeks} Wochen · je Zelle = 1 Tag',
retention_title: 'Lernqualität',
retention_desc: 'Anteil der Reviews ohne Ausrutscher.',
retention_reps: '{reps} Reviews',
retention_lapses: '{lapses} Ausrutscher',
retention_none: 'Noch keine Reviews.',
forecast_title: 'Nächste 7 Tage',
forecast_desc: 'Fällige Karten je Tag.',
forecast_empty: 'Nichts fällig.',
loading: 'Lade…',
error: 'Fehler: {msg}',
},
@ -256,6 +272,7 @@ export const de: TranslationNode = {
main_nav: 'Hauptnavigation',
notifications: 'Benachrichtigungen',
language_switcher: 'Sprache wechseln',
language_desc: 'Wähle die Sprache der App.',
},
image_occlusion: {
image_label: 'Bild auswählen',

View file

@ -208,6 +208,12 @@ export const en: TranslationNode = {
title: 'Account',
user_id_label: 'User ID',
logout: 'Sign out',
edit_profile: 'Edit profile',
name_label: 'Name',
name_placeholder: 'Your name',
save: 'Save',
cancel: 'Cancel',
profile_saved: 'Profile saved',
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',
@ -244,6 +250,16 @@ export const en: TranslationNode = {
fsrs_learning: 'Learning',
fsrs_review: 'Review',
fsrs_relearning: 'Relearning',
activity_title: 'Activity history',
activity_desc: '{weeks} weeks · each cell = 1 day',
retention_title: 'Retention',
retention_desc: 'Share of reviews without a lapse.',
retention_reps: '{reps} reviews',
retention_lapses: '{lapses} lapses',
retention_none: 'No reviews yet.',
forecast_title: 'Next 7 days',
forecast_desc: 'Cards due per day.',
forecast_empty: 'Nothing scheduled.',
loading: 'Loading…',
error: 'Error: {msg}',
},
@ -253,6 +269,7 @@ export const en: TranslationNode = {
main_nav: 'Main navigation',
notifications: 'Notifications',
language_switcher: 'Switch language',
language_desc: 'Choose the language of the app.',
},
image_occlusion: {
image_label: 'Choose image',

285
apps/web/src/lib/i18n/es.ts Normal file
View file

@ -0,0 +1,285 @@
import type { TranslationNode } from './de.ts';
export const es: TranslationNode = {
app: {
name: 'Cards',
title_suffix: 'Cards',
},
nav: {
decks: 'Mazos',
study: 'Estudio',
explore: 'Biblioteca',
import: 'Importar',
stats: 'Estadísticas',
login_dev: 'Iniciar sesión (dev)',
account: 'Cuenta',
},
landing: {
welcome: 'Tarjetas de memoria con repetición espaciada.',
intro:
'Cardecky es la app federada de flashcards de mana e.V. — planificador FSRS, tarjetas cloze, importación Anki.',
cta_login: 'Iniciar sesión (dev)',
dev_user_prompt: 'ID de usuario (dev):',
},
decks: {
title: 'Mazos',
new: 'Nuevo mazo',
empty: 'Ningún mazo todavía.',
empty_cta: 'Crear el primer mazo',
loading: 'Cargando…',
error: 'Error: {msg}',
card_count: '{n} tarjetas',
card_count_one: '1 tarjeta',
card_count_more: '{n} tarjetas más en el mazo',
card_count_more_one: '1 tarjeta más en el mazo',
due_count: '{n} pendientes',
delete_confirm:
'¿Eliminar el mazo "{name}"? Se perderán todas las tarjetas y los datos de repaso.',
deleted: 'Mazo "{name}" eliminado',
delete_failed: 'Error al eliminar: {msg}',
},
deck_detail: {
back: '← Volver a mazos',
study_button: 'Estudiar',
new_card: 'Nueva tarjeta',
empty: 'No hay tarjetas en este mazo.',
empty_cta: 'Crear la primera tarjeta →',
card_summary_due: '{cards} · {due} pendientes',
card_delete_aria: 'Eliminar tarjeta',
card_delete_label: 'Eliminar',
card_delete_confirm: '¿Eliminar la tarjeta? Los repasos se eliminarán junto con ella.',
fan_aria: 'Tarjetas desplegadas del mazo "{name}"',
card_open: 'Abrir tarjeta — {type}',
},
deck_stack: {
aria_label: 'Mazo "{name}" — {cards} tarjetas, {due} pendientes',
},
deck_edit: {
title: 'Editar mazo',
back: '← Volver al mazo',
name_label: 'Nombre',
description_label: 'Descripción (opcional)',
color_label: 'Color',
save: 'Guardar',
saving: 'Guardando…',
cancel: 'Cancelar',
save_failed: 'Error al guardar: {msg}',
saved: 'Mazo guardado',
},
deck_new: {
title: 'Nuevo mazo',
name_label: 'Nombre',
description_label: 'Descripción (opcional)',
color_label: 'Color (opcional)',
create: 'Crear mazo',
creating: 'Creando…',
cancel: 'Cancelar',
create_failed: 'Error al crear: {msg}',
},
card_new: {
title: 'Nueva tarjeta',
back: '← Atrás',
deck_label: 'Mazo',
type_label: 'Tipo',
type_basic: 'Básica (anverso → reverso)',
type_basic_reverse: 'Básica + Inversa (anverso ↔ reverso, 2 repasos)',
type_cloze: 'Cloze (completar espacios, 1 repaso por grupo)',
front_label: 'Anverso (Markdown)',
back_label: 'Reverso (Markdown)',
back_placeholder: 'Respuesta',
front_placeholder: '# Markdown está permitido\n**negrita**, _cursiva_, `código`',
preview_label: 'Vista previa',
cloze_text_label: 'Texto con espacios (Markdown)',
cloze_text_placeholder: 'La capital de {{c1::Francia}} es {{c2::París}}.',
cloze_help: '{{c1::Respuesta}} define un espacio. Cada ID de grupo (c1, c2, …) crea su propio repaso. Pista opcional: {{c1::Respuesta::Pista}} — la pista sustituye «…» en la pregunta.',
cloze_no_clusters: 'Se requiere al menos un grupo {{cN::…}}.',
cloze_clusters_detected: '{n} grupos detectados: c{ids} → {n} repasos.',
cloze_preview_label: 'Vista previa (c{first} enmascarado)',
cloze_extra_label: 'Extra (opcional)',
cloze_extra_placeholder: 'Contexto adicional, mostrado bajo la respuesta.',
create: 'Crear tarjeta',
creating: 'Guardando…',
cancel: 'Cancelar',
create_failed: 'Error al crear: {msg}',
toast_basic: 'Tarjeta creada',
toast_basic_reverse: '2 repasos inicializados (anverso→reverso, reverso→anverso)',
toast_cloze: '{n} repasos inicializados (1 por grupo)',
toast_image_occlusion: '{n} repasos inicializados (1 por máscara)',
type_image_occlusion: 'Oclusión de imagen (imagen + N máscaras)',
type_typing: 'Escritura (texto libre, coincidencia aproximada)',
type_multiple_choice: 'Opción múltiple (4 opciones, distractores IA)',
type_audio_front: 'Audio anverso (escuchar y responder)',
answer_label: 'Respuesta (Markdown)',
answer_placeholder: 'Respuesta correcta',
distractor_pool_label: 'Pool de distractores (opcional)',
distractor_pool_placeholder: 'Un elemento por línea — usado si el mazo es demasiado pequeño para distractores IA',
audio_ref_label: 'Referencia de audio (media_ref)',
audio_ref_placeholder: 'ej. abc123.mp3',
back_audio_label: 'Texto de respuesta (Markdown)',
toast_typing: 'Tarjeta de escritura creada',
toast_multiple_choice: 'Tarjeta de opción múltiple creada',
toast_audio_front: 'Tarjeta audio anverso creada',
decks_load_failed: 'No se pudieron cargar los mazos: {msg}',
},
card_edit: {
title: 'Editar tarjeta',
back: '← Volver al mazo',
type_locked_help: 'El tipo de tarjeta no se puede cambiar — la tabla de repasos depende de él.',
save: 'Guardar',
saving: 'Guardando…',
cancel: 'Cancelar',
delete: 'Eliminar',
deleting: 'Eliminando…',
delete_confirm: '¿Eliminar la tarjeta? Los repasos se eliminarán junto con ella.',
updated: 'Tarjeta actualizada',
save_failed: 'Error al guardar: {msg}',
delete_failed: 'Error al eliminar: {msg}',
deleted: 'Tarjeta eliminada',
},
study: {
title: 'Estudio',
empty: 'Ningún mazo.',
none_due: 'Nada pendiente por ahora.',
study_now: 'Estudiar ahora',
due_count: '{n} pendientes',
},
study_session: {
back: '← Resumen',
all_done: '¡Listo! Todas las tarjetas pendientes han sido repasadas.',
stats: 'Repasos: {reviewed} · De nuevo: {again}',
reveal: 'Mostrar respuesta',
reveal_hint: 'Espacio / Intro para revelar',
grade_again: 'De nuevo',
grade_hard: 'Difícil',
grade_good: 'Bien',
grade_easy: 'Fácil',
grade_hint: '1=De nuevo · 2=Difícil · 3=Bien · 4=Fácil',
loading: 'Cargando…',
error: 'Error: {msg}',
manage_link: 'Gestionar tarjetas →',
},
import: {
title: 'Importar',
intro: 'Importa mazos y tarjetas desde un archivo Anki (.apkg o .colpkg). El historial FSRS no se importa — todas las tarjetas empiezan como «nuevas».',
what_works_title: 'Qué se importa',
what_works_decks: 'Mazos (la jerarquía Anki Foo::Bar se convierte en Foo / Bar).',
what_works_basic: 'Básica + Básica-Inversa: anverso/reverso directamente.',
what_works_cloze: 'Cloze: {{c1::…}} creado con subíndice por grupo.',
what_works_media: 'Imágenes + audio (incrustados como Markdown / etiqueta <audio>).',
what_skipped_title: 'Qué no se importa',
what_skipped_media: '— (Imágenes + audio se importan desde el Sprint 9k, ver arriba)',
what_skipped_history: 'Historial de aprendizaje FSRS (los repasos de Anki se reinician deliberadamente).',
what_skipped_addons: 'Tipos de tarjetas específicos de complementos (oclusión de imagen, etc.).',
anki_label: 'Importar desde Anki',
dropzone: '📦 Suelta el archivo .apkg aquí o haz clic',
dropzone_hint: 'Básica, Básica + Inversa, Cloze · Imágenes + audio también se importan (límite 25 MB por archivo).',
parsing: 'Leyendo {file}…',
preview_found: 'Encontrado en',
preview_decks_one: '1 mazo',
preview_decks: '{n} mazos',
preview_cards_one: '1 tarjeta',
preview_cards: '{n} tarjetas',
preview_breakdown: '({basic} básica, {basic_reverse} básica-inversa, {cloze} cloze)',
preview_media: '{n} archivos multimedia se cargarán',
preview_skipped: '{n} omitido(s) (tipo desconocido)',
preview_warnings: 'Avisos ({n})',
cancel: 'Cancelar',
import_now: 'Importar',
stage_media: 'Cargando multimedia · {current} / {total}',
stage_decks: 'Creando mazos · {current} / {total}',
stage_cards: 'Importando tarjetas · {current} / {total}',
stage_done: 'Completado.',
done_summary_one: '✓ {cards} tarjetas en 1 mazo.',
done_summary: '✓ {cards} tarjetas en {decks} mazos.',
done_dupes: '{n} duplicado(s) omitido(s) (contenido ya existente).',
done_media: '{uploaded} multimedia cargados, {failed} fallidos.',
done_failures: '{n} errores',
done_more: 'Otro archivo',
error_label: 'Error: {msg}',
retry: 'Reintentar',
},
inbox_banner: {
label: '📥 Bandeja',
count_one: '1 tarjeta recibida de otras apps',
count: '{n} tarjetas recibidas de otras apps',
cta: '— ordenar →',
},
account: {
title: 'Cuenta',
user_id_label: 'ID de usuario',
logout: 'Cerrar sesión',
edit_profile: 'Editar perfil',
name_label: 'Nombre',
name_placeholder: 'Tu nombre',
save: 'Guardar',
cancel: 'Cancelar',
profile_saved: 'Perfil guardado',
phase2_hint:
'Nota fase 2: la identidad es actualmente un stub dev (sessionStorage). Con la federación auth, cambiará a un inicio de sesión mana-auth en auth.mana.how.',
export_title: 'Exportar datos',
export_intro:
'Descarga todos tus datos de Cards en JSON — mazos, tarjetas, repasos, sesiones de estudio, etiquetas, referencias multimedia, importaciones. RGPD Art. 15/20.',
export_button: 'Exportar datos',
export_loading: 'Cargando…',
export_done: 'Exportación descargada: {decks} mazos, {cards} tarjetas, {reviews} repasos.',
export_failed: 'Error al exportar: {msg}',
delete_title: 'Eliminar cuenta',
delete_intro:
'Elimina definitivamente todos tus datos de Cards. RGPD Art. 17. Las demás apps de mana (Memoro, Who, …) conservan sus datos de forma independiente — para eliminarlos, hazlo en cada app o mediante la solicitud RGPD colectiva en mana-admin.',
delete_button: 'Eliminar todos los datos de Cards',
delete_loading: 'Eliminando…',
delete_confirm:
'TODOS tus datos de Cards se eliminarán definitivamente. Escribe ELIMINAR para confirmar.',
delete_confirm_word: 'ELIMINAR',
delete_done: 'Eliminado: {decks} mazos, {imports} importaciones.',
delete_failed: 'Error al eliminar: {msg}',
},
stats: {
title: 'Estadísticas',
generated_at: 'A fecha de {date}',
decks: 'Mazos',
cards: 'Tarjetas',
reviews: 'Repasos',
due_now: 'Pendientes ahora',
days_title: 'Días de estudio',
streak: 'Racha: {n} días · últimos 7 días: {total} repasos',
streak_one: 'Racha: 1 día · últimos 7 días: {total} repasos',
fsrs_title: 'Estado FSRS',
fsrs_intro: 'Distribución de tus repasos entre los estados FSRS.',
fsrs_new: 'Nuevo',
fsrs_learning: 'Aprendiendo',
fsrs_review: 'Repaso',
fsrs_relearning: 'Reaprendiendo',
activity_title: 'Historial de actividad',
activity_desc: '{weeks} semanas · cada celda = 1 día',
retention_title: 'Retención',
retention_desc: 'Proporción de repasos sin recaída.',
retention_reps: '{reps} repasos',
retention_lapses: '{lapses} recaídas',
retention_none: 'Sin repasos aún.',
forecast_title: 'Próximos 7 días',
forecast_desc: 'Tarjetas pendientes por día.',
forecast_empty: 'Nada programado.',
loading: 'Cargando…',
error: 'Error: {msg}',
},
common: {
empty: '(vacío)',
skip_to_content: 'Ir al contenido',
main_nav: 'Navegación principal',
notifications: 'Notificaciones',
language_switcher: 'Cambiar idioma',
language_desc: 'Elige el idioma de la app.',
},
image_occlusion: {
image_label: 'Elegir imagen',
uploading: 'Subiendo imagen…',
not_an_image: 'El archivo no es una imagen.',
canvas_aria: 'Canvas de imagen — arrastra para crear máscaras',
draw_hint: 'Dibuja un rectángulo sobre la imagen para crear una máscara.',
label_placeholder: 'Etiqueta (opcional)',
delete_mask: 'Eliminar máscara',
no_image_selected: 'Elige primero una imagen.',
no_masks: 'Crea al menos una máscara.',
},
};

285
apps/web/src/lib/i18n/fr.ts Normal file
View file

@ -0,0 +1,285 @@
import type { TranslationNode } from './de.ts';
export const fr: TranslationNode = {
app: {
name: 'Cards',
title_suffix: 'Cards',
},
nav: {
decks: 'Collections',
study: 'Étude',
explore: 'Bibliothèque',
import: 'Importer',
stats: 'Stats',
login_dev: 'Connexion (dev)',
account: 'Compte',
},
landing: {
welcome: 'Fiches de révision espacée.',
intro:
"Cardecky est l'application de fiches fédérée de mana e.V. — planificateur FSRS, cartes à trous, import Anki.",
cta_login: 'Connexion (dev)',
dev_user_prompt: 'ID utilisateur (dev) :',
},
decks: {
title: 'Collections',
new: 'Nouvelle collection',
empty: 'Aucune collection.',
empty_cta: 'Créer la première collection',
loading: 'Chargement…',
error: 'Erreur : {msg}',
card_count: '{n} cartes',
card_count_one: '1 carte',
card_count_more: '{n} autres cartes dans la pile',
card_count_more_one: '1 autre carte dans la pile',
due_count: '{n} à réviser',
delete_confirm:
'Supprimer la collection « {name} » ? Toutes les cartes + données de révision seront perdues.',
deleted: 'Collection « {name} » supprimée',
delete_failed: 'Échec de la suppression : {msg}',
},
deck_detail: {
back: '← Retour aux collections',
study_button: 'Étudier',
new_card: 'Nouvelle carte',
empty: 'Aucune carte dans cette collection.',
empty_cta: 'Créer la première carte →',
card_summary_due: '{cards} · {due} à réviser',
card_delete_aria: 'Supprimer la carte',
card_delete_label: 'Supprimer',
card_delete_confirm: 'Supprimer la carte ? Les révisions seront supprimées avec elle.',
fan_aria: 'Cartes étalées de la pile « {name} »',
card_open: 'Ouvrir la carte — {type}',
},
deck_stack: {
aria_label: 'Pile « {name} » — {cards} cartes, {due} à réviser',
},
deck_edit: {
title: 'Modifier la collection',
back: '← Retour à la collection',
name_label: 'Nom',
description_label: 'Description (optionnel)',
color_label: 'Couleur',
save: 'Enregistrer',
saving: 'Enregistrement…',
cancel: 'Annuler',
save_failed: "Échec de l'enregistrement : {msg}",
saved: 'Collection enregistrée',
},
deck_new: {
title: 'Nouvelle collection',
name_label: 'Nom',
description_label: 'Description (optionnel)',
color_label: 'Couleur (optionnel)',
create: 'Créer la collection',
creating: 'Création…',
cancel: 'Annuler',
create_failed: 'Échec de la création : {msg}',
},
card_new: {
title: 'Nouvelle carte',
back: '← Retour',
deck_label: 'Collection',
type_label: 'Type',
type_basic: 'Basique (recto → verso)',
type_basic_reverse: 'Basique + Inverse (recto ↔ verso, 2 révisions)',
type_cloze: 'Texte à trous (1 révision par groupe)',
front_label: 'Recto (Markdown)',
back_label: 'Verso (Markdown)',
back_placeholder: 'Réponse',
front_placeholder: '# Markdown autorisé\n**gras**, _italique_, `code`',
preview_label: 'Aperçu',
cloze_text_label: 'Texte avec trous (Markdown)',
cloze_text_placeholder: 'La capitale de {{c1::France}} est {{c2::Paris}}.',
cloze_help: "{{c1::Réponse}} définit un trou. Chaque ID de groupe (c1, c2, …) crée sa propre révision. Indice optionnel : {{c1::Réponse::Indice}} — l'indice remplace « … » dans la question.",
cloze_no_clusters: 'Au moins un groupe {{cN::…}} est requis.',
cloze_clusters_detected: '{n} groupes détectés : c{ids} → {n} révisions.',
cloze_preview_label: 'Aperçu (c{first} masqué)',
cloze_extra_label: 'Extra (optionnel)',
cloze_extra_placeholder: 'Contexte supplémentaire, affiché sous la réponse.',
create: 'Créer la carte',
creating: 'Enregistrement…',
cancel: 'Annuler',
create_failed: 'Échec de la création : {msg}',
toast_basic: 'Carte créée',
toast_basic_reverse: '2 révisions initialisées (recto→verso, verso→recto)',
toast_cloze: '{n} révisions initialisées (1 par groupe)',
toast_image_occlusion: '{n} révisions initialisées (1 par masque)',
type_image_occlusion: "Occlusion d'image (image + N masques)",
type_typing: 'Saisie (texte libre, correspondance approximative)',
type_multiple_choice: 'Choix multiple (4 options, distracteurs IA)',
type_audio_front: 'Audio recto (écouter + répondre)',
answer_label: 'Réponse (Markdown)',
answer_placeholder: 'Bonne réponse',
distractor_pool_label: 'Pool de distracteurs (optionnel)',
distractor_pool_placeholder: 'Un élément par ligne — utilisé si la collection est trop petite pour les distracteurs IA',
audio_ref_label: 'Référence audio (media_ref)',
audio_ref_placeholder: 'ex. abc123.mp3',
back_audio_label: 'Texte de réponse (Markdown)',
toast_typing: 'Carte de saisie créée',
toast_multiple_choice: 'Carte à choix multiple créée',
toast_audio_front: 'Carte audio recto créée',
decks_load_failed: 'Impossible de charger les collections : {msg}',
},
card_edit: {
title: 'Modifier la carte',
back: '← Retour à la collection',
type_locked_help: 'Le type de carte ne peut pas être modifié — le tableau des révisions en dépend.',
save: 'Enregistrer',
saving: 'Enregistrement…',
cancel: 'Annuler',
delete: 'Supprimer',
deleting: 'Suppression…',
delete_confirm: 'Supprimer la carte ? Les révisions seront supprimées avec elle.',
updated: 'Carte mise à jour',
save_failed: "Échec de l'enregistrement : {msg}",
delete_failed: 'Échec de la suppression : {msg}',
deleted: 'Carte supprimée',
},
study: {
title: 'Étude',
empty: 'Aucune collection.',
none_due: "Rien à réviser pour l'instant.",
study_now: 'Étudier maintenant',
due_count: '{n} à réviser',
},
study_session: {
back: '← Aperçu',
all_done: 'Bravo ! Toutes les cartes dues ont été révisées.',
stats: 'Révisions : {reviewed} · À revoir : {again}',
reveal: 'Afficher la réponse',
reveal_hint: 'Espace / Entrée pour révéler',
grade_again: 'À revoir',
grade_hard: 'Difficile',
grade_good: 'Bien',
grade_easy: 'Facile',
grade_hint: '1=À revoir · 2=Difficile · 3=Bien · 4=Facile',
loading: 'Chargement…',
error: 'Erreur : {msg}',
manage_link: 'Gérer les cartes →',
},
import: {
title: 'Importer',
intro: "Importez des collections et des cartes depuis un fichier Anki (.apkg ou .colpkg). L'historique FSRS n'est pas importé — toutes les cartes démarrent comme « nouvelles ».",
what_works_title: 'Ce qui est importé',
what_works_decks: 'Collections (la hiérarchie Anki Foo::Bar devient Foo / Bar).',
what_works_basic: 'Basique + Basique-Inverse : recto/verso directement.',
what_works_cloze: 'Texte à trous : {{c1::…}} créé avec sous-index par groupe.',
what_works_media: 'Images + audio (intégrés en Markdown / balise <audio>).',
what_skipped_title: "Ce qui n'est pas importé",
what_skipped_media: '— (Images + audio sont importés depuis le Sprint 9k, voir ci-dessus)',
what_skipped_history: "Historique d'apprentissage FSRS (les révisions Anki sont délibérément réinitialisées).",
what_skipped_addons: "Types de cartes spécifiques aux extensions (occlusion d'image, etc.).",
anki_label: 'Importer depuis Anki',
dropzone: '📦 Déposez le fichier .apkg ici ou cliquez',
dropzone_hint: 'Basique, Basique + Inverse, Texte à trous · Images + audio également importés (limite 25 Mo par fichier).',
parsing: 'Lecture de {file}…',
preview_found: 'Trouvé dans',
preview_decks_one: '1 collection',
preview_decks: '{n} collections',
preview_cards_one: '1 carte',
preview_cards: '{n} cartes',
preview_breakdown: '({basic} basique, {basic_reverse} basique-inverse, {cloze} texte à trous)',
preview_media: '{n} fichiers médias seront importés',
preview_skipped: '{n} ignoré(s) (type inconnu)',
preview_warnings: 'Avertissements ({n})',
cancel: 'Annuler',
import_now: 'Importer',
stage_media: 'Envoi des médias · {current} / {total}',
stage_decks: 'Création des collections · {current} / {total}',
stage_cards: 'Import des cartes · {current} / {total}',
stage_done: 'Terminé.',
done_summary_one: '✓ {cards} cartes dans 1 collection.',
done_summary: '✓ {cards} cartes dans {decks} collections.',
done_dupes: '{n} doublon(s) ignoré(s) (contenu déjà présent).',
done_media: '{uploaded} médias importés, {failed} échoués.',
done_failures: '{n} erreurs',
done_more: 'Autre fichier',
error_label: 'Erreur : {msg}',
retry: 'Réessayer',
},
inbox_banner: {
label: '📥 Boîte de réception',
count_one: "1 carte reçue d'autres applications",
count: "{n} cartes reçues d'autres applications",
cta: '— trier →',
},
account: {
title: 'Compte',
user_id_label: 'ID utilisateur',
logout: 'Se déconnecter',
edit_profile: 'Modifier le profil',
name_label: 'Nom',
name_placeholder: 'Votre nom',
save: 'Enregistrer',
cancel: 'Annuler',
profile_saved: 'Profil enregistré',
phase2_hint:
"Note phase 2 : l'identité est actuellement un stub dev (sessionStorage). Avec la fédération auth, cela bascule vers une connexion mana-auth sur auth.mana.how.",
export_title: 'Export des données',
export_intro:
'Téléchargez toutes vos données Cards en JSON — collections, cartes, révisions, sessions, tags, références médias, imports. RGPD Art. 15/20.',
export_button: 'Exporter les données',
export_loading: 'Chargement…',
export_done: 'Export téléchargé : {decks} collections, {cards} cartes, {reviews} révisions.',
export_failed: 'Export échoué : {msg}',
delete_title: 'Supprimer le compte',
delete_intro:
"Supprime définitivement toutes vos données Cards. RGPD Art. 17. Les autres apps mana (Memoro, Who, …) conservent leurs données indépendamment — pour les supprimer, faites-le dans chaque app ou via la demande RGPD collective sur mana-admin.",
delete_button: 'Supprimer toutes les données Cards',
delete_loading: 'Suppression…',
delete_confirm:
'TOUTES vos données Cards seront supprimées définitivement. Tapez SUPPRIMER pour confirmer.',
delete_confirm_word: 'SUPPRIMER',
delete_done: 'Supprimé : {decks} collections, {imports} imports.',
delete_failed: 'Échec de la suppression : {msg}',
},
stats: {
title: 'Statistiques',
generated_at: 'Au {date}',
decks: 'Collections',
cards: 'Cartes',
reviews: 'Révisions',
due_now: 'À réviser',
days_title: "Jours d'étude",
streak: 'Série : {n} jours · 7 derniers jours : {total} révisions',
streak_one: 'Série : 1 jour · 7 derniers jours : {total} révisions',
fsrs_title: 'État FSRS',
fsrs_intro: 'Répartition de vos révisions selon les états FSRS.',
fsrs_new: 'Nouveau',
fsrs_learning: 'Apprentissage',
fsrs_review: 'Révision',
fsrs_relearning: 'Réapprentissage',
activity_title: "Historique d'activité",
activity_desc: '{weeks} semaines · chaque cellule = 1 jour',
retention_title: 'Rétention',
retention_desc: 'Part des révisions sans rechute.',
retention_reps: '{reps} révisions',
retention_lapses: '{lapses} rechutes',
retention_none: 'Aucune révision encore.',
forecast_title: '7 prochains jours',
forecast_desc: 'Cartes à réviser par jour.',
forecast_empty: 'Rien de prévu.',
loading: 'Chargement…',
error: 'Erreur : {msg}',
},
common: {
empty: '(vide)',
skip_to_content: 'Aller au contenu',
main_nav: 'Navigation principale',
notifications: 'Notifications',
language_switcher: 'Changer de langue',
language_desc: "Choisissez la langue de l'application.",
},
image_occlusion: {
image_label: 'Choisir une image',
uploading: "Envoi de l'image…",
not_an_image: "Le fichier n'est pas une image.",
canvas_aria: 'Canvas image — faites glisser pour créer des masques',
draw_hint: 'Tracez un rectangle sur l\'image pour créer un masque.',
label_placeholder: 'Étiquette (optionnel)',
delete_mask: 'Supprimer le masque',
no_image_selected: "Choisissez d'abord une image.",
no_masks: 'Créez au moins un masque.',
},
};

View file

@ -1,27 +1,36 @@
/**
* Schmaler i18n-Core. Eigenbau statt svelte-i18n, weil:
* - 2 Sprachen, ~150 Strings keine Compile-Time-Type-Safety nötig
* - 5 Sprachen, ~270 Strings keine Compile-Time-Type-Safety nötig
* - keine Pluralregeln (`_one` als manueller Fallback reicht)
* - keine Server-Side-Lade-Komplexität (alles eager, ~3kB)
* - keine Server-Side-Lade-Komplexität (alles eager, ~15kB)
*
* Locale wird in localStorage persistiert, Default = navigator.language
* (DE/EN), Fallback = de.
* (DE/EN/FR/IT/ES), Fallback = de.
*/
import { de, type TranslationNode } from './de.ts';
import { en } from './en.ts';
import { fr } from './fr.ts';
import { it } from './it.ts';
import { es } from './es.ts';
export type Locale = 'de' | 'en';
export type Locale = 'de' | 'en' | 'fr' | 'it' | 'es';
const TRANSLATIONS: Record<Locale, TranslationNode> = { de, en };
const LOCALES: Locale[] = ['de', 'en', 'fr', 'it', 'es'];
const TRANSLATIONS: Record<Locale, TranslationNode> = { de, en, fr, it, es };
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';
const stored = window.localStorage.getItem(STORAGE_KEY) as Locale | null;
if (stored && LOCALES.includes(stored)) return stored;
const nav = (window.navigator?.language ?? '').toLowerCase();
if (nav.startsWith('en')) return 'en';
if (nav.startsWith('fr')) return 'fr';
if (nav.startsWith('it')) return 'it';
if (nav.startsWith('es')) return 'es';
return 'de';
}
class I18nState {
@ -34,8 +43,9 @@ class I18nState {
}
}
set(locale: Locale) {
this.current = locale;
set(locale: Locale | string) {
if (!LOCALES.includes(locale as Locale)) return;
this.current = locale as Locale;
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, locale);
document.documentElement.setAttribute('lang', locale);

285
apps/web/src/lib/i18n/it.ts Normal file
View file

@ -0,0 +1,285 @@
import type { TranslationNode } from './de.ts';
export const it: TranslationNode = {
app: {
name: 'Cards',
title_suffix: 'Cards',
},
nav: {
decks: 'Mazzi',
study: 'Studio',
explore: 'Libreria',
import: 'Importa',
stats: 'Statistiche',
login_dev: 'Accesso (dev)',
account: 'Account',
},
landing: {
welcome: 'Flashcard con ripetizione spaziata.',
intro:
"Cardecky è l'app di flashcard federata di mana e.V. — pianificatore FSRS, carte cloze, importazione Anki.",
cta_login: 'Accesso (dev)',
dev_user_prompt: 'ID utente (dev):',
},
decks: {
title: 'Mazzi',
new: 'Nuovo mazzo',
empty: 'Nessun mazzo.',
empty_cta: 'Crea il primo mazzo',
loading: 'Caricamento…',
error: 'Errore: {msg}',
card_count: '{n} carte',
card_count_one: '1 carta',
card_count_more: '{n} altre carte nel mazzo',
card_count_more_one: '1 altra carta nel mazzo',
due_count: '{n} da ripassare',
delete_confirm:
'Eliminare il mazzo "{name}"? Tutte le carte e i dati di ripasso andranno persi.',
deleted: 'Mazzo "{name}" eliminato',
delete_failed: 'Eliminazione non riuscita: {msg}',
},
deck_detail: {
back: '← Torna ai mazzi',
study_button: 'Studia',
new_card: 'Nuova carta',
empty: 'Nessuna carta in questo mazzo.',
empty_cta: 'Crea la prima carta →',
card_summary_due: '{cards} · {due} da ripassare',
card_delete_aria: 'Elimina carta',
card_delete_label: 'Elimina',
card_delete_confirm: 'Eliminare la carta? I ripassi verranno eliminati insieme ad essa.',
fan_aria: 'Carte aperte a ventaglio dal mazzo "{name}"',
card_open: 'Apri carta — {type}',
},
deck_stack: {
aria_label: 'Mazzo "{name}" — {cards} carte, {due} da ripassare',
},
deck_edit: {
title: 'Modifica mazzo',
back: '← Torna al mazzo',
name_label: 'Nome',
description_label: 'Descrizione (opzionale)',
color_label: 'Colore',
save: 'Salva',
saving: 'Salvataggio…',
cancel: 'Annulla',
save_failed: 'Salvataggio non riuscito: {msg}',
saved: 'Mazzo salvato',
},
deck_new: {
title: 'Nuovo mazzo',
name_label: 'Nome',
description_label: 'Descrizione (opzionale)',
color_label: 'Colore (opzionale)',
create: 'Crea mazzo',
creating: 'Creazione…',
cancel: 'Annulla',
create_failed: 'Creazione non riuscita: {msg}',
},
card_new: {
title: 'Nuova carta',
back: '← Indietro',
deck_label: 'Mazzo',
type_label: 'Tipo',
type_basic: 'Base (fronte → retro)',
type_basic_reverse: 'Base + Inverso (fronte ↔ retro, 2 ripassi)',
type_cloze: 'Cloze (completamento, 1 ripasso per gruppo)',
front_label: 'Fronte (Markdown)',
back_label: 'Retro (Markdown)',
back_placeholder: 'Risposta',
front_placeholder: '# Markdown è supportato\n**grassetto**, _corsivo_, `codice`',
preview_label: 'Anteprima',
cloze_text_label: 'Testo con spazi (Markdown)',
cloze_text_placeholder: 'La capitale di {{c1::Francia}} è {{c2::Parigi}}.',
cloze_help: "{{c1::Risposta}} definisce uno spazio. Ogni ID gruppo (c1, c2, …) crea il proprio ripasso. Suggerimento opzionale: {{c1::Risposta::Suggerimento}} — il suggerimento sostituisce «…» nella domanda.",
cloze_no_clusters: 'È richiesto almeno un gruppo {{cN::…}}.',
cloze_clusters_detected: '{n} gruppi rilevati: c{ids} → {n} ripassi.',
cloze_preview_label: 'Anteprima (c{first} mascherato)',
cloze_extra_label: 'Extra (opzionale)',
cloze_extra_placeholder: 'Contesto aggiuntivo, mostrato sotto la risposta.',
create: 'Crea carta',
creating: 'Salvataggio…',
cancel: 'Annulla',
create_failed: 'Creazione non riuscita: {msg}',
toast_basic: 'Carta creata',
toast_basic_reverse: '2 ripassi inizializzati (fronte→retro, retro→fronte)',
toast_cloze: '{n} ripassi inizializzati (1 per gruppo)',
toast_image_occlusion: '{n} ripassi inizializzati (1 per maschera)',
type_image_occlusion: "Occlusione immagine (immagine + N maschere)",
type_typing: 'Digitazione (testo libero, corrispondenza approssimativa)',
type_multiple_choice: 'Scelta multipla (4 opzioni, distrattori IA)',
type_audio_front: 'Audio fronte (ascolta e rispondi)',
answer_label: 'Risposta (Markdown)',
answer_placeholder: 'Risposta corretta',
distractor_pool_label: 'Pool distrattori (opzionale)',
distractor_pool_placeholder: 'Un elemento per riga — usato se il mazzo è troppo piccolo per i distrattori IA',
audio_ref_label: 'Riferimento audio (media_ref)',
audio_ref_placeholder: 'es. abc123.mp3',
back_audio_label: 'Testo risposta (Markdown)',
toast_typing: 'Carta di digitazione creata',
toast_multiple_choice: 'Carta a scelta multipla creata',
toast_audio_front: 'Carta audio fronte creata',
decks_load_failed: 'Impossibile caricare i mazzi: {msg}',
},
card_edit: {
title: 'Modifica carta',
back: '← Torna al mazzo',
type_locked_help: 'Il tipo di carta non può essere modificato — la tabella dei ripassi dipende da esso.',
save: 'Salva',
saving: 'Salvataggio…',
cancel: 'Annulla',
delete: 'Elimina',
deleting: 'Eliminazione…',
delete_confirm: 'Eliminare la carta? I ripassi verranno eliminati insieme ad essa.',
updated: 'Carta aggiornata',
save_failed: 'Salvataggio non riuscito: {msg}',
delete_failed: 'Eliminazione non riuscita: {msg}',
deleted: 'Carta eliminata',
},
study: {
title: 'Studio',
empty: 'Nessun mazzo.',
none_due: 'Niente da ripassare al momento.',
study_now: 'Studia ora',
due_count: '{n} da ripassare',
},
study_session: {
back: '← Panoramica',
all_done: 'Ottimo! Tutte le carte in scadenza sono state ripassate.',
stats: 'Ripassi: {reviewed} · Da rivedere: {again}',
reveal: 'Mostra risposta',
reveal_hint: 'Spazio / Invio per rivelare',
grade_again: 'Di nuovo',
grade_hard: 'Difficile',
grade_good: 'Bene',
grade_easy: 'Facile',
grade_hint: '1=Di nuovo · 2=Difficile · 3=Bene · 4=Facile',
loading: 'Caricamento…',
error: 'Errore: {msg}',
manage_link: 'Gestisci carte →',
},
import: {
title: 'Importa',
intro: "Importa mazzi e carte da un file Anki (.apkg o .colpkg). La cronologia FSRS non viene importata — tutte le carte partono come «nuove».",
what_works_title: 'Cosa viene importato',
what_works_decks: 'Mazzi (la gerarchia Anki Foo::Bar diventa Foo / Bar).',
what_works_basic: 'Base + Base-Inverso: fronte/retro direttamente.',
what_works_cloze: 'Cloze: {{c1::…}} creato con sotto-indice per gruppo.',
what_works_media: 'Immagini + audio (incorporati come Markdown / tag <audio>).',
what_skipped_title: 'Cosa non viene importato',
what_skipped_media: '— (Immagini + audio vengono importati dallo Sprint 9k, vedi sopra)',
what_skipped_history: 'Cronologia di apprendimento FSRS (i ripassi Anki vengono deliberatamente azzerati).',
what_skipped_addons: 'Tipi di carte specifici dei componenti aggiuntivi (occlusione immagine, ecc.).',
anki_label: 'Importa da Anki',
dropzone: '📦 Trascina il file .apkg qui o fai clic',
dropzone_hint: 'Base, Base + Inverso, Cloze · Immagini + audio vengono importati (limite 25 MB per file).',
parsing: 'Lettura di {file}…',
preview_found: 'Trovato in',
preview_decks_one: '1 mazzo',
preview_decks: '{n} mazzi',
preview_cards_one: '1 carta',
preview_cards: '{n} carte',
preview_breakdown: '({basic} base, {basic_reverse} base-inverso, {cloze} cloze)',
preview_media: '{n} file multimediali verranno caricati',
preview_skipped: '{n} ignorato/i (tipo sconosciuto)',
preview_warnings: 'Avvisi ({n})',
cancel: 'Annulla',
import_now: 'Importa',
stage_media: 'Caricamento media · {current} / {total}',
stage_decks: 'Creazione mazzi · {current} / {total}',
stage_cards: 'Importazione carte · {current} / {total}',
stage_done: 'Completato.',
done_summary_one: '✓ {cards} carte in 1 mazzo.',
done_summary: '✓ {cards} carte in {decks} mazzi.',
done_dupes: '{n} duplicato/i ignorato/i (contenuto già presente).',
done_media: '{uploaded} media caricati, {failed} non riusciti.',
done_failures: '{n} errori',
done_more: 'Un altro file',
error_label: 'Errore: {msg}',
retry: 'Riprova',
},
inbox_banner: {
label: '📥 In arrivo',
count_one: "1 carta ricevuta da altre app",
count: "{n} carte ricevute da altre app",
cta: '— ordina →',
},
account: {
title: 'Account',
user_id_label: 'ID utente',
logout: 'Disconnetti',
edit_profile: 'Modifica profilo',
name_label: 'Nome',
name_placeholder: 'Il tuo nome',
save: 'Salva',
cancel: 'Annulla',
profile_saved: 'Profilo salvato',
phase2_hint:
"Note fase 2: l'identità è attualmente uno stub dev (sessionStorage). Con la federazione auth, passerà a un login mana-auth su auth.mana.how.",
export_title: 'Esporta dati',
export_intro:
'Scarica tutti i tuoi dati Cards in JSON — mazzi, carte, ripassi, sessioni di studio, tag, riferimenti media, import. GDPR Art. 15/20.',
export_button: 'Esporta dati',
export_loading: 'Caricamento…',
export_done: 'Esportazione scaricata: {decks} mazzi, {cards} carte, {reviews} ripassi.',
export_failed: 'Esportazione non riuscita: {msg}',
delete_title: 'Elimina account',
delete_intro:
"Elimina definitivamente tutti i tuoi dati Cards. GDPR Art. 17. Le altre app mana (Memoro, Who, …) conservano i propri dati indipendentemente — per eliminarli, fallo in ogni app o tramite la richiesta GDPR collettiva su mana-admin.",
delete_button: 'Elimina tutti i dati Cards',
delete_loading: 'Eliminazione…',
delete_confirm:
'TUTTI i tuoi dati Cards verranno eliminati definitivamente. Digita ELIMINA per confermare.',
delete_confirm_word: 'ELIMINA',
delete_done: 'Eliminato: {decks} mazzi, {imports} import.',
delete_failed: 'Eliminazione non riuscita: {msg}',
},
stats: {
title: 'Statistiche',
generated_at: 'Aggiornato al {date}',
decks: 'Mazzi',
cards: 'Carte',
reviews: 'Ripassi',
due_now: 'Da ripassare ora',
days_title: 'Giorni di studio',
streak: 'Serie: {n} giorni · ultimi 7 giorni: {total} ripassi',
streak_one: 'Serie: 1 giorno · ultimi 7 giorni: {total} ripassi',
fsrs_title: 'Stato FSRS',
fsrs_intro: 'Distribuzione dei tuoi ripassi tra gli stati FSRS.',
fsrs_new: 'Nuovo',
fsrs_learning: 'In apprendimento',
fsrs_review: 'Ripasso',
fsrs_relearning: 'Riapprendimento',
activity_title: 'Cronologia attività',
activity_desc: '{weeks} settimane · ogni cella = 1 giorno',
retention_title: 'Ritenzione',
retention_desc: 'Quota di ripassi senza ricadute.',
retention_reps: '{reps} ripassi',
retention_lapses: '{lapses} ricadute',
retention_none: 'Nessun ripasso ancora.',
forecast_title: 'Prossimi 7 giorni',
forecast_desc: 'Carte da ripassare per giorno.',
forecast_empty: 'Niente in programma.',
loading: 'Caricamento…',
error: 'Errore: {msg}',
},
common: {
empty: '(vuoto)',
skip_to_content: 'Vai al contenuto',
main_nav: 'Navigazione principale',
notifications: 'Notifiche',
language_switcher: 'Cambia lingua',
language_desc: "Scegli la lingua dell'app.",
},
image_occlusion: {
image_label: 'Scegli immagine',
uploading: "Caricamento immagine…",
not_an_image: "Il file non è un'immagine.",
canvas_aria: 'Canvas immagine — trascina per creare maschere',
draw_hint: "Disegna un rettangolo sull'immagine per creare una maschera.",
label_placeholder: 'Etichetta (opzionale)',
delete_mask: 'Elimina maschera',
no_image_selected: "Scegli prima un'immagine.",
no_masks: 'Crea almeno una maschera.',
},
};

View file

@ -22,11 +22,13 @@
const path = page.url.pathname;
return /^\/study\/[^/]+\/?$/.test(path);
});
const isLoginPage = $derived(page.url.pathname === '/');
</script>
<a href="#main" class="skip-link">{t('common.skip_to_content')}</a>
{#if !isFocusMode}
{#if !isFocusMode && !isLoginPage}
<Header />
{/if}

View file

@ -1,84 +1,36 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { env as publicEnv } from '$env/dynamic/public';
let email = $state('');
let password = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
function authWebUrl(): string {
return publicEnv.PUBLIC_AUTH_WEB_URL ?? 'https://auth.mana.how';
}
function callbackUrl(): string {
const base =
typeof window !== 'undefined' ? window.location.origin : 'https://cardecky.mana.how';
const next = page.url.searchParams.get('next');
const nextParam = next ? `?next=${encodeURIComponent(next)}` : '';
return `${base}/auth/callback${nextParam}`;
}
onMount(() => {
if (devUser.id) goto('/decks');
});
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (busy) return;
busy = true;
error = null;
try {
await devUser.login(email.trim(), password);
if (devUser.id) {
goto('/decks');
} catch (err) {
error = apiErrorMessage(err);
} finally {
busy = false;
}
return;
}
// Redirect zum zentralen Auth-Portal (mana-auth-web).
const loginUrl = new URL(`${authWebUrl()}/login`);
loginUrl.searchParams.set('app', 'cards');
loginUrl.searchParams.set('redirect', callbackUrl());
window.location.href = loginUrl.toString();
});
</script>
<div class="mx-auto max-w-2xl py-12 text-center">
<h1 class="text-3xl font-semibold">{t('app.name')}</h1>
<p class="mt-2 text-[hsl(var(--color-muted-foreground))]">{t('landing.welcome')}</p>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">{t('landing.intro')}</p>
{#if !devUser.id}
<form
class="mt-8 rounded-lg border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] p-6 text-left space-y-3"
onsubmit={onSubmit}
>
<h2 class="text-lg font-medium">Anmelden</h2>
<label class="block text-sm">
<span class="font-medium">E-Mail</span>
<input
type="email"
bind:value={email}
required
autocomplete="email"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-background))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
<label class="block text-sm">
<span class="font-medium">Passwort</span>
<input
type="password"
bind:value={password}
required
autocomplete="current-password"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-background))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
{#if error}
<p class="text-sm text-[hsl(var(--color-error))]" role="alert">{error}</p>
{/if}
<button
type="submit"
disabled={busy || !email || !password}
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{busy ? '…' : 'Login'}
</button>
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
Mana-Account auf <a
href="https://auth.mana.how"
target="_blank"
rel="noopener"
class="underline">auth.mana.how</a
>. Cross-App-SSO über *.mana.how.
</p>
</form>
{/if}
<!-- Kurzes Laden während onMount den Redirect startet -->
<div class="flex min-h-screen items-center justify-center">
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Wird weitergeleitet…</p>
</div>

View file

@ -4,13 +4,16 @@
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { exportMe, deleteMe } from '$lib/api/me.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import { stackLayers } from '$lib/utils/deck-tilt';
import CardSurface from '$lib/components/CardSurface.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
import { Package, Warning, Translate } from '@mana/shared-icons';
let exporting = $state(false);
let deleting = $state(false);
let editing = $state(false);
let nameInput = $state('');
onMount(() => {
if (!devUser.id) goto('/');
@ -28,6 +31,7 @@
const profileLayers = stackLayers('account-profile', 3);
const exportLayers = stackLayers('account-export', 3);
const langLayers = stackLayers('account-lang', 3);
const dangerLayers = stackLayers('account-danger', 3);
async function onExport() {
@ -78,6 +82,21 @@
devUser.clear();
goto('/');
}
function startEdit() {
nameInput = devUser.user?.name ?? '';
editing = true;
}
function cancelEdit() {
editing = false;
}
function saveEdit() {
devUser.patchProfile({ name: nameInput });
editing = false;
toasts.success(t('account.profile_saved'));
}
</script>
<svelte:head>
@ -98,6 +117,33 @@
<div class="card-corner">
<div class="avatar" aria-hidden="true">{initials}</div>
</div>
{#if editing}
<!-- Edit-Mode -->
<div class="card-body">
<p class="card-title">{t('account.edit_profile')}</p>
<label class="field-label">
{t('account.name_label')}
<input
class="field-input"
type="text"
bind:value={nameInput}
placeholder={t('account.name_placeholder')}
maxlength="80"
autocomplete="name"
/>
</label>
</div>
<div class="card-meta edit-actions">
<button type="button" class="btn-primary" onclick={saveEdit}>
{t('account.save')}
</button>
<button type="button" class="btn-ghost" onclick={cancelEdit}>
{t('account.cancel')}
</button>
</div>
{:else}
<!-- View-Mode -->
<div class="card-body">
{#if devUser.user?.name}
<p class="card-title">{devUser.user.name}</p>
@ -107,11 +153,44 @@
<span class="tier-badge">{devUser.user.tier}</span>
{/if}
</div>
<div class="card-meta">
<button type="button" class="btn-ghost" onclick={logout}>
<div class="card-meta edit-actions">
<button type="button" class="btn-ghost" onclick={startEdit}>
{t('account.edit_profile')}
</button>
<button type="button" class="btn-ghost btn-ghost-muted" onclick={logout}>
{t('account.logout')}
</button>
</div>
{/if}
</div>
</CardSurface>
</li>
<!-- Sprache-Karte -->
<li class="stack-wrap">
{#each langLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#6366F1">
<div class="card-inner">
<div class="card-corner">
<Translate size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('common.language_switcher')}</p>
<p class="card-desc">{t('common.language_desc')}</p>
</div>
<div class="card-meta">
<div class="lang-toggle">
{#each [['de','Deutsch'],['en','English'],['fr','Français'],['it','Italiano'],['es','Español']] as [id, label] (id)}
<button
class="lang-btn"
class:active={i18n.current === id}
onclick={() => i18n.set(id)}
>{label}</button>
{/each}
</div>
</div>
</div>
</CardSurface>
</li>
@ -124,7 +203,7 @@
<CardSurface size="md" colorAccent="#22C55E">
<div class="card-inner">
<div class="card-corner">
<span class="card-icon" aria-hidden="true">📦</span>
<Package size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('account.export_title')}</p>
@ -152,7 +231,7 @@
<CardSurface size="md" colorAccent="hsl(var(--color-error))">
<div class="card-inner">
<div class="card-corner">
<span class="card-icon" aria-hidden="true">⚠️</span>
<Warning size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title danger-title">{t('account.delete_title')}</p>
@ -264,11 +343,6 @@
letter-spacing: -0.02em;
}
.card-icon {
font-size: 1.5rem;
line-height: 1;
}
.card-body {
flex: 1;
display: flex;
@ -327,6 +401,44 @@
padding-top: 0.5rem;
}
.edit-actions {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.field-input {
padding: 0.3125rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-family: inherit;
font-size: 0.8125rem;
width: 100%;
box-sizing: border-box;
}
.field-input:focus {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
border-color: hsl(var(--color-primary));
}
.btn-ghost-muted {
color: hsl(var(--color-muted-foreground));
font-weight: 400;
}
/* Buttons */
.btn-ghost {
padding: 0.375rem 0.75rem;
@ -381,4 +493,36 @@
.btn-danger:hover:not(:disabled) { background: hsl(var(--color-error) / 0.08); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.lang-toggle {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.lang-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-family: inherit;
font-size: 0.75rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.lang-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.lang-btn.active {
background: rgb(99 102 241 / 0.12);
border-color: #6366F1;
color: #6366F1;
font-weight: 600;
}
</style>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { env as publicEnv } from '$env/dynamic/public';
let error = $state<string | null>(null);
function authWebUrl(): string {
return publicEnv.PUBLIC_AUTH_WEB_URL ?? 'https://auth.mana.how';
}
onMount(async () => {
// Token via SSO-Cookie holen (Cookie wurde von auth.mana.how gesetzt).
const ok = await devUser.tryRefresh();
if (ok) {
// User-Profil aus dem frisch geminteten JWT laden.
await devUser.loadUserFromToken();
const next = page.url.searchParams.get('next');
goto(next && next.startsWith('/') ? next : '/decks');
} else {
// Session abgelaufen oder Cookie fehlt — zurück zum Auth-Portal.
const loginUrl = new URL(`${authWebUrl()}/login`);
loginUrl.searchParams.set('app', 'cards');
loginUrl.searchParams.set('redirect', window.location.href);
window.location.href = loginUrl.toString();
}
});
</script>
<div class="flex min-h-screen items-center justify-center">
{#if error}
<p class="text-sm text-[hsl(var(--color-error))]">{error}</p>
{:else}
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">Anmeldung wird abgeschlossen…</p>
{/if}
</div>

View file

@ -5,7 +5,7 @@
import { createDeck, generateDeck, generateDeckFromImage } from '$lib/api/decks.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import { i18n, t, type Locale } from '$lib/i18n/index.svelte.ts';
import DeckCategoryIcon from '$lib/components/DeckCategoryIcon.svelte';
import { apiErrorMessage } from '$lib/api/error.ts';
@ -14,7 +14,7 @@
let color = $state('#0088ff');
let category = $state<DeckCategoryId | undefined>(undefined);
let count = $state(15);
let language = $state<'de' | 'en'>(i18n.current);
let language = $state<Locale>(i18n.current);
let saving = $state(false);
let generating = $state(false);
let aiError = $state<string | null>(null);

View file

@ -5,13 +5,23 @@
import { loadStats, type UserStats } from '$lib/api/me.ts';
import { i18n, t, tn } from '$lib/i18n/index.svelte.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { stackLayers } from '$lib/utils/deck-tilt';
import CardSurface from '$lib/components/CardSurface.svelte';
import { ChartBar, Fire, Brain, CalendarDots, Target, CalendarCheck } from '@mana/shared-icons';
let stats = $state<UserStats | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
const numberLayers = stackLayers('stats-numbers', 3);
const activityLayers = stackLayers('stats-activity', 3);
const fsrsLayers = stackLayers('stats-fsrs', 3);
const actGridLayers = stackLayers('stats-act-grid', 3);
const retentionLayers = stackLayers('stats-retention', 3);
const forecastLayers = stackLayers('stats-forecast', 3);
const peakDay = $derived.by(() => {
if (!stats) return 0;
if (!stats) return 1;
return Math.max(1, ...stats.reviewed_per_day.map((d) => d.n));
});
@ -20,11 +30,42 @@
return stats.reviewed_per_day.reduce((s, d) => s + d.n, 0);
});
onMount(async () => {
if (!devUser.id) {
goto('/');
return;
// 84 Tage als 12 Wochen × 7 Tage (Spalte = Woche, älteste links).
const activityGrid = $derived.by((): { day: string; n: number }[][] => {
if (!stats) return [];
const days = stats.activity_days;
const weeks: { day: string; n: number }[][] = [];
for (let w = 0; w < 12; w++) {
weeks.push(days.slice(w * 7, w * 7 + 7));
}
return weeks;
});
const peakActivity = $derived.by(() => {
if (!stats) return 1;
return Math.max(1, ...stats.activity_days.map((d) => d.n));
});
const retentionPct = $derived.by(() => {
if (!stats || stats.retention_rate === null) return null;
return Math.round(stats.retention_rate * 100);
});
const peakForecast = $derived.by(() => {
if (!stats) return 1;
return Math.max(1, ...stats.due_forecast.map((d) => d.n));
});
function cellLevel(n: number): number {
if (n === 0) return 0;
if (n <= 2) return 1;
if (n <= 5) return 2;
if (n <= 9) return 3;
return 4;
}
onMount(async () => {
if (!devUser.id) { goto('/'); return; }
try {
stats = await loadStats();
} catch (e) {
@ -34,16 +75,18 @@
}
});
const LOCALE_MAP: Record<string, string> = {
de: 'de-DE', en: 'en-US', fr: 'fr-FR', it: 'it-IT', es: 'es-ES',
};
function dayLabel(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
const lang = i18n.current === 'en' ? 'en-US' : 'de-DE';
return d.toLocaleDateString(lang, { weekday: 'short' });
return d.toLocaleDateString(LOCALE_MAP[i18n.current] ?? 'de-DE', { 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);
return d.toLocaleString(LOCALE_MAP[i18n.current] ?? 'de-DE');
}
</script>
@ -51,84 +94,449 @@
<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">{t('stats.title')}</h1>
<h1 class="page-title">{t('stats.title')}</h1>
{#if loading}
<p class="mt-6 text-[hsl(var(--color-muted-foreground))]">{t('stats.loading')}</p>
<p class="state-msg">{t('stats.loading')}</p>
{:else if error}
<p class="mt-6 text-[hsl(var(--color-error))]">{t('stats.error', { msg: error })}</p>
<p class="state-msg state-error">{t('stats.error', { msg: error })}</p>
{:else if stats}
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{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-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.decks')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_decks}</div>
</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.cards')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_cards}</div>
</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.reviews')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_reviews}</div>
</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.due_now')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.due_now}</div>
</div>
</section>
<ul class="card-row" aria-label={t('stats.title')}>
<section class="mt-6 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-medium">{t('stats.days_title')}</h2>
<span class="text-xs text-[hsl(var(--color-muted-foreground))]">
{tn('stats.streak', stats.streak_days, { total: reviewedTotal7 })}
</span>
<!-- Zahlen-Karte -->
<li class="stack-wrap">
{#each numberLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="hsl(var(--color-primary))">
<div class="card-inner">
<div class="card-corner">
<ChartBar size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="mt-3 flex h-32 items-end gap-2" role="list">
<div class="card-body">
<p class="card-title">{t('stats.title')}</p>
<p class="card-desc">{t('stats.generated_at', { date: fullDate(stats.generated_at) })}</p>
<dl class="stat-grid">
<div class="stat-cell">
<dt class="stat-label">{t('stats.decks')}</dt>
<dd class="stat-value">{stats.total_decks}</dd>
</div>
<div class="stat-cell">
<dt class="stat-label">{t('stats.cards')}</dt>
<dd class="stat-value">{stats.total_cards}</dd>
</div>
<div class="stat-cell">
<dt class="stat-label">{t('stats.reviews')}</dt>
<dd class="stat-value">{stats.total_reviews}</dd>
</div>
<div class="stat-cell">
<dt class="stat-label">{t('stats.due_now')}</dt>
<dd class="stat-value due">{stats.due_now}</dd>
</div>
</dl>
</div>
</div>
</CardSurface>
</li>
<!-- Aktivitäts-Karte -->
<li class="stack-wrap">
{#each activityLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#F59E0B">
<div class="card-inner">
<div class="card-corner">
<Fire size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('stats.days_title')}</p>
<p class="card-desc">{tn('stats.streak', stats.streak_days, { total: reviewedTotal7 })}</p>
<div class="bar-chart" role="list">
{#each stats.reviewed_per_day as d (d.day)}
<div
class="flex flex-1 flex-col items-center justify-end gap-1"
class="bar-col"
role="listitem"
aria-label="{dayLabel(d.day)}: {d.n}"
>
<div class="text-xs tabular-nums">{d.n || ''}</div>
<span class="bar-count">{d.n || ''}</span>
<div
class="w-full rounded-t bg-[hsl(var(--color-primary))]/80"
style="height: {(d.n / peakDay) * 100}%; min-height: {d.n > 0 ? '4px' : '0'};"
class="bar-fill"
style:height="{(d.n / peakDay) * 100}%"
style:min-height={d.n > 0 ? '3px' : '0'}
aria-hidden="true"
></div>
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{dayLabel(d.day)}</div>
<span class="bar-label">{dayLabel(d.day)}</span>
</div>
{/each}
</div>
</section>
</div>
</div>
</CardSurface>
</li>
<section class="mt-6 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<h2 class="text-lg font-medium">{t('stats.fsrs_title')}</h2>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{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-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_new')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.new}</dd>
<!-- FSRS-Karte -->
<li class="stack-wrap">
{#each fsrsLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#8B5CF6">
<div class="card-inner">
<div class="card-corner">
<Brain size={24} weight="duotone" aria-hidden="true" />
</div>
<div>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_learning')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.learning}</dd>
<div class="card-body">
<p class="card-title">{t('stats.fsrs_title')}</p>
<p class="card-desc">{t('stats.fsrs_intro')}</p>
<dl class="stat-grid">
<div class="stat-cell">
<dt class="stat-label">{t('stats.fsrs_new')}</dt>
<dd class="stat-value">{stats.state_counts.new}</dd>
</div>
<div>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_review')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.review}</dd>
<div class="stat-cell">
<dt class="stat-label">{t('stats.fsrs_learning')}</dt>
<dd class="stat-value">{stats.state_counts.learning}</dd>
</div>
<div>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_relearning')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.relearning}</dd>
<div class="stat-cell">
<dt class="stat-label">{t('stats.fsrs_review')}</dt>
<dd class="stat-value">{stats.state_counts.review}</dd>
</div>
<div class="stat-cell">
<dt class="stat-label">{t('stats.fsrs_relearning')}</dt>
<dd class="stat-value">{stats.state_counts.relearning}</dd>
</div>
</dl>
</section>
</div>
</div>
</CardSurface>
</li>
<!-- Activity-Grid-Karte -->
<li class="stack-wrap">
{#each actGridLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#10B981">
<div class="card-inner">
<div class="card-corner">
<CalendarDots size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('stats.activity_title')}</p>
<p class="card-desc">{t('stats.activity_desc', { weeks: 12 })}</p>
<div class="act-grid" role="img" aria-label={t('stats.activity_title')}>
{#each activityGrid as week, wi (wi)}
<div class="act-col">
{#each week as cell (cell.day)}
<div
class="act-cell level-{cellLevel(cell.n)}"
title="{cell.day}: {cell.n}"
></div>
{/each}
</div>
{/each}
</div>
</div>
</div>
</CardSurface>
</li>
<!-- Retention-Karte -->
<li class="stack-wrap">
{#each retentionLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#06B6D4">
<div class="card-inner">
<div class="card-corner">
<Target size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('stats.retention_title')}</p>
<p class="card-desc">{t('stats.retention_desc')}</p>
{#if retentionPct === null}
<p class="retention-none">{t('stats.retention_none')}</p>
{:else}
<p class="retention-big" style:color="#06B6D4">{retentionPct}%</p>
<p class="retention-sub">
{t('stats.retention_reps', { reps: stats!.retention_reps })}
<span class="retention-sep">·</span>
{t('stats.retention_lapses', { lapses: stats!.retention_lapses })}
</p>
{/if}
</div>
</div>
</CardSurface>
</li>
<!-- Fälligkeitsvorschau-Karte -->
<li class="stack-wrap">
{#each forecastLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#F97316">
<div class="card-inner">
<div class="card-corner">
<CalendarCheck size={24} weight="duotone" aria-hidden="true" />
</div>
<div class="card-body">
<p class="card-title">{t('stats.forecast_title')}</p>
<p class="card-desc">{t('stats.forecast_desc')}</p>
{#if stats!.due_forecast.every((d) => d.n === 0)}
<p class="retention-none">{t('stats.forecast_empty')}</p>
{:else}
<div class="bar-chart forecast-chart" role="list">
{#each stats!.due_forecast as d (d.day)}
<div
class="bar-col"
role="listitem"
aria-label="{dayLabel(d.day)}: {d.n}"
>
<span class="bar-count">{d.n || ''}</span>
<div
class="bar-fill forecast-fill"
style:height="{(d.n / peakForecast) * 100}%"
style:min-height={d.n > 0 ? '3px' : '0'}
aria-hidden="true"
></div>
<span class="bar-label">{dayLabel(d.day)}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</CardSurface>
</li>
</ul>
{/if}
<style>
.page-title {
margin: 0 0 1.5rem;
font-size: 1.75rem;
font-weight: 600;
}
.state-msg {
margin-top: 2rem;
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
}
.state-error { color: hsl(var(--color-error)); }
/* Horizontal-Scroll-Reihe — identisch Account-Page */
.card-row {
list-style: none;
margin: 0;
padding-block: 1.25rem 2.5rem;
display: flex;
flex-direction: row;
gap: 1.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
scrollbar-width: thin;
scrollbar-color: hsl(var(--color-border)) transparent;
width: 100dvw;
margin-left: calc(50% - 50dvw);
}
.card-row::before,
.card-row::after {
content: '';
flex-shrink: 0;
width: max(1rem, calc((100dvw - 72rem) / 2 + 1rem));
}
.card-row::-webkit-scrollbar { height: 4px; }
.card-row::-webkit-scrollbar-track { background: transparent; }
.card-row::-webkit-scrollbar-thumb {
background: hsl(var(--color-border));
border-radius: 9999px;
}
.stack-wrap {
position: relative;
flex: 0 0 auto;
width: 16rem;
aspect-ratio: 5 / 7;
scroll-snap-align: start;
}
.layer {
position: absolute;
inset: 0;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
}
.card-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 1rem 1rem 1.125rem 1.375rem;
overflow: hidden;
}
.card-corner {
position: absolute;
top: 0.875rem;
right: 0.875rem;
}
.card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 2.75rem 0.5rem 0 0;
min-height: 0;
}
.card-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 600;
line-height: 1.3;
}
.card-desc {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
/* 2×2 Zahlen-Grid */
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.625rem 0.5rem;
margin: 0;
margin-top: auto;
}
.stat-cell { display: flex; flex-direction: column; gap: 0.125rem; }
.stat-label {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.stat-value.due { color: hsl(var(--color-primary)); }
/* Bar-Chart */
.bar-chart {
display: flex;
align-items: flex-end;
gap: 0.25rem;
height: 5.5rem;
margin-top: auto;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 0.125rem;
height: 100%;
}
.bar-count {
font-size: 0.5625rem;
line-height: 1;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
min-height: 0.75rem;
}
.bar-fill {
width: 100%;
border-radius: 2px 2px 0 0;
background: hsl(var(--color-primary) / 0.75);
transition: height 0.3s ease;
}
.bar-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
}
/* Retention */
.retention-big {
margin: auto 0;
font-size: 3.5rem;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
text-align: center;
}
.retention-sub {
margin: 0;
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
line-height: 1.4;
}
.retention-sep { margin: 0 0.25em; }
.retention-none {
margin: auto 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
/* Forecast bar override */
.forecast-fill {
background: rgb(249 115 22 / 0.75);
}
/* Activity-Grid */
.act-grid {
display: flex;
gap: 0.1875rem;
margin-top: auto;
}
.act-col {
display: flex;
flex-direction: column;
gap: 0.1875rem;
}
.act-cell {
width: 0.6875rem;
height: 0.6875rem;
border-radius: 0.125rem;
}
.act-cell.level-0 { background: hsl(var(--color-border)); }
.act-cell.level-1 { background: #10B981; opacity: 0.25; }
.act-cell.level-2 { background: #10B981; opacity: 0.5; }
.act-cell.level-3 { background: #10B981; opacity: 0.75; }
.act-cell.level-4 { background: #10B981; }
</style>

View file

@ -12,7 +12,8 @@
"scripts": {
"dev": "turbo run dev",
"dev:auth": "cd ../mana/services/mana-auth && pnpm dev",
"dev:full": "pnpm docker:up && pnpm docker:up:auth && pnpm db:push && pnpm db:push:auth && concurrently -n cards,auth -c cyan,blue \"pnpm dev\" \"pnpm dev:auth\"",
"dev:auth-web": "cd ../mana/services/mana-auth-web && pnpm dev",
"dev:full": "pnpm docker:up && pnpm docker:up:auth && pnpm db:push && pnpm db:push:auth && concurrently -n cards,auth,auth-web -c cyan,blue,magenta \"pnpm dev\" \"pnpm dev:auth\" \"pnpm dev:auth-web\"",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",