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:
Martin Dvořák 2026-10-08 13:56:32 +00:00
commit 618ad5fb9c
42 changed files with 1892 additions and 509 deletions

View file

@ -27,6 +27,7 @@ import {
type PreparedImage,
} from "@/lib/image";
import { createFolder, folderStore } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { upsertDeck } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
@ -53,40 +54,38 @@ const MAX_IMAGES_TO_SEND = 12;
/** Cap for the merged text sent to the API. */
const MAX_TEXT_TO_SEND = 100_000;
function describeExtraction(document: ExtractedDocument): string {
function describeExtraction(
document: ExtractedDocument,
tr: (key: string, vars?: Record<string, string | number>) => string,
pl: (n: number, base: string, vars?: Record<string, string | number>) => string,
): string {
const parts = [
`${document.words.toLocaleString()} word${document.words === 1 ? "" : "s"}`,
document.pages ? `${document.pages} page${document.pages === 1 ? "" : "s"}` : "",
document.images.length
? `${document.images.length} page image${document.images.length === 1 ? "" : "s"}`
: "",
document.truncated ? "truncated" : "",
pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
document.pages ? pl(document.pages, "upload.pages") : "",
document.images.length ? pl(document.images.length, "upload.pageImages") : "",
document.truncated ? tr("upload.truncated") : "",
];
return parts.filter(Boolean).join(" · ");
}
const DIFFICULTIES: Array<{ value: Difficulty; label: string; icon: React.ReactNode; blurb: string }> = [
const DIFFICULTIES: Array<{ value: Difficulty; labelKey: string; icon: React.ReactNode; blurbKey: string }> = [
{ value: "easy", labelKey: "upload.easy", icon: <SunIcon className="size-5" />, blurbKey: "upload.easyBlurb" },
{
value: "easy",
label: "Easy",
icon: <SunIcon className="size-5" />,
blurb: "Recall & recognition",
value: "medium",
labelKey: "upload.medium",
icon: <SnowflakeIcon className="size-5" />,
blurbKey: "upload.mediumBlurb",
},
{ value: "medium", label: "Medium", icon: <SnowflakeIcon className="size-5" />, blurb: "Understanding & application" },
{ value: "hard", label: "Hard", icon: <IceCubeIcon className="size-5" />, blurb: "Analysis & tricky edges" },
{ value: "hard", labelKey: "upload.hard", icon: <IceCubeIcon className="size-5" />, blurbKey: "upload.hardBlurb" },
];
const COUNT_OPTIONS = [8, 10, 12, 15];
const GENERATION_TIPS = [
"Tip: swipe up and down through your deck like a feed.",
"Tip: matching questions only give full XP for a perfect match.",
"Tip: your decks are saved in this browser — no account needed.",
"Tip: export a deck as JSON to share it with a friend.",
];
const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"];
export function UploadScreen() {
const router = useRouter();
const { t, plural } = useI18n();
const [images, setImages] = useState<PreparedImage[]>([]);
const [documents, setDocuments] = useState<DocItem[]>([]);
const [text, setText] = useState("");
@ -100,7 +99,7 @@ export function UploadScreen() {
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "Warming up…" });
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "" });
const folders = useSyncExternalStore(
folderStore.subscribe,
@ -149,15 +148,13 @@ export function UploadScreen() {
if (problem) problems.push(problem);
else documentFiles.push(file);
} else {
problems.push(
`"${file.name}" is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.`,
);
problems.push(t("upload.unsupported", { name: file.name }));
}
}
const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length);
const acceptedDocuments = documentFiles.slice(0, documentRoom);
if (documentFiles.length > documentRoom) problems.push(`Up to ${MAX_DOCUMENTS} documents per deck.`);
if (documentFiles.length > documentRoom) problems.push(t("upload.tooManyDocs", { n: MAX_DOCUMENTS }));
// Documents are read asynchronously (pdf.js can take a while for scans).
if (acceptedDocuments.length > 0) {
@ -179,7 +176,7 @@ export function UploadScreen() {
} catch (error) {
update({
status: "error",
error: error instanceof Error ? error.message : "Could not read this file.",
error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"),
});
}
}
@ -187,14 +184,14 @@ export function UploadScreen() {
const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length);
const acceptedImages = imageFiles.slice(0, photoRoom);
if (imageFiles.length > photoRoom) problems.push(`Up to ${MAX_IMAGES} photos per deck.`);
if (imageFiles.length > photoRoom) problems.push(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
const prepared: PreparedImage[] = [];
for (const file of acceptedImages) {
try {
prepared.push(await prepareImage(file));
} catch {
problems.push(`Could not read "${file.name}".`);
problems.push(t("upload.couldNotReadFile", { name: file.name }));
}
}
if (prepared.length > 0) {
@ -204,7 +201,7 @@ export function UploadScreen() {
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
setPhase("idle");
}, []);
}, [t]);
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
useEffect(() => {
@ -261,7 +258,7 @@ export function UploadScreen() {
setPhase("loading");
setProgress({
stage: imagesToSend.length ? "vision" : "generation",
message: imagesToSend.length ? "Reading your notes…" : "Finding the key ideas…",
message: imagesToSend.length ? t("upload.vision1") : t("upload.gen1"),
});
const controller = new AbortController();
@ -297,7 +294,7 @@ export function UploadScreen() {
message:
generateError instanceof ApiError
? generateError.message
: "Something went wrong while generating your deck. Check your Ollama settings and try again.",
: t("upload.genericError"),
kind: generateError instanceof ApiError ? generateError.kind : undefined,
});
setPhase("error");
@ -316,11 +313,10 @@ export function UploadScreen() {
<section className="space-y-2">
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
<span>Turn your notes into a study feed</span>
<span>{t("upload.title")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
Snap or drop photos of your notes. Freezmo reads them with your own Ollama vision model
and builds a swipeable deck of mini study games.
{t("upload.intro")}
</p>
</section>
@ -332,22 +328,22 @@ export function UploadScreen() {
>
<p className="font-bold">
{error.kind === "needs_key"
? "API key required"
? t("upload.errNeedsKey")
: error.kind === "rate_limited"
? "Slow down a moment"
? t("upload.errRate")
: error.kind === "auth"
? "Ollama rejected your key"
: "Generation failed"}
? t("upload.errAuth")
: t("upload.errFailed")}
</p>
<p className="mt-1">{error.message}</p>
<div className="mt-3 flex flex-wrap gap-2">
{error.kind === "needs_key" || error.kind === "auth" ? (
<Button size="sm" onClick={openSettings}>
Open settings
{t("upload.openSettings")}
</Button>
) : null}
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
Retry
{t("common.retry")}
</Button>
</div>
</div>
@ -355,7 +351,7 @@ export function UploadScreen() {
{/* ---------------------------------------------------------- */}
<section
aria-label="Add photos of your notes"
aria-label={t("upload.addPhotosAria")}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
@ -373,19 +369,17 @@ export function UploadScreen() {
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">1. Add your notes</h2>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step1")}</h2>
<p className="text-[12.5px] text-[var(--text-subtle)]">
Drag &amp; drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word,
PowerPoint, OpenDocument and text files. Up to {MAX_IMAGES} photos and {MAX_DOCUMENTS}{" "}
documents.
{t("upload.step1Help", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<FolderIcon className="size-4" /> Choose files
<FolderIcon className="size-4" /> {t("upload.chooseFiles")}
</Button>
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
<CameraIcon className="size-4" /> Camera
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
</div>
</div>
@ -423,10 +417,10 @@ export function UploadScreen() {
<SnowflakeIcon className="size-12" />
</span>
<span className="text-[14px] font-medium text-[var(--text)]">
Drop your notes here
{t("upload.dropHere")}
</span>
<span className="text-[12.5px] text-[var(--text-subtle)]">
Photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · images resized to 1600px before sending
{t("upload.formats")}
</span>
</button>
) : (
@ -442,7 +436,7 @@ export function UploadScreen() {
<button
type="button"
onClick={() => removeImage(image.id)}
aria-label={`Remove ${image.name}`}
aria-label={t("upload.remove", { name: image.name })}
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
>
✕
@ -458,7 +452,7 @@ export function UploadScreen() {
type="button"
onClick={() => fileInputRef.current?.click()}
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
aria-label="Add more photos"
aria-label={t("upload.addMorePhotos")}
>
+
</button>
@ -483,7 +477,7 @@ export function UploadScreen() {
</p>
{entry.status === "extracting" ? (
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
{entry.progress ?? "Reading…"}
{entry.progress ?? t("upload.reading")}
</p>
) : null}
{entry.status === "error" ? (
@ -492,11 +486,11 @@ export function UploadScreen() {
{entry.status === "ready" && entry.extracted ? (
<>
<p className="text-[12.5px] text-[var(--text-subtle)]">
{describeExtraction(entry.extracted)}
{describeExtraction(entry.extracted, t, plural)}
</p>
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
No readable text — is this a scan without a text layer?
{t("upload.noReadable")}
</p>
) : null}
{entry.extracted.warnings.map((warning) => (
@ -510,7 +504,7 @@ export function UploadScreen() {
<button
type="button"
onClick={() => removeDocument(entry.id)}
aria-label={`Remove ${entry.name}`}
aria-label={t("upload.remove", { name: entry.name })}
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
>
✕
@ -523,22 +517,24 @@ export function UploadScreen() {
{images.length > 0 || documents.length > 0 ? (
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
{images.length > 0
? `${images.length} photo${images.length === 1 ? "" : "s"} · ${formatBytes(totalBytes)} after compression`
? plural(images.length, "upload.photoSummary", { size: formatBytes(totalBytes) })
: ""}
{images.length > 0 && documents.length > 0 ? " · " : ""}
{documents.length > 0
? `${documents.length} document${documents.length === 1 ? "" : "s"}${
documentImages.length ? ` (${documentImages.length} page image${documentImages.length === 1 ? "" : "s"})` : ""
? `${plural(documents.length, "upload.docSummary")}${
documentImages.length
? ` (${plural(documentImages.length, "upload.docImages")})`
: ""
}`
: ""}
{imagesSkipped > 0
? ` · ${imagesSkipped} image${imagesSkipped === 1 ? "" : "s"} will be skipped (max ${MAX_IMAGES_TO_SEND} per deck)`
? ` · ${plural(imagesSkipped, "upload.skipImages", { max: MAX_IMAGES_TO_SEND })}`
: ""}
</p>
) : null}
{phase === "preparing" ? (
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">Compressing photos…</p>
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{fileError ? (
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
@ -550,35 +546,35 @@ export function UploadScreen() {
{/* ---------------------------------------------------------- */}
<section className="card space-y-4 p-4 sm:p-5">
<div>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">2. Optional extras</h2>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step2")}</h2>
<p className="text-[12.5px] text-[var(--text-subtle)]">
Paste extra notes or give the AI a hint about what to focus on.
{t("upload.step2Help")}
</p>
</div>
<div className="space-y-1.5">
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Pasted notes or text
{t("upload.pastedNotes")}
</label>
<textarea
id="notes-text"
value={text}
onChange={(event) => setText(event.target.value)}
rows={4}
placeholder="Paste lecture text, a summary, or any extra facts here…"
placeholder={t("upload.pastedPlaceholder")}
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Topic hint (optional)
{t("upload.topicHint")}
</label>
<input
id="focus"
value={focus}
onChange={(event) => setFocus(event.target.value)}
placeholder="e.g. focus on the formulas, or on dates"
placeholder={t("upload.topicPlaceholder")}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/>
</div>
@ -586,14 +582,14 @@ export function UploadScreen() {
{/* ---------------------------------------------------------- */}
<section className="card space-y-4 p-4 sm:p-5">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">3. Difficulty &amp; size</h2>
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step3")}</h2>
<div className="space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Freeze into folder
{t("upload.freezeInto")}
</span>
<div className="flex flex-wrap items-center gap-2">
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...sortedFolders].map((folder) => {
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => {
const active = folderId === folder.id;
return (
<button
@ -636,8 +632,8 @@ export function UploadScreen() {
}
}
}}
aria-label="New folder name"
placeholder="e.g. History"
aria-label={t("common.newFolderName")}
placeholder={t("upload.folderPlaceholder")}
maxLength={60}
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
/>
@ -654,7 +650,7 @@ export function UploadScreen() {
}
}}
>
Add
{t("common.add")}
</Button>
</span>
) : (
@ -663,13 +659,13 @@ export function UploadScreen() {
onClick={() => setCreatingFolder(true)}
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-500 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
>
<PlusIcon className="size-4" /> New folder
<PlusIcon className="size-4" /> {t("common.newFolder")}
</button>
)}
</div>
</div>
<div role="radiogroup" aria-label="Difficulty" className="grid grid-cols-3 gap-2">
<div role="radiogroup" aria-label={t("upload.difficultyAria")} className="grid grid-cols-3 gap-2">
{DIFFICULTIES.map((option) => {
const selected = difficulty === option.value;
return (
@ -689,8 +685,8 @@ export function UploadScreen() {
<span aria-hidden className="text-frost-600 dark:text-frost-200">
{option.icon}
</span>
<span className="text-sm font-bold text-[var(--text)]">{option.label}</span>
<span className="hidden text-[11px] text-frost-500 sm:block dark:text-frost-400">{option.blurb}</span>
<span className="text-sm font-bold text-[var(--text)]">{t(option.labelKey)}</span>
<span className="hidden text-[11px] text-frost-500 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
</button>
);
})}
@ -698,7 +694,7 @@ export function UploadScreen() {
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Activities
{t("upload.activities")}
</span>
{COUNT_OPTIONS.map((option) => (
<button
@ -723,14 +719,14 @@ export function UploadScreen() {
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[12.5px] text-[var(--text-subtle)]">
{documentsBusy
? "Reading your documents…"
? t("upload.readingDocs")
: hasContent
? "Ready when you are — generation can take a minute."
: "Add a photo or a document (or some text) to start."}
? t("upload.ready")
: t("upload.addToStart")}
</p>
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={() => void generate()} disabled={!canGenerate}>
<SnowflakeIcon className="size-5" /> Freeze my deck
<SnowflakeIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
</div>
</div>
@ -744,12 +740,13 @@ export function UploadScreen() {
/* Loading state */
/* ------------------------------------------------------------------ */
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; messages: string[] }> = [
{ stage: "vision", messages: ["Reading your notes…", "Transcribing handwriting…", "Spotting definitions…"] },
{ stage: "generation", messages: ["Finding the key ideas…", "Writing good questions…", "Building your activities…"] },
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> = [
{ stage: "vision", keys: ["upload.vision1", "upload.vision2", "upload.vision3"] },
{ stage: "generation", keys: ["upload.gen1", "upload.gen2", "upload.gen3"] },
];
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
const { t } = useI18n();
const [tick, setTick] = useState(0);
const [tip, setTip] = useState(0);
@ -763,8 +760,8 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
}, []);
const activeIndex = progress.stage === "vision" ? 0 : 1;
const messages = STAGE_STEPS[activeIndex].messages;
const message = progress.message && tick === 0 ? progress.message : messages[tick % messages.length];
const keys = STAGE_STEPS[activeIndex].keys;
const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]);
return (
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
@ -781,20 +778,20 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
{message}
</p>
<ProgressBar value={activeIndex + 1} max={2} label="Generation progress" />
<ProgressBar value={activeIndex + 1} max={2} label={t("upload.generationProgress")} />
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
{STAGE_STEPS.map((step, index) => (
<li key={step.stage} className="flex items-center gap-2">
<span aria-hidden>
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
</span>
{step.messages[0]}
{t(step.keys[0])}
</li>
))}
</ol>
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{GENERATION_TIPS[tip]}</p>
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{t(GENERATION_TIPS[tip])}</p>
<Button variant="ghost" size="sm" onClick={onCancel}>
Cancel
{t("upload.cancel")}
</Button>
</div>
</div>