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

@ -15,6 +15,7 @@ import {
XIcon,
} from "@/components/icons";
import { Spinner } from "@/components/ui";
import { LOCALES, useI18n } from "@/lib/i18n";
import { verifyOllamaKey } from "@/lib/client-api";
import { saveSettings, settingsStore } from "@/lib/settings";
import { useHealth } from "@/lib/use-health";
@ -78,6 +79,7 @@ function CornerButton({
function GateScreen() {
const reduceMotion = useReducedMotion();
const { theme, toggle } = useTheme();
const { locale, setLocale, t } = useI18n();
const [draft, setDraft] = useState("");
const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false);
@ -192,17 +194,30 @@ function GateScreen() {
}}
>
<CornerButton
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Light mode" : "Dark mode"}
label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
onClick={toggle}
>
{theme === "dark" ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
</CornerButton>
<CornerButton
label={t("language.label")}
title={t("language.label")}
onClick={() => {
const index = LOCALES.findIndex((entry) => entry.id === locale);
setLocale(LOCALES[(index + 1) % LOCALES.length].id);
}}
>
<span className="text-[12px] font-bold tracking-wide">
{LOCALES.find((entry) => entry.id === locale)?.short ?? "EN"}
</span>
</CornerButton>
<div className="group relative">
<CornerButton
label="How do I get an Ollama API key?"
title="How do I get a key?"
label={t("gate.howGetAria")}
title={t("gate.howGet")}
onClick={() => setHelpOpen(true)}
>
<QuestionIcon className="size-[18px]" />
@ -211,7 +226,7 @@ function GateScreen() {
role="tooltip"
className="pointer-events-none absolute right-0 top-full mt-2 whitespace-nowrap rounded-lg border border-[var(--hairline)] bg-[var(--gate-card)] px-2.5 py-1.5 text-[11.5px] text-[var(--text-muted)] opacity-0 shadow-[var(--shadow-card)] transition group-hover:opacity-100 group-focus-within:opacity-100"
>
How do I get a key?
{t("gate.howGet")}
</span>
</div>
</div>
@ -241,16 +256,16 @@ function GateScreen() {
/>
<h1 className="mt-6 text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[28px]">
Connect your Ollama key
{t("gate.title")}
</h1>
<p className="mx-auto mt-1.5 max-w-[300px] text-balance text-[15px] leading-relaxed text-[var(--text-subtle)]">
One step to freeze your first deck.
{t("gate.subtitle")}
</p>
<form className="mt-6" onSubmit={submit} noValidate>
<div className="relative">
<label className="sr-only" htmlFor="gate-api-key">
Ollama API key
{t("gate.apiKeyLabel")}
</label>
<input
ref={inputRef}
@ -261,7 +276,7 @@ function GateScreen() {
setDraft(event.target.value);
if (error) setError(null);
}}
placeholder="Paste your Ollama API key"
placeholder={t("gate.placeholder")}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
@ -273,7 +288,7 @@ function GateScreen() {
<button
type="button"
onClick={() => setReveal((value) => !value)}
aria-label={reveal ? "Hide API key" : "Show API key"}
aria-label={reveal ? t("gate.hide") : t("gate.show")}
aria-pressed={reveal}
className="absolute right-1.5 top-1/2 grid size-9 -translate-y-1/2 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
@ -307,10 +322,10 @@ function GateScreen() {
>
{busy ? (
<>
<Spinner className="size-4" /> Checking…
<Spinner className="size-4" /> {t("gate.checking")}
</>
) : (
"Continue"
t("gate.continue")
)}
</button>
</form>
@ -322,12 +337,12 @@ function GateScreen() {
className="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
>
<QuestionIcon className="size-3.5" />
How do I get a key?
{t("gate.howGet")}
</button>
</div>
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
Your key stays in this browser. Our server never stores it.
{t("gate.stays")}
</p>
</motion.div>
</div>
@ -343,6 +358,7 @@ function GateScreen() {
function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const reduceMotion = useReducedMotion();
const { t } = useI18n();
return (
<AnimatePresence>
@ -370,12 +386,12 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
id="gate-help-title"
className="text-[19px] font-semibold leading-snug tracking-[-0.02em] text-[var(--text)]"
>
How to get your Ollama API key
{t("gate.helpTitle")}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
aria-label={t("common.close")}
className="grid size-9 shrink-0 place-items-center rounded-[10px] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-4" />
@ -385,33 +401,33 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
<div className="mt-4 flex items-start gap-2.5 rounded-xl border border-[var(--gate-callout-border)] bg-[var(--gate-callout-bg)] px-3.5 py-3">
<CheckCircleIcon className="mt-px size-[18px] shrink-0 text-[var(--text-muted)]" />
<p className="text-[12.5px] leading-relaxed text-[var(--text-muted)]">
You only need the{" "}
<strong className="font-semibold text-[var(--text)]">free cloud tier</strong>. No
payment and no credit card required.
{t("gate.freeTier.before")}{" "}
<strong className="font-semibold text-[var(--text)]">{t("gate.freeTier.strong")}</strong>
{t("gate.freeTier.after")}
</p>
</div>
<ol className="mt-5 space-y-4">
<Step index={1}>
Go to{" "}
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink> and sign in, or
create a free account.
{t("gate.step1.before")}{" "}
<ExternalLink href="https://ollama.com">ollama.com</ExternalLink>{" "}
{t("gate.step1.after")}
</Step>
<Step index={2}>
Open Settings → API keys at{" "}
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>.
{t("gate.step2.before")}{" "}
<ExternalLink href={KEYS_URL}>ollama.com/settings/keys</ExternalLink>
{t("gate.step2.after")}
</Step>
<Step index={3}>
Click{" "}
<span className="font-medium text-[var(--text-muted)]">&ldquo;Create new key&rdquo;</span>,
give it any name, and copy it.
{t("gate.step3.before")}{" "}
<span className="font-medium text-[var(--text-muted)]">{t("gate.step3.strong")}</span>
{t("gate.step3.after")}
</Step>
<Step index={4}>Paste it into Freezmo and press Continue.</Step>
<Step index={4}>{t("gate.step4")}</Step>
</ol>
<p className="mt-5 text-[12px] leading-relaxed text-[var(--text-subtle)]">
The free tier is enough for generating decks. You can swap or remove your key anytime in
Settings.
{t("gate.footnote")}
</p>
<button
@ -419,7 +435,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
onClick={onClose}
className="btn-primary mt-5 h-11 w-full rounded-[13px] text-[14px] font-medium"
>
Got it
{t("gate.gotIt")}
</button>
</motion.div>
</motion.div>

View file

@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { GearIcon, MenuIcon } from "@/components/icons";
import { SettingsDialog } from "@/components/SettingsDialog";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useI18n } from "@/lib/i18n";
import { sidebarOpen } from "@/lib/nav";
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
@ -16,6 +17,7 @@ import { cn } from "@/lib/utils";
* for navigation.
*/
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
const { t } = useI18n();
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
@ -41,12 +43,12 @@ export function AppHeader({ className, compact = false }: { className?: string;
<button
type="button"
onClick={() => sidebarOpen.toggle()}
aria-label="Open menu"
aria-label={t("header.openMenu")}
className="mr-1 grid size-9 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<MenuIcon className="size-5" />
</button>
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label="Freezmo home">
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label={t("header.home")}>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
@ -60,7 +62,7 @@ export function AppHeader({ className, compact = false }: { className?: string;
</span>
{!compact ? (
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
study feed
{t("header.tagline")}
</span>
) : null}
</span>
@ -70,8 +72,8 @@ export function AppHeader({ className, compact = false }: { className?: string;
<button
type="button"
onClick={() => setSettingsOpen(true)}
aria-label="Open settings and Ollama status"
title="Settings"
aria-label={t("header.openSettings")}
title={t("common.settings")}
className="inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<GearIcon className="size-[18px]" />

View file

@ -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&apos;t carry it).
{t("deck.noSource")}
</p>
) : null}
</div>

View file

@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import { FolderIcon, PlusIcon } from "@/components/icons";
import { createFolder } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import type { Folder } from "@/lib/schemas";
import { cn } from "@/lib/utils";
@ -32,6 +33,7 @@ export function FolderBar({
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const submit = () => {
const result = createFolder(draft);
@ -62,7 +64,7 @@ export function FolderBar({
aria-pressed={selected === ALL_FOLDERS}
className={pill(selected === ALL_FOLDERS)}
>
All decks
{t("common.allDecks")}
</button>
{folders.map((folder) => (
@ -97,8 +99,8 @@ export function FolderBar({
setError(null);
}
}}
placeholder="Folder name"
aria-label="New folder name"
placeholder={t("common.folderName")}
aria-label={t("common.newFolderName")}
maxLength={60}
className="w-28 bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
/>
@ -107,7 +109,7 @@ export function FolderBar({
onClick={submit}
className="btn-primary h-7 px-3 text-[12px]"
>
Add
{t("common.add")}
</button>
</span>
) : (
@ -117,7 +119,7 @@ export function FolderBar({
className="inline-flex shrink-0 items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-4" />
New folder
{t("common.newFolder")}
</button>
)}
</div>

View file

@ -15,6 +15,7 @@ import {
XIcon,
} from "@/components/icons";
import { Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import {
detectBrowser,
detectPlatform,
@ -52,6 +53,7 @@ export function InstallSheet() {
const [installing, setInstalling] = useState(false);
const dragControls = useDragControls();
const reduceMotion = useReducedMotion();
const { t } = useI18n();
const domain = typeof window === "undefined" ? "" : window.location.hostname;
/** Everything needed to render the sheet, or null if it shouldn't appear. */
@ -148,55 +150,64 @@ export function InstallSheet() {
if (info.browser !== "safari") {
steps.push(
<>
Open this page in <Chip icon={<CompassIcon className="size-4" />}>Safari</Chip>
{t("install.openIn")} <Chip icon={<CompassIcon className="size-4" />}>Safari</Chip>
</>,
);
}
steps.push(
<>
Tap <IconChip icon={<EllipsisIcon className="size-4" />} label="More options" /> in the
browser bar
{t("install.tap")}{" "}
<IconChip icon={<EllipsisIcon className="size-4" />} label={t("install.moreOptions")} />{" "}
{t("install.inBrowserBar")}
</>,
);
steps.push(
<>
Tap <Chip icon={<ShareIcon className="size-4" />}>Share</Chip>, then{" "}
<Chip icon={<ChevronDownIcon className="size-4" />}>More</Chip>
{t("install.tap")} <Chip icon={<ShareIcon className="size-4" />}>{t("install.share")}</Chip>
{t("install.then")} <Chip icon={<ChevronDownIcon className="size-4" />}>{t("install.more")}</Chip>
</>,
);
steps.push(
<>
Choose <Chip icon={<AddToHomeIcon className="size-4" />}>Add to Home Screen</Chip>
{t("install.choose")}{" "}
<Chip icon={<AddToHomeIcon className="size-4" />}>{t("install.addToHome")}</Chip>
</>,
);
steps.push(
<>
Tap <AccentPill icon={<PlusIcon className="size-4" />}>Add</AccentPill> to confirm
{t("install.tapConfirm")}{" "}
<AccentPill icon={<PlusIcon className="size-4" />}>{t("install.add")}</AccentPill>{" "}
{t("install.toConfirm")}
</>,
);
} else if (info?.platform === "android") {
if (info.browser !== "chrome") {
steps.push(
<>
Open this page in <Chip icon={<ChromeIcon className="size-4" />}>Chrome</Chip>
{t("install.openIn")} <Chip icon={<ChromeIcon className="size-4" />}>Chrome</Chip>
</>,
);
}
steps.push(
<>
Tap <IconChip icon={<EllipsisIcon className="size-4" />} label="More options" /> in the top
right of the browser
{t("install.tap")}{" "}
<IconChip icon={<EllipsisIcon className="size-4" />} label={t("install.moreOptions")} />{" "}
{t("install.inTopRight")}
</>,
);
steps.push(
<>
Choose <Chip icon={<DownloadIcon className="size-4" />}>Install app</Chip> or{" "}
<Chip icon={<AddToHomeIcon className="size-4" />}>Add to Home screen</Chip>
{t("install.choose")}{" "}
<Chip icon={<DownloadIcon className="size-4" />}>{t("install.installApp")}</Chip>{" "}
{t("install.or")}{" "}
<Chip icon={<AddToHomeIcon className="size-4" />}>{t("install.addToHomeScreen")}</Chip>
</>,
);
steps.push(
<>
Tap <AccentPill icon={<DownloadIcon className="size-4" />}>Install</AccentPill> to confirm
{t("install.tapConfirm")}{" "}
<AccentPill icon={<DownloadIcon className="size-4" />}>{t("install.install")}</AccentPill>{" "}
{t("install.toConfirm")}
</>,
);
}
@ -252,14 +263,12 @@ export function InstallSheet() {
id="install-sheet-title"
className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--text)]"
>
{info.platform === "ios"
? "Install Freezmo on iPhone"
: "Install Freezmo on Android"}
{info.platform === "ios" ? t("install.titleIos") : t("install.titleAndroid")}
</h2>
<button
type="button"
onClick={close}
aria-label="Close"
aria-label={t("common.close")}
className="grid size-10 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-[18px]" />
@ -294,11 +303,11 @@ export function InstallSheet() {
>
{installing ? (
<>
<Spinner className="size-4" /> Installing…
<Spinner className="size-4" /> {t("install.installing")}
</>
) : (
<>
<DownloadIcon className="size-4" /> Install
<DownloadIcon className="size-4" /> {t("install.install")}
</>
)}
</button>
@ -306,7 +315,7 @@ export function InstallSheet() {
{showNativePrompt ? (
<p className="mt-4 text-center text-[12px] text-[var(--text-subtle)]">
Or install manually:
{t("install.orManually")}
</p>
) : null}
@ -329,7 +338,7 @@ export function InstallSheet() {
onClick={close}
className="mt-6 w-full rounded-[14px] py-3 text-center text-[15px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
Maybe later
{t("install.maybeLater")}
</button>
</div>
</motion.div>

View file

@ -8,6 +8,7 @@ import { FolderBar } from "@/components/FolderBar";
import { FolderIcon, UploadIcon } from "@/components/icons";
import { Button, EmptyState } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { openSettings } from "@/lib/ui-events";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
@ -27,6 +28,7 @@ export function LibraryScreen() {
);
const selected = useSelectedFolder();
const setSelected = folderSelection.set;
const { t, plural } = useI18n();
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
@ -51,7 +53,7 @@ export function LibraryScreen() {
const groups = useMemo(() => {
const unorganized: { id: string; name: string; decks: Deck[]; virtual: boolean } = {
id: UNORGANIZED_ID,
name: "Unorganized",
name: t("common.unorganized"),
decks: sorted.filter((deck) => !deck.folderId),
virtual: true,
};
@ -72,7 +74,7 @@ export function LibraryScreen() {
virtual: false,
}));
return [...named, unorganized];
}, [folderList, selected, sorted]);
}, [folderList, selected, sorted, t]);
const totalActivities = decks.reduce((sum, deck) => sum + deck.activities.length, 0);
const bestEver = decks.reduce((best, deck) => Math.max(best, deck.bestScore ?? 0), 0);
@ -84,9 +86,9 @@ export function LibraryScreen() {
const text = await file.text();
const result = importDeck(text);
if ("error" in result) setMessage({ tone: "error", text: result.error });
else setMessage({ tone: "ok", text: `Imported “${result.deck.title}”.` });
else setMessage({ tone: "ok", text: t("library.imported", { title: result.deck.title }) });
} catch {
setMessage({ tone: "error", text: "Could not read that file." });
setMessage({ tone: "error", text: t("library.couldNotRead") });
}
};
@ -94,7 +96,7 @@ export function LibraryScreen() {
deleteFolder(folderId);
setConfirmDelete(null);
setSelected(ALL_FOLDERS);
setMessage({ tone: "ok", text: "Folder deleted — its decks moved to Unorganized." });
setMessage({ tone: "ok", text: t("library.folderDeleted") });
};
const isEmpty = sorted.length === 0 && folderList.length === 0;
@ -109,10 +111,10 @@ export function LibraryScreen() {
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
Your icebox
{t("library.title")}
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
Decks frozen from your notes, saved in this browser.
{t("library.subtitle")}
</p>
</div>
@ -124,7 +126,7 @@ export function LibraryScreen() {
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
Import JSON
{t("library.import")}
</Button>
<button
type="button"
@ -132,7 +134,7 @@ export function LibraryScreen() {
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
>
<UploadIcon className="size-4" />
Import JSON
{t("library.import")}
</button>
<input
ref={importRef}
@ -151,15 +153,15 @@ export function LibraryScreen() {
where the sidebar already shows per-folder counts. */}
{decks.length > 0 ? (
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
{decks.length} {decks.length === 1 ? "deck" : "decks"}
{plural(decks.length, "stats.decks")}
<span aria-hidden> · </span>
{folderList.length} {folderList.length === 1 ? "folder" : "folders"}
{plural(folderList.length, "stats.folders")}
<span aria-hidden> · </span>
{totalActivities} activities
{plural(totalActivities, "stats.activities")}
{played ? (
<>
<span aria-hidden> · </span>
best {bestEver}%
{t("library.stats.best", { n: bestEver })}
</>
) : null}
</p>
@ -178,7 +180,7 @@ export function LibraryScreen() {
setSelected(id);
setMessage({
tone: "ok",
text: "Folder created — decks you freeze while it's selected land here.",
text: t("library.folderCreated"),
});
}}
/>
@ -200,22 +202,22 @@ export function LibraryScreen() {
{/* deck grid ------------------------------------------------------ */}
{isEmpty ? (
<EmptyState
title="No decks yet"
title={t("library.noDecks")}
action={
<Link href="/create" className="btn-primary h-10 px-4 text-sm">
Make your first deck
{t("library.makeFirst")}
</Link>
}
>
Freeze a page of notes into a feed of flashcards and quizzes.
{t("library.emptyBody")}
<span className="mt-3 block text-[12.5px] text-[var(--text-subtle)]">
Using your own Ollama models —{" "}
{t("library.emptyHint")}{" "}
<button
type="button"
onClick={openSettings}
className="font-medium text-[var(--text-muted)] underline underline-offset-2 hover:text-[var(--text)]"
>
check the connection in Settings
{t("library.emptySettings")}
</button>
.
</span>
@ -245,7 +247,7 @@ export function LibraryScreen() {
}
if (event.key === "Escape") setEditingFolder(null);
}}
aria-label="Folder name"
aria-label={t("library.renameFolderAria")}
maxLength={60}
className="min-w-32 max-w-xs 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"
/>
@ -257,7 +259,7 @@ export function LibraryScreen() {
else setEditingFolder(null);
}}
>
Save
{t("common.save")}
</Button>
</span>
) : (
@ -280,15 +282,15 @@ export function LibraryScreen() {
onClick={() => setEditingFolder({ id: group.id, name: group.name })}
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
Rename
{t("common.rename")}
</button>
{confirmDelete === group.id ? (
<>
<Button size="sm" variant="danger" onClick={() => removeFolderEverywhere(group.id)}>
Delete
{t("common.delete")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>
Cancel
{t("common.cancel")}
</Button>
</>
) : (
@ -297,7 +299,7 @@ export function LibraryScreen() {
onClick={() => setConfirmDelete(group.id)}
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
>
Delete
{t("common.delete")}
</button>
)}
</div>
@ -307,22 +309,19 @@ export function LibraryScreen() {
{/* One quiet label replaces the redundant single-group heading. */}
{!hasFolders ? (
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
All decks
{t("common.allDecks")}
</p>
) : null}
{confirmDelete === group.id ? (
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to
Unorganized — nothing is deleted.
{plural(group.decks.length, "library.deleteWarning")}
</p>
) : null}
{group.decks.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--hairline)] px-5 py-8 text-center text-[13px] text-[var(--text-subtle)]">
{group.virtual
? "Nothing unfiled right now."
: "Empty folder — freeze a deck into it or move one over from Unorganized."}
{group.virtual ? t("library.nothingUnfiled") : t("library.emptyFolder")}
</div>
) : (
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
@ -334,7 +333,7 @@ export function LibraryScreen() {
upsertDeck({ ...deck, folderId });
setMessage({
tone: "ok",
text: folderId ? "Deck moved." : "Deck moved to Unorganized.",
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
});
}}
/>

View file

@ -3,6 +3,7 @@
import Link from "next/link";
import { motion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import type { Activity, Deck } from "@/lib/schemas";
import { downloadDeck } from "@/lib/storage";
import { cn } from "@/lib/utils";
@ -11,14 +12,16 @@ import { Button, Chip } from "@/components/ui";
import type { AttemptResult } from "./StudyFeed";
export function answerFor(activity: Activity): string {
type Translator = (key: string, vars?: Record<string, string | number>) => string;
export function answerFor(activity: Activity, tr: Translator): string {
switch (activity.type) {
case "flashcard":
return activity.back;
case "multiple_choice":
return activity.options[activity.correctIndex];
case "true_false":
return activity.answer ? "True" : "False";
return activity.answer ? tr("results.true") : tr("results.false");
case "fill_blank":
return activity.answer;
case "matching":
@ -78,6 +81,7 @@ export function ResultsScreen({
regenerating: boolean;
regenerateError: string | null;
}) {
const { t } = useI18n();
const percent = total > 0 ? Math.round((correct / total) * 100) : 0;
const perfect = missed.length === 0 && total > 0;
const tone = percent >= 80 ? "emerald" : percent >= 50 ? "frost" : "amber";
@ -91,26 +95,37 @@ export function ResultsScreen({
>
<ScoreRing percent={percent} tone={tone} />
<h1 className="text-2xl font-semibold text-frost-950 text-balance dark:text-frost-50">
{perfect ? "Perfect freeze! 🧊" : percent >= 60 ? "Nicely done!" : "Good start — keep going!"}
{perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}
</h1>
<p className="text-sm text-frost-600 dark:text-frost-300">
{perfect
? `You aced all ${total} activities in “${deck.title}”.`
: `You got ${correct} of ${total} right${retryMode ? " on the retry run" : ""}.`}
? t("results.acedAll", { n: total, title: deck.title })
: t("results.gotOf", {
correct,
total,
retry: retryMode ? t("results.retrySuffix") : "",
})}
</p>
<div className="mt-1 flex flex-wrap justify-center gap-2">
<Chip tone="accent">
<BoltIcon className="size-3.5" /> {xp} XP earned
<BoltIcon className="size-3.5" /> {t("results.xpEarned", { n: xp })}
</Chip>
<Chip tone="warning">
<FlameIcon className="size-3.5" /> Best streak {bestStreak}
<FlameIcon className="size-3.5" /> {t("results.bestStreak", { n: bestStreak })}
</Chip>
<Chip tone="neutral">
{[deck.difficulty].map((value) => value[0].toUpperCase() + value.slice(1))[0]} difficulty
{t("results.difficulty", {
level:
deck.difficulty === "easy"
? t("upload.easy")
: deck.difficulty === "hard"
? t("upload.hard")
: t("upload.medium"),
})}
</Chip>
{deck.bestScore !== undefined && !retryMode ? (
<Chip tone="success">🏆 Best {deck.bestScore}%</Chip>
<Chip tone="success">{t("results.best", { n: deck.bestScore })}</Chip>
) : null}
</div>
</motion.div>
@ -118,7 +133,7 @@ export function ResultsScreen({
{missed.length > 0 ? (
<div className="card space-y-3 p-5">
<h2 className="flex items-center gap-2 text-base font-bold text-frost-950 dark:text-frost-50">
<SnowflakeIcon className="size-4" /> Missed items ({missed.length})
<SnowflakeIcon className="size-4" /> {t("results.missed", { n: missed.length })}
</h2>
<ul className="space-y-3">
{missed.map((activity) => (
@ -129,12 +144,12 @@ export function ResultsScreen({
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
{results[activity.id]?.userAnswer ? (
<p className="mt-1 text-xs text-rose-600 dark:text-rose-300">
Your answer: {results[activity.id]?.userAnswer}
{t("results.yourAnswer", { answer: results[activity.id]?.userAnswer ?? "" })}
</p>
) : null}
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-300">
<span className="font-bold">Answer: </span>
{answerFor(activity)}
<span className="font-bold">{t("results.answer")} </span>
{answerFor(activity, t)}
</p>
</li>
))}
@ -145,16 +160,16 @@ export function ResultsScreen({
<div className="flex flex-col gap-2">
{missed.length > 0 ? (
<Button size="lg" onClick={onRetryMissed}>
<RefreshIcon className="size-5" /> Retry missed ones ({missed.length})
<RefreshIcon className="size-5" /> {t("results.retryMissed", { n: missed.length })}
</Button>
) : (
<Button size="lg" variant="secondary" onClick={onRestart}>
<RefreshIcon className="size-5" /> Study this deck again
<RefreshIcon className="size-5" /> {t("results.studyAgain")}
</Button>
)}
{missed.length > 0 ? (
<Button size="lg" variant="secondary" onClick={onRestart}>
<RefreshIcon className="size-5" /> Study the whole deck again
<RefreshIcon className="size-5" /> {t("results.studyWhole")}
</Button>
) : null}
<Button
@ -165,7 +180,7 @@ export function ResultsScreen({
disabled={regenerating}
>
<SparklesIcon className="size-5" />
{regenerating ? "Regenerating…" : "Regenerate deck"}
{regenerating ? t("results.regenerating") : t("results.regenerate")}
</Button>
{regenerateError ? (
<p className="text-center text-xs text-rose-600 dark:text-rose-300" role="alert">
@ -174,13 +189,13 @@ export function ResultsScreen({
) : null}
<div className="grid grid-cols-2 gap-2">
<Button variant="ghost" onClick={() => downloadDeck(deck)}>
<DownloadIcon className="size-4" /> Export JSON
<DownloadIcon className="size-4" /> {t("results.export")}
</Button>
<Link
href="/"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white/80 px-4 py-2.5 text-sm font-semibold text-frost-800 transition hover:bg-white dark:bg-white/10 dark:text-frost-100 dark:hover:bg-white/20"
>
<HomeIcon className="size-4" /> Library
<HomeIcon className="size-4" /> {t("common.library")}
</Link>
</div>
</div>
@ -189,6 +204,7 @@ export function ResultsScreen({
}
function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "frost" | "amber" }) {
const { t } = useI18n();
const radius = 52;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - Math.min(100, Math.max(0, percent)) / 100);
@ -218,7 +234,7 @@ function ScoreRing({ percent, tone }: { percent: number; tone: "emerald" | "fros
<div className="absolute flex flex-col items-center">
<span className="text-3xl font-extrabold text-frost-950 dark:text-frost-50">{percent}%</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-frost-500 dark:text-frost-400">
score
{t("results.score")}
</span>
</div>
</div>

View file

@ -3,6 +3,7 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { LOCALES, useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { clearApiKey, saveSettings } from "@/lib/settings";
@ -13,6 +14,7 @@ import { Button, Spinner } from "@/components/ui";
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { health, loading, error, refresh, apiKey } = useHealth();
const installAvailable = useInstallAvailable();
const { locale, setLocale, t } = useI18n();
const [keyDraft, setKeyDraft] = useState(apiKey);
const [reveal, setReveal] = useState(false);
const [saved, setSaved] = useState(false);
@ -66,40 +68,63 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
Settings
{t("settings.title")}
</h2>
<p className="text-sm text-frost-600 dark:text-frost-300">
Models come from <code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code> on
the server; your API key stays in this browser.
{t("settings.subtitle.before")}{" "}
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
{t("settings.subtitle.after")}
</p>
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close settings">
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("settings.close")}>
✕
</Button>
</div>
{/* --------------------------------------------- language */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("language.label")}
</h3>
<div className="segmented" role="group" aria-label={t("language.label")}>
{LOCALES.map((entry) => (
<button
key={entry.id}
type="button"
aria-pressed={locale === entry.id}
onClick={() => setLocale(entry.id)}
>
{entry.label}
</button>
))}
</div>
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
{t("language.note")}
</p>
</section>
{/* ------------------------------------------------ API key */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">Ollama API key</h3>
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.apiKey")}</h3>
{apiKey ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-bold text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200">
saved in this browser
{t("settings.savedInBrowser")}
</span>
) : needsKeyMode ? (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-500/15 dark:text-amber-200">
required
{t("settings.required")}
</span>
) : (
<span className="rounded-full bg-[var(--surface-sunken)] px-2 py-0.5 text-[11px] font-semibold text-frost-600 dark:text-frost-300">
not needed
{t("settings.notNeeded")}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="sr-only" htmlFor="ollama-api-key">
Ollama API key
{t("settings.apiKey")}
</label>
<input
id="ollama-api-key"
@ -110,38 +135,37 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
event.stopPropagation();
if (event.key === "Enter") save();
}}
placeholder="paste your key from ollama.com/settings/keys"
placeholder={t("settings.pasteKey")}
autoComplete="off"
spellCheck={false}
className="min-w-40 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-frost-400 focus:border-accent-500 dark:text-frost-50"
/>
<Button size="sm" variant="secondary" onClick={() => setReveal((value) => !value)}>
{reveal ? "Hide" : "Show"}
{reveal ? t("settings.hide") : t("settings.show")}
</Button>
<Button size="sm" onClick={save} disabled={keyDraft.trim() === apiKey}>
Save
{t("common.save")}
</Button>
</div>
<p className="text-[11px] leading-relaxed text-frost-500 dark:text-frost-400">
Stored only in this browser&apos;s local storage and sent with each request so the server can talk to
Ollama for you. It is never written to the server, never logged and never put in a cookie. Prefer the
local daemon? Set <code>OLLAMA_AUTH=local</code> in <code>.env.local</code> and use{" "}
<code>ollama signin</code> instead.
{t("settings.keyNote")} <code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")}{" "}
<code>.env.local</code> {t("settings.keyNoteEnd")} <code>ollama signin</code>
{t("settings.keyNoteEnd2")}
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button size="sm" variant="secondary" onClick={() => void refresh()} loading={loading}>
Test connection
{t("settings.testConnection")}
</Button>
{apiKey ? (
<Button size="sm" variant="ghost" onClick={forget}>
Remove key from this browser
{t("settings.removeKey")}
</Button>
) : null}
{saved ? (
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">
Saved — testing…
{t("settings.savedTesting")}
</span>
) : null}
</div>
@ -166,10 +190,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
Install app
{t("settings.installApp")}
</span>
<span className="block text-[12px] text-[var(--text-subtle)]">
Add Freezmo to your home screen
{t("settings.addToHome")}
</span>
</span>
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
@ -179,7 +203,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
{loading && !health ? (
<div className="flex items-center gap-3 py-6 text-sm text-frost-600 dark:text-frost-300">
<Spinner className="size-5" /> Checking Ollama…
<Spinner className="size-5" /> {t("settings.checking")}
</div>
) : null}
@ -198,6 +222,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
}
function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"] }) {
const { t } = useI18n();
if (!health) return null;
const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose";
const toneClasses: Record<string, string> = {
@ -220,59 +245,62 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
</span>
<span>
{health.ok
? "Ollama is reachable and both models are available."
? t("health.ok")
: health.needsKey
? "Waiting for your API key."
? t("health.needsKey")
: health.keyAccepted === false
? "Ollama rejected your API key."
? t("health.rejected")
: health.reachable
? "Ollama is reachable, but a configured model is missing."
: "Ollama isn't reachable."}
? t("health.modelMissing")
: t("health.unreachable")}
{health.error ? <p className="mt-1 font-normal">{health.error}</p> : null}
</span>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm">
<Info
label="Auth"
label={t("health.auth")}
value={
health.authMode === "user-key"
? "Your own key"
? t("health.authUserKey")
: health.authMode === "server"
? "Server key (.env)"
: "Local daemon sign-in"
? t("health.authServer")
: t("health.authLocal")
}
/>
<Info label="Endpoint" value={health.baseUrl} mono />
<Info label="Mode" value={health.mode === "direct-cloud" ? "Direct cloud API" : "Local daemon"} />
<Info label={t("health.endpoint")} value={health.baseUrl} mono />
<Info
label="Key"
label={t("health.mode")}
value={health.mode === "direct-cloud" ? t("health.modeCloud") : t("health.modeLocal")}
/>
<Info
label={t("health.key")}
value={
health.needsKey
? "missing"
? t("health.keyMissing")
: health.keyAccepted === true
? "accepted"
? t("health.keyAccepted")
: health.keyAccepted === false
? "rejected"
? t("health.keyRejected")
: health.authMode === "local"
? "not needed"
: "unknown"
? t("health.keyNotNeeded")
: t("health.keyUnknown")
}
/>
</dl>
<div className="space-y-2">
<ModelRow title="Vision model (reads photos)" model={health.vision} />
<ModelRow title="Text model (builds & grades)" model={health.text} />
<ModelRow title={t("health.visionModel")} model={health.vision} />
<ModelRow title={t("health.textModel")} model={health.text} />
</div>
{health.mode === "direct-cloud" && health.needsKey ? (
<p className="rounded-2xl bg-[var(--surface-sunken)] p-3 text-xs text-frost-700 dark:text-frost-200">
Create a key at{" "}
{t("health.createKey.before")}{" "}
<a className="font-semibold underline underline-offset-2" href="https://ollama.com/settings/keys" target="_blank" rel="noreferrer">
ollama.com/settings/keys
</a>{" "}
and paste it above. Tip: with the local daemon you can run <code>ollama signin</code> instead and set{" "}
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
<code>OLLAMA_AUTH=local</code>.
</p>
) : null}
@ -296,12 +324,13 @@ function ModelRow({
title: string;
model: { configured: string; resolved: string; available: boolean | null; note?: string };
}) {
const { t } = useI18n();
const badge =
model.available === true
? { text: "available", className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" }
: model.available === false
? { text: "not found", className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
: { text: "unknown", className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
? { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }
: { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-frost-600 dark:text-frost-300" };
return (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
@ -313,7 +342,7 @@ function ModelRow({
</div>
<p className="mt-1 font-mono text-sm font-semibold">{model.resolved}</p>
{model.configured !== model.resolved ? (
<p className="text-xs text-frost-500 dark:text-frost-400">configured as {model.configured}</p>
<p className="text-xs text-frost-500 dark:text-frost-400">{t("health.configuredAs", { model: model.configured })}</p>
) : null}
{model.note ? <p className="mt-1 text-xs text-amber-700 dark:text-amber-300">{model.note}</p> : null}
</div>

View file

@ -16,6 +16,7 @@ import {
} from "@/components/icons";
import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage";
@ -42,6 +43,7 @@ function NavContent({
);
const selected = useSelectedFolder();
const { theme, toggle } = useTheme();
const { t } = useI18n();
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
@ -106,7 +108,7 @@ function NavContent({
href="/"
onClick={onNavigate}
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
aria-label="Freezmo home"
aria-label={t("header.home")}
title="Freezmo"
>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
@ -124,8 +126,8 @@ function NavContent({
<button
type="button"
onClick={() => sidebarCollapse.set(true)}
aria-label="Collapse sidebar"
title="Collapse sidebar"
aria-label={t("sidebar.collapse")}
title={t("sidebar.collapse")}
className="grid size-8 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<ChevronLeftIcon className="size-4" />
@ -138,10 +140,10 @@ function NavContent({
href="/create"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title="New deck"
title={t("common.newDeck")}
>
<PlusIcon className="size-4 shrink-0" />
{!collapsed ? <span className="flex-1 truncate text-left">New deck</span> : null}
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
</Link>
{/* folders --------------------------------------------------------- */}
@ -149,13 +151,13 @@ function NavContent({
{!desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
Folders
{t("common.folders")}
</span>
<button
type="button"
onClick={() => setCreating(true)}
aria-label="New folder"
title="New folder"
aria-label={t("common.newFolder")}
title={t("common.newFolder")}
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
@ -164,15 +166,15 @@ function NavContent({
) : null}
<nav
aria-label={desktop ? "Library" : "Folders"}
aria-label={desktop ? t("sidebar.library") : t("common.folders")}
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
>
{collapsed ? (
<button
type="button"
onClick={() => sidebarCollapse.set(false)}
aria-label="Expand sidebar"
title="Expand sidebar"
aria-label={t("sidebar.expand")}
title={t("sidebar.expand")}
className="nav-row justify-center px-2"
>
<ChevronRightIcon className="size-4" />
@ -185,13 +187,13 @@ function NavContent({
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
"All decks",
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
"Unorganized",
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
@ -200,13 +202,13 @@ function NavContent({
<>
{viewRow(
ALL_FOLDERS,
"All decks",
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
"Unorganized",
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
count(counts[UNORGANIZED_ID] ?? 0),
)}
@ -217,13 +219,13 @@ function NavContent({
{desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1 pt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
Folders
{t("common.folders")}
</span>
<button
type="button"
onClick={() => setCreating(true)}
aria-label="New folder"
title="New folder"
aria-label={t("common.newFolder")}
title={t("common.newFolder")}
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
@ -261,13 +263,13 @@ function NavContent({
setError(null);
}
}}
placeholder="Folder name"
aria-label="New folder name"
placeholder={t("common.folderName")}
aria-label={t("common.newFolderName")}
maxLength={60}
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[13px] text-[var(--text)] outline-none focus:border-[var(--text-subtle)]"
/>
<button type="button" onClick={submit} className="btn-primary h-8 px-3 text-[12px]">
Add
{t("common.add")}
</button>
</div>
) : null}
@ -280,16 +282,16 @@ function NavContent({
<button
type="button"
onClick={openSettings}
title="Settings"
title={t("common.settings")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<GearIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">Settings</span> : null}
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.settings")}</span> : null}
</button>
<button
type="button"
onClick={toggle}
title={theme === "dark" ? "Light mode" : "Dark mode"}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
{theme === "dark" ? (
@ -298,7 +300,9 @@ function NavContent({
<MoonIcon className="size-4 shrink-0 opacity-70" />
)}
{!collapsed ? (
<span className="flex-1 truncate text-left">{theme === "dark" ? "Light mode" : "Dark mode"}</span>
<span className="flex-1 truncate text-left">
{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
</span>
) : null}
</button>
</div>
@ -357,10 +361,11 @@ function useCollapsed(): boolean {
export function Sidebar() {
const isCollapsed = useCollapsed();
const { t } = useI18n();
return (
<aside
aria-label="Sidebar"
aria-label={t("sidebar.label")}
className={cn(
"fixed inset-y-0 left-0 z-40 hidden p-3 transition-[width] duration-200 lg:flex",
isCollapsed ? "w-[84px]" : "w-[268px]",
@ -376,6 +381,7 @@ export function Sidebar() {
export function MobileSidebar() {
const open = useSidebarOpen();
const { t } = useI18n();
return (
<AnimatePresence>
@ -388,12 +394,12 @@ export function MobileSidebar() {
>
<button
type="button"
aria-label="Close menu"
aria-label={t("sidebar.closeMenu")}
onClick={() => sidebarOpen.set(false)}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
/>
<motion.aside
aria-label="Menu"
aria-label={t("common.menu")}
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}

View file

@ -8,6 +8,7 @@ import { ActivityRenderer } from "@/components/activities";
import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api";
import { materialiseActivities } from "@/lib/deck-ops";
import { useI18n } from "@/lib/i18n";
import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage";
import { cn } from "@/lib/utils";
@ -19,6 +20,7 @@ import { ResultsScreen } from "./ResultsScreen";
export type AttemptResult = { correct: boolean; userAnswer?: string };
export function StudyFeed({ deck }: { deck: Deck }) {
const { t } = useI18n();
const [currentDeck, setCurrentDeck] = useState(deck);
const [queue, setQueue] = useState<Activity[]>(deck.activities);
const [index, setIndex] = useState(0);
@ -168,7 +170,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
if (regenerating) return;
const source = currentDeck.sourceText?.trim();
if (!source) {
setRegenerateError("This deck has no stored source notes, so it can't be regenerated.");
setRegenerateError(t("deck.noSourceRegenerate"));
return;
}
setRegenerating(true);
@ -196,11 +198,11 @@ export function StudyFeed({ deck }: { deck: Deck }) {
setCurrentDeck(updated);
restart(activities, false);
} catch (error) {
setRegenerateError(error instanceof ApiError ? error.message : "Could not regenerate the deck.");
setRegenerateError(error instanceof ApiError ? error.message : t("deck.couldNotRegenerate"));
} finally {
setRegenerating(false);
}
}, [currentDeck, regenerating, restart]);
}, [currentDeck, regenerating, restart, t]);
const progressValue = Math.min(index + 1, slideCount);
const progressMax = slideCount;
@ -213,7 +215,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<div className="flex items-center gap-3">
<Link
href="/"
aria-label="Back to your library"
aria-label={t("feed.back")}
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
@ -222,7 +224,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<ProgressBar
value={progressValue}
max={progressMax}
label={`Activity ${progressValue} of ${progressMax}`}
label={t("feed.activityOf", { n: progressValue, total: progressMax })}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
@ -233,16 +235,16 @@ export function StudyFeed({ deck }: { deck: Deck }) {
? "tint-peach"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
title={`Answer streak: ${streak}`}
aria-label={`Answer streak: ${streak}`}
title={t("feed.streak", { n: streak })}
aria-label={t("feed.streak", { n: streak })}
>
<FlameIcon className="size-3.5" />
{streak}
</span>
<span
className="inline-flex items-center gap-1 rounded-xl bg-[var(--surface-sunken)] px-2 py-1 text-xs font-extrabold text-[var(--text-muted)]"
title={`XP earned: ${xp}`}
aria-label={`XP earned: ${xp}`}
title={t("feed.xp", { n: xp })}
aria-label={t("feed.xp", { n: xp })}
>
<BoltIcon className="size-3.5" />
{xp}
@ -252,7 +254,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
<span className="min-w-0 truncate">{currentDeck.title}</span>
<span className="shrink-0">
{retryMode ? "Retry · " : ""}
{retryMode ? t("feed.retry") : ""}
{Math.min(index + 1, total)}/{total}
</span>
</div>
@ -268,7 +270,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{queue.map((activity, activityIndex) => (
<section
key={`${activity.id}-${runId}`}
aria-label={`Activity ${activityIndex + 1} of ${total}`}
aria-label={t("feed.activityOf", { n: activityIndex + 1, total })}
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
@ -285,7 +287,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
))}
<section
aria-label="Results"
aria-label={t("feed.results")}
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
>
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
@ -318,7 +320,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
className="pointer-events-none absolute inset-x-0 bottom-6 z-20 flex justify-center sm:hidden"
>
<span className="rounded-full bg-frost-950/70 px-4 py-2 text-xs font-semibold text-white backdrop-blur">
Swipe up for the next activity ↑
{t("feed.swipeUp")}
</span>
</motion.div>
) : null}
@ -327,13 +329,13 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{/* Desktop next/prev buttons --------------------------------- */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden justify-center pb-4 sm:flex">
<div className="study-chrome pointer-events-auto flex items-center gap-2 rounded-full px-2 py-1.5">
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="Previous activity">
<Button variant="ghost" size="sm" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label={t("feed.prev")}>
↑
</Button>
<span className="px-2 text-xs font-semibold text-[var(--text)]">
{index + 1} / {slideCount}
</span>
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label="Next activity">
<Button variant="ghost" size="sm" onClick={() => goTo(index + 1)} disabled={index >= slideCount - 1} aria-label={t("feed.next")}>
↓
</Button>
</div>

View file

@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
@ -14,6 +15,7 @@ import { cn } from "@/lib/utils";
*/
export function TabBar() {
const pathname = usePathname();
const { t } = useI18n();
const item = "flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
const active = "text-[var(--text)]";
@ -21,12 +23,12 @@ export function TabBar() {
return (
<nav
aria-label="Main"
aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
>
<Link href="/" aria-current={pathname === "/" ? "page" : undefined} className={cn(item, pathname === "/" ? active : idle)}>
<HomeIcon className="size-6" />
Library
{t("common.library")}
</Link>
<Link
href="/create"
@ -36,11 +38,11 @@ export function TabBar() {
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>
New deck
{t("common.newDeck")}
</Link>
<button type="button" onClick={openSettings} className={cn(item, idle)}>
<GearIcon className="size-6" />
Settings
{t("common.settings")}
</button>
</nav>
);

View file

@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { MoonIcon, SunIcon } from "@/components/icons";
@ -33,14 +34,15 @@ export function useTheme() {
export function ThemeToggle({ className }: { className?: string }) {
const { theme, toggle } = useTheme();
const { t } = useI18n();
const dark = theme === "dark";
return (
<button
type="button"
onClick={toggle}
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
title={dark ? "Light mode" : "Dark mode"}
aria-label={dark ? t("common.switchToLight") : t("common.switchToDark")}
title={dark ? t("common.lightMode") : t("common.darkMode")}
className={cn(
"inline-flex size-9 items-center justify-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
className,

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>

View file

@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -67,6 +68,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
}
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
const { t } = useI18n();
const [value, setValue] = useState("");
const [chosen, setChosen] = useState<string | null>(null);
const [result, setResult] = useState<boolean | null>(null);
@ -121,8 +123,8 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
event.stopPropagation();
}}
disabled={result !== null}
aria-label="Type the missing word"
placeholder="type it"
aria-label={t("fill.typeMissing")}
placeholder={t("fill.placeholder")}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
@ -142,7 +144,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
</PromptCard>
{activity.options ? (
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label={t("activity.wordBank")}>
{activity.options.map((option) => {
const state =
result === null
@ -168,13 +170,13 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
) : result === null ? (
<div className="flex gap-2">
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
Check answer
{t("fill.check")}
</Button>
</div>
) : null}
{result === null && !activity.options ? (
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
<Hint>{t("fill.hint")}</Hint>
) : null}
{result !== null ? (
@ -186,11 +188,11 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
onNext={onNext}
detail={
<span>
Expected: <span className="font-bold">{activity.answer}</span>
{t("fill.expected")} <span className="font-bold">{activity.answer}</span>
{!result && chosen ? (
<>
{" "}
— you wrote <span className="italic">{chosen}</span>
{t("fill.youWrote")} <span className="italic">{chosen}</span>
</>
) : null}
</span>

View file

@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { Button } from "@/components/ui";
@ -18,6 +19,7 @@ import {
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
const { t } = useI18n();
const [flipped, setFlipped] = useState(false);
const [result, setResult] = useState<boolean | null>(null);
@ -30,13 +32,13 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
return (
<>
<ActivityHeader type="flashcard" topic={activity.topic} />
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</Hint>
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
<button
type="button"
onClick={() => setFlipped((value) => !value)}
className="group relative block w-full text-left [perspective:1400px]"
aria-label={flipped ? "Flip back to the question" : "Reveal the answer"}
aria-label={flipped ? t("flashcard.flipBack") : t("flashcard.reveal")}
>
<motion.div
animate={{ rotateX: flipped ? 180 : 0 }}
@ -45,18 +47,18 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
>
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
Question
{t("flashcard.question")}
</span>
<QuestionText>{activity.front}</QuestionText>
<span className="mt-2 text-xs font-semibold text-frost-400 dark:text-frost-400">
tap to flip ↻
{t("flashcard.tapFlip")}
</span>
</PromptCard>
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
Answer
{t("flashcard.answer")}
</span>
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
{activity.back}
@ -69,10 +71,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
{flipped && result === null ? (
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
😅 Not yet
{t("flashcard.notYet")}
</Button>
<Button variant="success" size="lg" onClick={() => settle(true)}>
🧊 I knew it
{t("flashcard.knewIt")}
</Button>
</div>
) : null}
@ -83,7 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
headline={result ? "Frozen solid!" : "Keep it on the pile"}
headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")}
onNext={onNext}
/>
) : null}

View file

@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
@ -16,6 +17,7 @@ import {
type MatchingActivity = Extract<Activity, { type: "matching" }>;
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
const { t, plural } = useI18n();
const terms = useMemo(
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
[activity],
@ -60,7 +62,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
topic={activity.topic}
extra={
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
{matched.length}/{total} matched
{t("matching.matched", { n: matched.length, total })}
</span>
}
/>
@ -68,10 +70,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
<Hint>
{done
? "All matched!"
? t("matching.allMatched")
: selectedTerm === null
? "Tap a term on the left, then its match on the right."
: "Now tap the matching definition →"}
? t("matching.tapTerm")
: t("matching.tapDef")}
</Hint>
</PromptCard>
@ -136,12 +138,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
xp={activity.xp}
isLast={isLast}
onNext={onNext}
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
detail={
<span>
{misses === 0
? "You matched everything first try."
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
{misses === 0 ? t("matching.firstTry") : plural(misses, "matching.wrongAttempts")}
</span>
}
/>

View file

@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import {
@ -23,6 +24,7 @@ export function MultipleChoice({
onNext,
isLast,
}: ActivityComponentProps<MultipleChoiceActivity>) {
const { t } = useI18n();
const [selected, setSelected] = useState<number | null>(null);
const answered = selected !== null;
@ -62,7 +64,7 @@ export function MultipleChoice({
<QuestionText>{activity.question}</QuestionText>
</PromptCard>
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
<div className="flex flex-col gap-2.5" role="group" aria-label={t("activity.answerOptions")}>
{activity.options.map((option, index) => (
<OptionTile
key={option + index}
@ -76,7 +78,7 @@ export function MultipleChoice({
))}
</div>
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
{!answered ? <Hint>{t("mc.tipKeys")}</Hint> : null}
{answered ? (
<FeedbackPanel
@ -88,7 +90,7 @@ export function MultipleChoice({
detail={
selected === activity.correctIndex ? null : (
<>
Correct answer:{" "}
{t("activity.correctAnswer")}{" "}
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
</>
)

View file

@ -3,6 +3,7 @@
import { Reorder } from "framer-motion";
import { useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -18,6 +19,7 @@ import {
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
const { t } = useI18n();
const initial = useMemo(() => shuffle(activity.items), [activity]);
const [items, setItems] = useState<string[]>(initial);
const [checked, setChecked] = useState(false);
@ -48,7 +50,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
<PromptCard className="py-4">
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
<Hint>
{checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
</Hint>
</PromptCard>
@ -89,7 +91,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
<span className="flex flex-col">
<button
type="button"
aria-label={`Move "${item}" up`}
aria-label={t("ordering.moveUp", { item })}
onClick={() => move(index, -1)}
disabled={index === 0}
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
@ -98,7 +100,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
</button>
<button
type="button"
aria-label={`Move "${item}" down`}
aria-label={t("ordering.moveDown", { item })}
onClick={() => move(index, 1)}
disabled={index === items.length - 1}
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
@ -116,7 +118,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
{!checked ? (
<Button size="lg" onClick={check} disabled={items.length < 2}>
Check order
{t("ordering.check")}
</Button>
) : (
<FeedbackPanel

View file

@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, gradeAnswer } from "@/lib/client-api";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button, Spinner } from "@/components/ui";
@ -26,6 +27,7 @@ type GradeState =
| { status: "self-check" };
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
const { t } = useI18n();
const [value, setValue] = useState("");
const [state, setState] = useState<GradeState>({ status: "idle" });
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -61,13 +63,13 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
message:
error instanceof ApiError
? error.message
: "Grading failed. Check that Ollama is reachable and try again.",
: t("short.gradeFailed"),
});
}
};
const selfCheck = (correct: boolean) => {
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
setState({ status: "graded", correct, feedback: t("short.selfChecked"), missing: [] });
onAnswered(correct, { userAnswer: value.trim() });
};
@ -79,7 +81,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
</PromptCard>
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
Your answer
{t("short.yourAnswer")}
</label>
<textarea
id={`answer-${activity.id}`}
@ -95,15 +97,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
}}
disabled={state.status === "grading" || state.status === "graded"}
rows={3}
placeholder="Type your answer in your own words…"
placeholder={t("short.placeholder")}
className="w-full resize-none rounded-2xl border-2 border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-frost-400 focus:border-[var(--focus)] disabled:opacity-70 dark:border-[var(--glass-border)] dark:bg-[var(--surface-strong)] dark:text-frost-50 dark:placeholder:text-frost-400"
/>
{state.status === "idle" ? (
<div className="flex items-center justify-between gap-3">
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
<Hint>{t("short.ctrlEnter")}</Hint>
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
Submit answer
{t("short.submit")}
</Button>
</div>
) : null}
@ -111,20 +113,20 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{state.status === "grading" ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
<Spinner className="size-5" />
Grading your answer…
{t("short.grading")}
</div>
) : null}
{state.status === "error" ? (
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-bold">Couldn&apos;t grade that answer</p>
<p className="font-bold">{t("short.couldNotGrade")}</p>
<p className="mt-1">{state.message}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => void grade()}>
Retry
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
Show answer &amp; self-check
{t("short.showAnswer")}
</Button>
</div>
</div>
@ -132,15 +134,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{state.status === "self-check" ? (
<div className="card space-y-3 p-4">
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">Model answer</p>
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">{t("short.modelAnswer")}</p>
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
<p className="text-xs text-frost-500 dark:text-frost-400">Key points: {activity.keyPoints.join(" · ")}</p>
<p className="text-xs text-frost-500 dark:text-frost-400">{t("short.keyPoints")} {activity.keyPoints.join(" · ")}</p>
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" onClick={() => selfCheck(false)}>
😅 I missed it
{t("short.missedIt")}
</Button>
<Button variant="success" onClick={() => selfCheck(true)}>
🧊 I had it right
{t("short.hadRight")}
</Button>
</div>
</div>
@ -157,7 +159,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
)}
>
<p className="font-extrabold uppercase tracking-wide">
{state.correct ? "Accepted" : "Needs work"}
{state.correct ? t("short.accepted") : t("short.needsWork")}
</p>
<p className="mt-1 font-normal">{state.feedback}</p>
{state.missing.length ? (
@ -169,7 +171,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
) : null}
{!state.correct ? (
<p className="mt-2 font-normal">
<span className="font-bold">Model answer: </span>
<span className="font-bold">{t("short.modelAnswer")}: </span>
{activity.referenceAnswer}
</p>
) : null}

View file

@ -3,6 +3,7 @@
import { motion, useMotionValue, useTransform } from "framer-motion";
import { useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { Button } from "@/components/ui";
@ -17,6 +18,7 @@ import {
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
const { t } = useI18n();
const [result, setResult] = useState<boolean | null>(null);
const x = useMotionValue(0);
// The drag is elastic, so the card moves less than the pointer — these
@ -37,7 +39,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
<>
<ActivityHeader type="true_false" topic={activity.topic} />
<Hint>
{result === null ? "Swipe the card right for true, left for false — or tap a button." : "Nice swipe!"}
{result === null ? t("tf.hint") : t("tf.niceSwipe")}
</Hint>
<div className="relative">
@ -55,10 +57,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
>
<PromptCard className="relative min-h-44 select-none">
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
✓ true
{t("tf.trueLabel")}
</motion.span>
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
false ✕
{t("tf.falseLabel")}
</motion.span>
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
“{activity.statement}”
@ -70,10 +72,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
{result === null ? (
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
✕ False
{t("tf.falseBtn")}
</Button>
<Button variant="success" size="lg" onClick={() => answer(true)}>
✓ True
{t("tf.trueBtn")}
</Button>
</div>
) : (
@ -85,7 +87,8 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
onNext={onNext}
detail={
<span>
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
{t("tf.statementIs.before")}{" "}
<span className="font-bold">{activity.answer ? t("tf.true") : t("tf.false")}</span>.
</span>
}
/>

View file

@ -7,6 +7,7 @@ import { MultipleChoice } from "./MultipleChoice";
import { Ordering } from "./Ordering";
import { ShortAnswer } from "./ShortAnswer";
import { TrueFalse } from "./TrueFalse";
import { t } from "@/lib/i18n";
import { type ActivityComponentProps } from "./shared";
export type { ActivityComponentProps } from "./shared";
@ -38,7 +39,7 @@ export function ActivityRenderer(props: ActivityComponentProps) {
// Exhaustiveness guard: adding a new activity type without a component
// becomes a TypeScript error.
const never: never = activity;
return <p className="text-sm">Unsupported activity: {JSON.stringify(never)}</p>;
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
}
}
}

View file

@ -2,6 +2,7 @@
import { motion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -19,14 +20,14 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
isLast: boolean;
};
export const TYPE_META: Record<Activity["type"], { label: string; icon: string; tint: string }> = {
flashcard: { label: "Flashcard", icon: "🃏", tint: "tint-sky" },
multiple_choice: { label: "Multiple choice", icon: "🔘", tint: "tint-lilac" },
true_false: { label: "True or false", icon: "⚖️", tint: "tint-mint" },
fill_blank: { label: "Fill the blank", icon: "✏️", tint: "tint-peach" },
matching: { label: "Match up", icon: "🧲", tint: "tint-sky" },
ordering: { label: "Put in order", icon: "🔢", tint: "tint-lilac" },
short_answer: { label: "Short answer", icon: "💬", tint: "tint-rose" },
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
flashcard: { labelKey: "type.flashcard", icon: "🃏", tint: "tint-sky" },
multiple_choice: { labelKey: "type.multipleChoice", icon: "🔘", tint: "tint-lilac" },
true_false: { labelKey: "type.trueFalse", icon: "⚖️", tint: "tint-mint" },
fill_blank: { labelKey: "type.fillBlank", icon: "✏️", tint: "tint-peach" },
matching: { labelKey: "type.matching", icon: "🧲", tint: "tint-sky" },
ordering: { labelKey: "type.ordering", icon: "🔢", tint: "tint-lilac" },
short_answer: { labelKey: "type.shortAnswer", icon: "💬", tint: "tint-rose" },
};
export function ActivityHeader({
type,
@ -37,6 +38,7 @@ export function ActivityHeader({
topic?: string;
extra?: React.ReactNode;
}) {
const { t } = useI18n();
const meta = TYPE_META[type];
return (
<div className="flex flex-wrap items-center gap-2">
@ -47,7 +49,7 @@ export function ActivityHeader({
)}
>
<span aria-hidden>{meta.icon}</span>
{meta.label}
{t(meta.labelKey)}
</span>
{topic ? (
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
@ -144,6 +146,7 @@ export function FeedbackPanel({
detail?: React.ReactNode;
children?: React.ReactNode;
}) {
const { t } = useI18n();
return (
<motion.div
initial={{ opacity: 0, y: 18 }}
@ -166,7 +169,7 @@ export function FeedbackPanel({
)}
>
<span aria-hidden>{correct ? "✅" : "💧"}</span>
{headline ?? (correct ? "Nice one!" : "Not quite")}
{headline ?? (correct ? t("activity.niceOne") : t("activity.notQuite"))}
</p>
{correct && xp ? (
<motion.span
@ -182,7 +185,7 @@ export function FeedbackPanel({
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
<span className="font-bold">Why: </span>
<span className="font-bold">{t("activity.why")} </span>
{explanation}
</p>
@ -190,7 +193,7 @@ export function FeedbackPanel({
<div className="mt-4 flex items-center justify-end">
<Button onClick={onNext} size="md" className="min-w-28">
{isLast ? "See results" : "Next"}
{isLast ? t("activity.seeResults") : t("activity.next")}
<span aria-hidden>→</span>
</Button>
</div>