diff --git a/README.md b/README.md index 095ed1c..ef51f94 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,10 @@ no database, no data leaving your machine except the requests you send to Ollama restores it before any activities are written — the UI shows *“Restoring the notes' language…”*. On good transcriptions the guard stays silent and costs nothing. - The deck stores its language, so short answers are graded and feedback written in the same language. -- The Freezmo interface itself stays English (only your content is language-matched). +- The **Freezmo interface** has its own language switch: **English (default)** and a full **Czech** + translation, picked in **Settings → Language** or from the `EN`/`CS` button on the key gate. The + chosen locale is remembered per browser and also decides the language of server messages (API + errors, health notes), so nothing falls back to English mid-sentence. **Study feed** @@ -123,7 +126,7 @@ no database, no data leaving your machine except the requests you send to Ollama - Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu - **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over - a faint viewport-fixed aurora wash (sky/lilac/mint) — depth without noise + the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise - **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose), correct/wrong feedback, key status @@ -839,6 +842,16 @@ Things I chose while building, with the reasoning: soft radial gradients with no filter — visually the same, 4-5x cheaper — phones get two blobs instead of four and a 16px (instead of 24px) glass blur, and nested glass never blurs. Result: 60fps idle and ~17ms median frames while scrolling under 4x throttle. +33. **Interface copy lives in two flat dictionaries, not in a framework.** `lib/i18n-dict.ts` holds + the `en` and `cs` tables plus `translate()` / `translatePlural()`; `lib/i18n.ts` adds the browser + store (`freezmo.locale.v1`), the `useI18n()` hook and the `x-freezmo-locale` header that API + routes read to answer in the same language. English is the default and the fallback for any + missing key. Czech uses the real plural rules (1 sada / 2–4 sady / 5+ sad). Crucially, the hook's + `t` is *bound to the locale React is rendering with* — the first client render matches the + server HTML, then re-renders in the stored language; using the module-level `t` during render + instead caused a hydration mismatch that silently dropped the `dark` class. Module-level `t` is + only for event handlers and non-React helpers (documents, folders, API errors). The notes' own + language is untouched by this — decks still come back in the language of your notes. 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 4eeea45..7c5e9e3 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai"; -import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; +import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; -import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { translate } from "@/lib/i18n-dict"; import { difficultySchema } from "@/lib/schemas"; import { uid } from "@/lib/utils"; import { xpForActivity } from "@/lib/xp"; @@ -48,6 +49,7 @@ function encoder() { const MAX_BODY_BYTES = 33554432; export async function POST(request: Request) { + const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); @@ -57,12 +59,12 @@ export async function POST(request: Request) { // Cheap requests are rejected before any work happens. const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs); - if (!limit.allowed) return rateLimitResponse(limit); + if (!limit.allowed) return rateLimitResponse(limit, locale); const auth = resolveAuth(config, request); if ("error" in auth) { return Response.json( - { error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) }, + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, { status: 401 }, ); } @@ -71,17 +73,19 @@ export async function POST(request: Request) { try { payload = await request.json(); } catch { - return Response.json({ error: "Request body was not valid JSON." }, { status: 400 }); + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); } const parsed = requestSchema.safeParse(payload); if (!parsed.success) { return Response.json( { - error: `Invalid request: ${parsed.error.issues - .slice(0, 3) - .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) - .join("; ")}`, + error: translate(locale, "server.invalidRequest", { + detail: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; "), + }), }, { status: 400 }, ); @@ -92,7 +96,7 @@ export async function POST(request: Request) { if (imageList.length === 0 && !text?.trim()) { return Response.json( - { error: "Add at least one photo of your notes or paste some text to build a deck." }, + { error: translate(locale, "server.needContent") }, { status: 400 }, ); } @@ -187,7 +191,7 @@ export async function POST(request: Request) { }, }); } catch (error) { - const message = friendlyErrorMessage(error, auth.auth.baseUrl); + const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale); // Never log the request body: it may contain the user's notes. console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); send({ diff --git a/app/api/grade/route.ts b/app/api/grade/route.ts index ff8d0b0..a04c46c 100644 --- a/app/api/grade/route.ts +++ b/app/api/grade/route.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { gradeShortAnswer } from "@/lib/ai"; -import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; +import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; -import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -22,6 +23,7 @@ const requestSchema = z.object({ const MAX_BODY_BYTES = 65536; export async function POST(request: Request) { + const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); @@ -30,12 +32,12 @@ export async function POST(request: Request) { const config = getServerConfig(); const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs); - if (!limit.allowed) return rateLimitResponse(limit); + if (!limit.allowed) return rateLimitResponse(limit, locale); const auth = resolveAuth(config, request); if ("error" in auth) { return Response.json( - { error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) }, + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, { status: 401 }, ); } @@ -44,12 +46,12 @@ export async function POST(request: Request) { try { payload = await request.json(); } catch { - return Response.json({ error: "Request body was not valid JSON." }, { status: 400 }); + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); } const parsed = requestSchema.safeParse(payload); if (!parsed.success) { - return Response.json({ error: "Invalid grading request." }, { status: 400 }); + return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 }); } try { @@ -64,7 +66,10 @@ export async function POST(request: Request) { } catch (error) { console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); return Response.json( - { error: friendlyErrorMessage(error, auth.auth.baseUrl), kind: error instanceof OllamaError ? error.kind : "unknown" }, + { + error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), + kind: error instanceof OllamaError ? error.kind : "unknown", + }, { status: 502 }, ); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 6579feb..1f33716 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,6 +1,8 @@ -import { AUTH_ERROR_MESSAGES, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config"; +import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config"; import { friendlyErrorMessage, ollamaListModels, ollamaVerifyKey, OllamaError } from "@/lib/ollama"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { requestLocale } from "@/lib/http-guard"; +import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -33,10 +35,11 @@ export type HealthResponse = { }; export async function GET(request: Request) { + const locale = requestLocale(request); const config = getServerConfig(); const limit = rateLimit(`health:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs); - if (!limit.allowed) return rateLimitResponse(limit); + if (!limit.allowed) return rateLimitResponse(limit, locale); const started = Date.now(); const resolvedAuth = resolveAuth(config, request); @@ -69,8 +72,8 @@ export async function GET(request: Request) { if ("error" in resolvedAuth) { health.error = resolvedAuth.error === "missing_key" - ? "No Ollama API key yet. Add your key in the settings dialog — it is stored only in this browser." - : AUTH_ERROR_MESSAGES[resolvedAuth.error]; + ? translate(locale, "server.healthNoKey") + : authErrorMessage(locale, resolvedAuth.error); health.kind = authErrorKind(resolvedAuth.error); return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } }); } @@ -87,12 +90,12 @@ export async function GET(request: Request) { const probe = await ollamaVerifyKey(resolvedAuth.auth, config.textModel, config.healthTimeoutMs * 3); health.keyAccepted = probe.valid; if (probe.valid === false) { - health.error = "Ollama rejected this API key. Create a new one at ollama.com/settings/keys and paste it again."; + health.error = translate(locale, "server.healthKeyRejected"); health.kind = "auth"; return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } }); } if (probe.reason === "no_credits") { - health.error = `The key works, but ${config.textModel} is not included in this account's plan. Add usage credits or switch to a free-tier model in .env.local.`; + health.error = translate(locale, "server.healthNoCredits", { model: config.textModel }); health.kind = "no_credits"; } } @@ -106,17 +109,17 @@ export async function GET(request: Request) { if (!listed) { entry.note = health.mode === "local-daemon" - ? `Not in the daemon's model list. Run \`ollama pull ${entry.resolved}\` or check your sign-in.` - : `Not returned by ${config.baseUrl}/api/tags. Check the model name and your key's plan.`; + ? translate(locale, "server.healthModelNoteLocal", { model: entry.resolved }) + : translate(locale, "server.healthModelNoteCloud", { url: config.baseUrl }); } } health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false); if (!health.ok && !health.error) { - health.error = "Ollama is reachable, but at least one configured model is not available."; + health.error = translate(locale, "server.healthModelsMissing"); } } catch (error) { - health.error = friendlyErrorMessage(error, config.baseUrl); + health.error = friendlyErrorMessage(error, config.baseUrl, locale); health.kind = error instanceof OllamaError ? error.kind : "unknown"; if (error instanceof OllamaError && (error.kind === "auth" || error.kind === "http")) { health.keyAccepted = false; diff --git a/app/deck/[id]/page.tsx b/app/deck/[id]/page.tsx index 4e6d953..c2cf11e 100644 --- a/app/deck/[id]/page.tsx +++ b/app/deck/[id]/page.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { ApiKeyGate } from "@/components/ApiKeyGate"; import { StudyFeed } from "@/components/StudyFeed"; import { Button, Spinner } from "@/components/ui"; +import { useI18n } from "@/lib/i18n"; import type { Deck } from "@/lib/schemas"; import { getDeck } from "@/lib/storage"; @@ -15,6 +16,7 @@ export default function DeckPage() { const id = typeof params.id === "string" ? params.id : ""; const [deck, setDeck] = useState(null); const [loaded, setLoaded] = useState(false); + const { t } = useI18n(); useEffect(() => { if (!id) { @@ -29,7 +31,7 @@ export default function DeckPage() { return (
- Loading your deck… + {t("deckPage.loading")}
); @@ -42,20 +44,19 @@ export default function DeckPage() {

🧊

-

Deck not found

+

{t("deckPage.notFound")}

- This deck isn't in this browser's library. It may have been deleted, or it was - created in a different browser profile. + {t("deckPage.notFoundBody")}

- Back to library + {t("deckPage.back")} - +
diff --git a/app/layout.tsx b/app/layout.tsx index 978af14..7f4c904 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -56,7 +56,7 @@ export const viewport: Viewport = { }; /** Applies the saved theme before paint to avoid a flash. */ -const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`; +const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;}catch(e){}})();`; /** * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) diff --git a/app/offline/page.tsx b/app/offline/page.tsx index 6905dde..c75765e 100644 --- a/app/offline/page.tsx +++ b/app/offline/page.tsx @@ -1,18 +1,18 @@ -import { HomeIcon } from "@/components/icons"; +"use client"; -export const metadata = { title: "Offline" }; +import { HomeIcon } from "@/components/icons"; +import { useI18n } from "@/lib/i18n"; /** Shown by the service worker when a navigation can't reach the network. */ export default function OfflinePage() { + const { t } = useI18n(); + return (
-

You're offline

-

- Freezmo needs a connection for generating decks and grading answers. Your saved decks are - still in this browser — reconnect and reload. -

+

{t("offline.title")}

+

{t("offline.body")}

); diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx index 97a96bc..5643baa 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -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() { }} > {theme === "dark" ? : } + { + const index = LOCALES.findIndex((entry) => entry.id === locale); + setLocale(LOCALES[(index + 1) % LOCALES.length].id); + }} + > + + {LOCALES.find((entry) => entry.id === locale)?.short ?? "EN"} + + +
setHelpOpen(true)} > @@ -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")}
@@ -241,16 +256,16 @@ function GateScreen() { />

- Connect your Ollama key + {t("gate.title")}

- One step to freeze your first deck. + {t("gate.subtitle")}

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 ? ( <> - Checking… + {t("gate.checking")} ) : ( - "Continue" + t("gate.continue") )} @@ -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" > - How do I get a key? + {t("gate.howGet")}

- Your key stays in this browser. Our server never stores it. + {t("gate.stays")}

@@ -343,6 +358,7 @@ function GateScreen() { function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const reduceMotion = useReducedMotion(); + const { t } = useI18n(); return ( @@ -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")} diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index 0610c61..5fc6058 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -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; - + {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} {!compact ? ( - study feed + {t("header.tagline")} ) : null} @@ -70,8 +72,8 @@ export function AppHeader({ className, compact = false }: { className?: string; ) : ( @@ -158,28 +160,26 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str {formatDate(deck.updatedAt)} · - {deck.activities.length} activities + {plural(deck.activities.length, "deck.activities")} {deck.attempts > 0 ? ( <> · - {deck.attempts} {deck.attempts === 1 ? "run" : "runs"} + {plural(deck.attempts, "deck.runs")} ) : null} {/* Desktop always shows the same two fields. */} · - {deck.attempts > 0 - ? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}` - : "Not played yet"} + {deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}

{confirmDelete ? (
) : ( @@ -298,17 +309,17 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str ⌫ - Delete deck + {t("deck.delete")} )} {/* folder ---------------------------------------------------- */}

- Folder + {t("common.folder")}

- {[{ 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 ( ) : ( @@ -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")} )} @@ -387,7 +398,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str {/* add more -------------------------------------------------- */}

- Add more activities + {t("deck.addMore")}

{ADD_OPTIONS.map((option) => ( @@ -407,12 +418,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str ))}
{!hasSource ? (

- No stored source text for this deck (imported decks don't carry it). + {t("deck.noSource")}

) : null}
diff --git a/components/FolderBar.tsx b/components/FolderBar.tsx index 50470f1..ef84044 100644 --- a/components/FolderBar.tsx +++ b/components/FolderBar.tsx @@ -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(null); const inputRef = useRef(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")} {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")} ) : ( @@ -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)]" > - New folder + {t("common.newFolder")} )}
diff --git a/components/InstallSheet.tsx b/components/InstallSheet.tsx index 0f68dc7..a9c94d0 100644 --- a/components/InstallSheet.tsx +++ b/components/InstallSheet.tsx @@ -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 }>Safari + {t("install.openIn")} }>Safari , ); } steps.push( <> - Tap } label="More options" /> in the - browser bar + {t("install.tap")}{" "} + } label={t("install.moreOptions")} />{" "} + {t("install.inBrowserBar")} , ); steps.push( <> - Tap }>Share, then{" "} - }>More + {t("install.tap")} }>{t("install.share")} + {t("install.then")} }>{t("install.more")} , ); steps.push( <> - Choose }>Add to Home Screen + {t("install.choose")}{" "} + }>{t("install.addToHome")} , ); steps.push( <> - Tap }>Add to confirm + {t("install.tapConfirm")}{" "} + }>{t("install.add")}{" "} + {t("install.toConfirm")} , ); } else if (info?.platform === "android") { if (info.browser !== "chrome") { steps.push( <> - Open this page in }>Chrome + {t("install.openIn")} }>Chrome , ); } steps.push( <> - Tap } label="More options" /> in the top - right of the browser + {t("install.tap")}{" "} + } label={t("install.moreOptions")} />{" "} + {t("install.inTopRight")} , ); steps.push( <> - Choose }>Install app or{" "} - }>Add to Home screen + {t("install.choose")}{" "} + }>{t("install.installApp")}{" "} + {t("install.or")}{" "} + }>{t("install.addToHomeScreen")} , ); steps.push( <> - Tap }>Install to confirm + {t("install.tapConfirm")}{" "} + }>{t("install.install")}{" "} + {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")} @@ -306,7 +315,7 @@ export function InstallSheet() { {showNativePrompt ? (

- Or install manually: + {t("install.orManually")}

) : 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")}
diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 8f561eb..c35a7fe 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -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(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() {

- Your icebox + {t("library.title")}

- Decks frozen from your notes, saved in this browser. + {t("library.subtitle")}

@@ -124,7 +126,7 @@ export function LibraryScreen() { onClick={() => importRef.current?.click()} > - Import JSON + {t("library.import")} 0 ? (

- {decks.length} {decks.length === 1 ? "deck" : "decks"} + {plural(decks.length, "stats.decks")} · - {folderList.length} {folderList.length === 1 ? "folder" : "folders"} + {plural(folderList.length, "stats.folders")} · - {totalActivities} activities + {plural(totalActivities, "stats.activities")} {played ? ( <> · - best {bestEver}% + {t("library.stats.best", { n: bestEver })} ) : null}

@@ -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 ? ( - Make your first deck + {t("library.makeFirst")} } > - Freeze a page of notes into a feed of flashcards and quizzes. + {t("library.emptyBody")} - Using your own Ollama models —{" "} + {t("library.emptyHint")}{" "} . @@ -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")} ) : ( @@ -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")} {confirmDelete === group.id ? ( <> ) : ( @@ -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")} )}
@@ -307,22 +309,19 @@ export function LibraryScreen() { {/* One quiet label replaces the redundant single-group heading. */} {!hasFolders ? (

- All decks + {t("common.allDecks")}

) : null} {confirmDelete === group.id ? (

- The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to - Unorganized — nothing is deleted. + {plural(group.decks.length, "library.deleteWarning")}

) : null} {group.decks.length === 0 ? (
- {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")}
) : (
    @@ -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"), }); }} /> diff --git a/components/ResultsScreen.tsx b/components/ResultsScreen.tsx index 55f73a8..a25958a 100644 --- a/components/ResultsScreen.tsx +++ b/components/ResultsScreen.tsx @@ -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; + +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({ >

    - {perfect ? "Perfect freeze! 🧊" : percent >= 60 ? "Nicely done!" : "Good start — keep going!"} + {perfect ? t("results.perfect") : percent >= 60 ? t("results.nice") : t("results.start")}

    {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") : "", + })}

    - {xp} XP earned + {t("results.xpEarned", { n: xp })} - Best streak {bestStreak} + {t("results.bestStreak", { n: bestStreak })} - {[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"), + })} {deck.bestScore !== undefined && !retryMode ? ( - 🏆 Best {deck.bestScore}% + {t("results.best", { n: deck.bestScore })} ) : null}
    @@ -118,7 +133,7 @@ export function ResultsScreen({ {missed.length > 0 ? (

    - Missed items ({missed.length}) + {t("results.missed", { n: missed.length })}

      {missed.map((activity) => ( @@ -129,12 +144,12 @@ export function ResultsScreen({

      {promptFor(activity)}

      {results[activity.id]?.userAnswer ? (

      - Your answer: {results[activity.id]?.userAnswer} + {t("results.yourAnswer", { answer: results[activity.id]?.userAnswer ?? "" })}

      ) : null}

      - Answer: - {answerFor(activity)} + {t("results.answer")} + {answerFor(activity, t)}

      ))} @@ -145,16 +160,16 @@ export function ResultsScreen({
      {missed.length > 0 ? ( ) : ( )} {missed.length > 0 ? ( ) : null} {regenerateError ? (

      @@ -174,13 +189,13 @@ export function ResultsScreen({ ) : null}

      - Library + {t("common.library")}
      @@ -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
      {percent}% - score + {t("results.score")}
    diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 17365a0..834a45a 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -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: () =

    - Settings + {t("settings.title")}

    - Models come from .env.local on - the server; your API key stays in this browser. + {t("settings.subtitle.before")}{" "} + .env.local{" "} + {t("settings.subtitle.after")}

    -
    + {/* --------------------------------------------- language */} +
    +

    + {t("language.label")} +

    +
    + {LOCALES.map((entry) => ( + + ))} +
    +

    + {t("language.note")} +

    +
    + {/* ------------------------------------------------ API key */}
    -

    Ollama API key

    +

    {t("settings.apiKey")}

    {apiKey ? ( - saved in this browser + {t("settings.savedInBrowser")} ) : needsKeyMode ? ( - required + {t("settings.required")} ) : ( - not needed + {t("settings.notNeeded")} )}

    - Stored only in this browser'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 OLLAMA_AUTH=local in .env.local and use{" "} - ollama signin instead. + {t("settings.keyNote")} OLLAMA_AUTH=local {t("settings.keyNoteAfter")}{" "} + .env.local {t("settings.keyNoteEnd")} ollama signin + {t("settings.keyNoteEnd2")}

    {apiKey ? ( ) : null} {saved ? ( - Saved — testing… + {t("settings.savedTesting")} ) : null}
    @@ -166,10 +190,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = - Install app + {t("settings.installApp")} - Add Freezmo to your home screen + {t("settings.addToHome")} @@ -179,7 +203,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = {loading && !health ? (
    - Checking Ollama… + {t("settings.checking")}
    ) : null} @@ -198,6 +222,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = } function HealthPanel({ health }: { health: ReturnType["health"] }) { + const { t } = useI18n(); if (!health) return null; const tone = health.ok ? "emerald" : health.needsKey ? "amber" : health.reachable ? "amber" : "rose"; const toneClasses: Record = { @@ -220,59 +245,62 @@ function HealthPanel({ health }: { health: ReturnType["health" {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 ?

    {health.error}

    : null}
    - - + +
    - - + +
    {health.mode === "direct-cloud" && health.needsKey ? (

    - Create a key at{" "} + {t("health.createKey.before")}{" "} ollama.com/settings/keys {" "} - and paste it above. Tip: with the local daemon you can run ollama signin instead and set{" "} + {t("health.createKey.after")} ollama signin {t("health.createKey.end")}{" "} OLLAMA_AUTH=local.

    ) : 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 (
    @@ -313,7 +342,7 @@ function ModelRow({

    {model.resolved}

    {model.configured !== model.resolved ? ( -

    configured as {model.configured}

    +

    {t("health.configuredAs", { model: model.configured })}

    ) : null} {model.note ?

    {model.note}

    : null} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index ffa2cb4..0afd16a 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -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({ ) : null} @@ -280,16 +282,16 @@ function NavContent({ @@ -357,10 +361,11 @@ function useCollapsed(): boolean { export function Sidebar() { const isCollapsed = useCollapsed(); + const { t } = useI18n(); return (