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:
parent
578a0a41f7
commit
3a4523da3e
20 changed files with 1778 additions and 273 deletions
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
}: {
|
||||
text: string;
|
||||
extra: string;
|
||||
clusterIds: string[];
|
||||
clusterIds: number[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
285
apps/web/src/lib/i18n/es.ts
Normal 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
285
apps/web/src/lib/i18n/fr.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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
285
apps/web/src/lib/i18n/it.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 deleting = $state(false);
|
||||
let editing = $state(false);
|
||||
let nameInput = $state('');
|
||||
|
||||
onMount(() => {
|
||||
if (!devUser.id) goto('/');
|
||||
|
|
@ -26,9 +29,10 @@
|
|||
.join('');
|
||||
});
|
||||
|
||||
const profileLayers = stackLayers('account-profile', 3);
|
||||
const exportLayers = stackLayers('account-export', 3);
|
||||
const dangerLayers = stackLayers('account-danger', 3);
|
||||
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() {
|
||||
exporting = true;
|
||||
|
|
@ -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,19 +117,79 @@
|
|||
<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>
|
||||
{/if}
|
||||
<p class="card-sub">{devUser.user?.email ?? '—'}</p>
|
||||
{#if devUser.user?.tier}
|
||||
<span class="tier-badge">{devUser.user.tier}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<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">
|
||||
{#if devUser.user?.name}
|
||||
<p class="card-title">{devUser.user.name}</p>
|
||||
{/if}
|
||||
<p class="card-sub">{devUser.user?.email ?? '—'}</p>
|
||||
{#if devUser.user?.tier}
|
||||
<span class="tier-badge">{devUser.user.tier}</span>
|
||||
{/if}
|
||||
<p class="card-title">{t('common.language_switcher')}</p>
|
||||
<p class="card-desc">{t('common.language_desc')}</p>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<button type="button" class="btn-ghost" onclick={logout}>
|
||||
{t('account.logout')}
|
||||
</button>
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
39
apps/web/src/routes/auth/callback/+page.svelte
Normal file
39
apps/web/src/routes/auth/callback/+page.svelte
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 stats = $state<UserStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
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>
|
||||
{:else if error}
|
||||
<p class="mt-6 text-[hsl(var(--color-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>
|
||||
{#if loading}
|
||||
<p class="state-msg">{t('stats.loading')}</p>
|
||||
{:else if error}
|
||||
<p class="state-msg state-error">{t('stats.error', { msg: error })}</p>
|
||||
{:else if stats}
|
||||
|
||||
<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="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>
|
||||
<div class="mt-3 flex h-32 items-end gap-2" role="list">
|
||||
{#each stats.reviewed_per_day as d (d.day)}
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-end gap-1"
|
||||
role="listitem"
|
||||
aria-label="{dayLabel(d.day)}: {d.n}"
|
||||
>
|
||||
<div class="text-xs tabular-nums">{d.n || ''}</div>
|
||||
<div
|
||||
class="w-full rounded-t bg-[hsl(var(--color-primary))]/80"
|
||||
style="height: {(d.n / peakDay) * 100}%; min-height: {d.n > 0 ? '4px' : '0'};"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{dayLabel(d.day)}</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="bar-col"
|
||||
role="listitem"
|
||||
aria-label="{dayLabel(d.day)}: {d.n}"
|
||||
>
|
||||
<span class="bar-count">{d.n || ''}</span>
|
||||
<div
|
||||
class="bar-fill"
|
||||
style:height="{(d.n / peakDay) * 100}%"
|
||||
style:min-height={d.n > 0 ? '3px' : '0'}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<span class="bar-label">{dayLabel(d.day)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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 class="stat-cell">
|
||||
<dt class="stat-label">{t('stats.fsrs_learning')}</dt>
|
||||
<dd class="stat-value">{stats.state_counts.learning}</dd>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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="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>
|
||||
</dl>
|
||||
</section>
|
||||
{/if}
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue