Add an English/Czech interface language switch
English stays the default; Czech is a full translation. Only the app chrome is translated - notes and generated decks keep their own language, which is a core feature, not a setting. i18n - lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad). English is the default and the fallback for a missing key. - lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the x-freezmo-locale header. The hook's t/plural are bound to the locale React is rendering with, so the first client render matches the server HTML and then re-renders in the stored language; module-level t is only for event handlers and non-React helpers. - <html lang> follows the choice (inline script + setLocale). UI - Settings -> Language segmented control (English / Cestina), plus an EN/CS button on the key gate for first run. - Every screen translated: sidebar, header, tab bar, library, deck cards, folder bar, create flow, all seven activities, study chrome, results, settings + health panel, API-key gate and help, install sheet, offline and the deck-not-found page. - Plural-aware counts everywhere (decks, folders, activities, runs, photos, documents, pages, words, wrong attempts). Server - API routes and guards (generate, grade, health, http-guard, rate-limit, ollama error mapping) answer in the request's language, so error cards and the health panel never fall back to English mid-sentence. Fixes - A hydration mismatch when a non-default locale was stored silently dropped the .dark class after hydration; render-time t() is now bound to the hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor, DeckCard's folder fallback). - Bumped the service worker cache to freezmo-v4 for this deploy. Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals, settings switch) and a rebuilt regression suite 72/72 (English default, library, create, all 7 activity types, results, settings, install sheet, offline, PWA, security, Czech smoke).
This commit is contained in:
parent
4c2834edac
commit
618ad5fb9c
42 changed files with 1892 additions and 509 deletions
|
|
@ -5,7 +5,8 @@ import { useState } from "react";
|
|||
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
||||
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
|
||||
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
|
|
@ -21,6 +22,7 @@ const ADD_OPTIONS = [3, 5, 8];
|
|||
* prominent Study CTA with quiet secondary actions.
|
||||
*/
|
||||
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
|
||||
const { t, plural } = useI18n();
|
||||
const folders = listFolders();
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
|
@ -44,7 +46,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
|
||||
const addMore = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be extended.");
|
||||
setError(t("deck.noSourceExtend"));
|
||||
return;
|
||||
}
|
||||
setBusy("more");
|
||||
|
|
@ -59,11 +61,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||
});
|
||||
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
|
||||
setNotice(`Added ${generated.activities.length} activities`);
|
||||
setNotice(plural(generated.activities.length, "deck.added"));
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (addError) {
|
||||
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
|
||||
setError(addError instanceof ApiError ? addError.message : t("deck.couldNotAdd"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
|
@ -71,7 +73,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
|
||||
const regenerate = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be regenerated.");
|
||||
setError(t("deck.noSourceRegenerate"));
|
||||
return;
|
||||
}
|
||||
setBusy("regenerate");
|
||||
|
|
@ -94,11 +96,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
bestScore: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setNotice("Deck regenerated");
|
||||
setNotice(t("deck.regenerated"));
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (regenerateError) {
|
||||
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
|
||||
setError(regenerateError instanceof ApiError ? regenerateError.message : t("deck.couldNotRegenerate"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
|
@ -123,7 +125,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
{renaming ? (
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
||||
Deck title
|
||||
{t("deck.titleLabel")}
|
||||
</label>
|
||||
<input
|
||||
id={`rename-${deck.id}`}
|
||||
|
|
@ -141,7 +143,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle}>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -158,28 +160,26 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
{formatDate(deck.updatedAt)}
|
||||
<span aria-hidden> · </span>
|
||||
</span>
|
||||
{deck.activities.length} activities
|
||||
{plural(deck.activities.length, "deck.activities")}
|
||||
<span className="lg:hidden">
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
|
||||
{plural(deck.attempts, "deck.runs")}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Desktop always shows the same two fields. */}
|
||||
<span className="hidden lg:inline">
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts > 0
|
||||
? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}`
|
||||
: "Not played yet"}
|
||||
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Deck actions"
|
||||
aria-label={t("deck.actions")}
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
className={cn(
|
||||
|
|
@ -196,21 +196,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
|
||||
<Tag>
|
||||
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
|
||||
<span className={difficultyClass(deck.difficulty)}>
|
||||
{deck.difficulty === "easy"
|
||||
? t("upload.easy")
|
||||
: deck.difficulty === "hard"
|
||||
? t("upload.hard")
|
||||
: t("upload.medium")}
|
||||
</span>
|
||||
</Tag>
|
||||
{deck.folderId ? (
|
||||
<Tag>
|
||||
<FolderIcon className="size-3.5 opacity-70" />
|
||||
{folderName(deck.folderId)}
|
||||
{folders.find((entry) => entry.id === deck.folderId)?.name ?? t("common.unorganized")}
|
||||
</Tag>
|
||||
) : null}
|
||||
{deck.bestScore !== undefined ? (
|
||||
<>
|
||||
<Tag className="lg:hidden">
|
||||
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||
{t("deck.bestLabel")}{" "}
|
||||
<span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||
</Tag>
|
||||
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
|
||||
Best {deck.bestScore}%
|
||||
{t("deck.bestLabel")} {deck.bestScore}%
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -226,9 +233,13 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
{/* status ------------------------------------------------------- */}
|
||||
{busy ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
|
||||
<ProgressBar
|
||||
value={1}
|
||||
max={2}
|
||||
label={busy === "more" ? t("deck.addingActivities") : t("deck.regeneratingDeck")}
|
||||
/>
|
||||
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
|
||||
{busy === "more" ? t("deck.addingNew") : t("deck.regeneratingWhole")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -248,12 +259,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
<div className="mt-5 lg:mt-4">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
{t("deck.study")}
|
||||
</Link>
|
||||
<div className="relative z-10 hidden lg:flex lg:justify-end">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
{t("deck.study")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -268,11 +279,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
||||
✎
|
||||
</span>
|
||||
Rename
|
||||
{t("common.rename")}
|
||||
</button>
|
||||
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
|
||||
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Export JSON
|
||||
{t("deck.export")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -281,16 +292,16 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
disabled={Boolean(busy) || !hasSource}
|
||||
>
|
||||
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Regenerate deck
|
||||
{t("deck.regenerate")}
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
||||
Really delete
|
||||
{t("deck.reallyDelete")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -298,17 +309,17 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
<span aria-hidden className="w-4 text-center">
|
||||
⌫
|
||||
</span>
|
||||
Delete deck
|
||||
{t("deck.delete")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* folder ---------------------------------------------------- */}
|
||||
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Folder
|
||||
{t("common.folder")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
|
||||
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...folders].map((folder) => {
|
||||
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
|
||||
return (
|
||||
<button
|
||||
|
|
@ -336,7 +347,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
onClick={() => setNewFolder("")}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
+ New folder
|
||||
{t("deck.newFolder")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
|
||||
|
|
@ -358,8 +369,8 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="Folder name"
|
||||
aria-label={t("common.newFolderName")}
|
||||
placeholder={t("common.folderName")}
|
||||
maxLength={60}
|
||||
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||
/>
|
||||
|
|
@ -377,7 +388,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
{t("common.add")}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -387,7 +398,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
{/* add more -------------------------------------------------- */}
|
||||
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Add more activities
|
||||
{t("deck.addMore")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{ADD_OPTIONS.map((option) => (
|
||||
|
|
@ -407,12 +418,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
|
|||
</button>
|
||||
))}
|
||||
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
|
||||
Generate
|
||||
{t("deck.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
{!hasSource ? (
|
||||
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
No stored source text for this deck (imported decks don't carry it).
|
||||
{t("deck.noSource")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue