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

@ -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`).

View file

@ -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
error: translate(locale, "server.invalidRequest", {
detail: parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
.join("; ")}`,
.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({

View file

@ -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 },
);
}

View file

@ -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;

View file

@ -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<Deck | null>(null);
const [loaded, setLoaded] = useState(false);
const { t } = useI18n();
useEffect(() => {
if (!id) {
@ -29,7 +31,7 @@ export default function DeckPage() {
return (
<main className="frost-bg grid min-h-dvh place-items-center">
<div className="relative flex items-center gap-3 text-sm font-semibold text-frost-800 dark:text-frost-100">
<Spinner className="size-5" /> Loading your deck…
<Spinner className="size-5" /> {t("deckPage.loading")}
</div>
</main>
);
@ -42,20 +44,19 @@ export default function DeckPage() {
<p className="text-4xl" aria-hidden>
🧊
</p>
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1>
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">{t("deckPage.notFound")}</h1>
<p className="text-sm text-frost-600 dark:text-frost-300">
This deck isn&apos;t in this browser&apos;s library. It may have been deleted, or it was
created in a different browser profile.
{t("deckPage.notFoundBody")}
</p>
<div className="flex justify-center gap-2 pt-1">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-2xl bg-accent-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-700"
>
Back to library
{t("deckPage.back")}
</Link>
<Link href="/create">
<Button variant="secondary">Create a deck</Button>
<Button variant="secondary">{t("deckPage.create")}</Button>
</Link>
</div>
</div>

View file

@ -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)

View file

@ -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 (
<main className="frost-bg grid min-h-dvh place-items-center px-4">
<div className="card relative max-w-sm space-y-3 p-6 text-center">
<HomeIcon className="mx-auto size-8 text-frost-400" />
<h1 className="text-xl font-semibold text-frost-950 dark:text-frost-50">You&apos;re offline</h1>
<p className="text-sm text-frost-600 dark:text-frost-300">
Freezmo needs a connection for generating decks and grading answers. Your saved decks are
still in this browser — reconnect and reload.
</p>
<h1 className="text-xl font-semibold text-frost-950 dark:text-frost-50">{t("offline.title")}</h1>
<p className="text-sm text-frost-600 dark:text-frost-300">{t("offline.body")}</p>
</div>
</main>
);

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>

View file

@ -1,5 +1,6 @@
"use client";
import { localeHeader, t } from "./i18n";
import type { Activity, Difficulty } from "./schemas";
import { API_KEY_HEADER, authHeaders } from "./settings";
@ -55,23 +56,23 @@ export async function generateDeck(
try {
response = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify(input),
signal: options.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError("Generation was cancelled.", "aborted");
throw new ApiError(t("api.cancelled"), "aborted");
}
throw new ApiError("Could not reach the Freezmo server. Is it still running?", "network");
throw new ApiError(t("api.unreachable"), "network");
}
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
throw new ApiError(body?.error ?? `The server returned HTTP ${response.status}.`, body?.kind);
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
}
if (!response.body) {
throw new ApiError("The server sent an empty response.", "bad_response");
throw new ApiError(t("api.empty"), "bad_response");
}
const reader = response.body.getReader();
@ -96,7 +97,7 @@ export async function generateDeck(
deck = event.deck;
meta = event.meta ?? {};
sawActivity = true;
options.onProgress?.({ stage: "done", message: "Almost there…" });
options.onProgress?.({ stage: "done", message: t("api.almost") });
} else if (event.type === "error") {
streamError = new ApiError(event.error, event.kind);
}
@ -119,8 +120,8 @@ export async function generateDeck(
if (!deck) {
throw new ApiError(
sawActivity
? "The server finished without sending a deck."
: "No deck came back from the server. Check your Ollama settings and try again.",
? t("api.incomplete")
: t("api.noDeck"),
"bad_response",
);
}
@ -129,7 +130,7 @@ export async function generateDeck(
(activity) => activity && typeof activity.type === "string" && typeof activity.id === "string",
);
if (!valid || valid.length === 0) {
throw new ApiError("The generated deck had no usable activities. Try again.", "bad_response");
throw new ApiError(t("api.noActivities"), "bad_response");
}
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
@ -147,15 +148,15 @@ export async function gradeAnswer(input: {
try {
response = await fetch("/api/grade", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify(input),
signal: input.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new ApiError("Grading was cancelled.", "aborted");
throw new ApiError(t("api.gradingCancelled"), "aborted");
}
throw new ApiError("Could not reach the Freezmo server.", "network");
throw new ApiError(t("api.unreachableShort"), "network");
}
const body = (await response.json().catch(() => null)) as
@ -163,7 +164,7 @@ export async function gradeAnswer(input: {
| null;
if (!response.ok || !body || body.error) {
throw new ApiError(body?.error ?? `Grading failed with HTTP ${response.status}.`, body?.kind);
throw new ApiError(body?.error ?? t("api.gradingFailed", { status: response.status }), body?.kind);
}
return {
@ -202,21 +203,21 @@ export type Health = {
export async function fetchHealth(): Promise<Health> {
let response: Response;
try {
response = await fetch("/api/health", { cache: "no-store", headers: authHeaders() });
response = await fetch("/api/health", { cache: "no-store", headers: { ...authHeaders(), ...localeHeader() } });
} catch {
throw new ApiError("Could not reach the Freezmo server.", "network");
throw new ApiError(t("api.unreachableShort"), "network");
}
const body = (await response.json().catch(() => null)) as (Partial<Health> & { error?: string; kind?: string }) | null;
if (!response.ok) {
throw new ApiError(
body?.error ?? `The health check failed with HTTP ${response.status}.`,
body?.error ?? t("api.healthFailed", { status: response.status }),
body?.kind ?? "http",
);
}
// Guard against anything that isn't a health payload (e.g. a proxy error
// page) so consumers can rely on the shape.
if (!body || typeof body !== "object" || !body.vision || !body.text) {
throw new ApiError("The Freezmo server sent an unexpected health response.", "bad_response");
throw new ApiError(t("api.healthUnexpected"), "bad_response");
}
return body as Health;
}
@ -237,12 +238,12 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
try {
response = await fetch("/api/health", {
cache: "no-store",
headers: { [API_KEY_HEADER]: apiKey },
headers: { [API_KEY_HEADER]: apiKey, ...localeHeader() },
});
} catch {
return {
result: "offline",
message: "Could not reach the Freezmo server. Is it still running?",
message: t("api.unreachable"),
};
}
@ -250,7 +251,7 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
if (!response.ok || !body) {
return {
result: "offline",
message: "The Freezmo server answered unexpectedly. Try again in a moment.",
message: t("api.serverUnexpected"),
};
}
@ -258,8 +259,7 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
return {
result: "invalid",
message:
body.error ??
"Ollama rejected this key. Check for typos, or create a new one at ollama.com/settings/keys.",
body.error ?? t("api.keyRejected"),
};
}
if (body.keyAccepted === true) return { result: "valid" };
@ -268,7 +268,7 @@ export async function verifyOllamaKey(apiKey: string): Promise<KeyCheck> {
return {
result: "offline",
message:
body.error ?? "Couldn't reach Ollama just now. Check your connection and try again.",
body.error ?? t("api.ollamaUnreachable"),
};
}

View file

@ -6,6 +6,7 @@
*/
import { sanitiseApiKey } from "./http-guard";
import { translate, type Locale } from "./i18n-dict";
export type AuthMode = "user-key" | "server" | "local";
@ -103,15 +104,16 @@ export function getServerConfig(): ServerConfig {
* Human-readable explanation for every auth configuration problem, so the UI
* never has to show a bare "unauthorised".
*/
export const AUTH_ERROR_MESSAGES: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
missing_key:
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
server_key_missing:
"This server is configured to use its own Ollama API key (OLLAMA_AUTH=server), but OLLAMA_API_KEY is empty in .env.local.",
local_with_cloud_url:
"This server is set to OLLAMA_AUTH=local but points at https://ollama.com. A signed-in local daemon can't be used against the cloud URL — either set OLLAMA_AUTH=user-key, or point OLLAMA_BASE_URL at http://localhost:11434.",
const AUTH_ERROR_KEYS: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
missing_key: "server.authMissingKey",
server_key_missing: "server.authServerKeyMissing",
local_with_cloud_url: "server.authLocalCloud",
};
export function authErrorMessage(locale: Locale, error: keyof typeof AUTH_ERROR_KEYS): string {
return translate(locale, AUTH_ERROR_KEYS[error]);
}
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
if (error === "missing_key") return "needs_key";
return "config";

View file

@ -14,6 +14,7 @@
* to the API routes. Photos uploaded as images keep working exactly as before.
*/
import { t } from "./i18n";
import { prepareImageFromBlob, type PreparedImage } from "./image";
export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; // 40 MB per file
@ -114,10 +115,10 @@ export function isDocumentFile(file: File): boolean {
export function describeDocumentError(file: File): string | null {
if (!isDocumentFile(file)) {
return `"${file.name}" is not a supported document (use PDF, DOCX, PPTX, ODT/ODP or text).`;
return t("file.notDocument", { name: file.name });
}
if (file.size > MAX_DOCUMENT_BYTES) {
return `"${file.name}" is larger than 40 MB.`;
return t("file.documentTooLarge", { name: file.name });
}
return null;
}
@ -224,7 +225,7 @@ async function extractPdf(
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
onProgress?.({
message: `Reading page ${pageNumber} of ${totalPages}…`,
message: t("doc.readingPage", { n: pageNumber, total: totalPages }),
progress: pageNumber / totalPages,
});
@ -452,16 +453,16 @@ export async function extractDocument(
case "pdf":
return extractPdf(file, onProgress);
case "docx":
onProgress?.({ message: "Reading the Word document…" });
onProgress?.({ message: t("doc.readingWord") });
return extractDocx(file);
case "pptx":
onProgress?.({ message: "Reading the slides…" });
onProgress?.({ message: t("doc.readingSlides") });
return extractOoxmlPackage(file, "pptx");
case "opendocument":
onProgress?.({ message: "Reading the OpenDocument file…" });
onProgress?.({ message: t("doc.readingOpen") });
return extractOoxmlPackage(file, "opendocument");
case "text":
onProgress?.({ message: "Reading the text file…" });
onProgress?.({ message: t("doc.readingText") });
return extractPlainText(file);
}
}

View file

@ -8,6 +8,7 @@
* never persisted — it is simply every deck with no `folderId`.
*/
import { t } from "./i18n";
import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas";
import { uid } from "./utils";
@ -99,8 +100,8 @@ export function getFolder(id: string | undefined): Folder | undefined {
}
export function folderName(id: string | undefined): string {
if (!id || id === UNORGANIZED_ID) return "Unorganized";
return getFolder(id)?.name ?? "Unorganized";
if (!id || id === UNORGANIZED_ID) return t("common.unorganized");
return getFolder(id)?.name ?? t("common.unorganized");
}
export function findFolderByName(name: string): Folder | undefined {
@ -111,8 +112,8 @@ export function findFolderByName(name: string): Folder | undefined {
/** Case-insensitive uniqueness; returns the existing folder when it matches. */
export function createFolder(name: string): { folder: Folder } | { error: string } {
const trimmed = name.trim().replace(/\s+/g, " ");
if (!trimmed) return { error: "Give the folder a name." };
if (trimmed.length > 60) return { error: "That name is a bit long (60 characters max)." };
if (!trimmed) return { error: t("folder.nameRequired") };
if (trimmed.length > 60) return { error: t("folder.nameTooLong") };
const existing = findFolderByName(trimmed);
if (existing) return { folder: existing };
@ -125,11 +126,11 @@ export function createFolder(name: string): { folder: Folder } | { error: string
export function renameFolder(id: string, name: string): { ok: true } | { error: string } {
const trimmed = name.trim().replace(/\s+/g, " ");
if (!trimmed) return { error: "Give the folder a name." };
if (!trimmed) return { error: t("folder.nameRequired") };
const clash = folderStore
.getSnapshot()
.find((folder) => folder.id !== id && folder.name.trim().toLowerCase() === trimmed.toLowerCase());
if (clash) return { error: "Another folder already has that name." };
if (clash) return { error: t("folder.nameTaken") };
ensureLoaded();
write(folderStore.getSnapshot().map((folder) => (folder.id === id ? { ...folder, name: trimmed } : folder)));

View file

@ -1,3 +1,5 @@
import { LOCALE_HEADER, normaliseLocale, translate } from "./i18n-dict";
/**
* Small server-side guards shared by the API routes.
*
@ -6,6 +8,11 @@
* bodies exhausting memory, and (c) odd values ending up in outbound headers.
*/
/** The language this request asked for (browsers send x-freezmo-locale). */
export function requestLocale(request: Request) {
return normaliseLocale(request.headers.get(LOCALE_HEADER));
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
@ -19,7 +26,10 @@ export function rejectOversizedBody(request: Request, maxBytes: number): Respons
if (Number.isFinite(length) && length > maxBytes) {
return Response.json(
{
error: `Request body is too large (${formatBytes(length)}). Limit is ${formatBytes(maxBytes)}.`,
error: translate(requestLocale(request), "server.bodyTooLarge", {
size: formatBytes(length),
limit: formatBytes(maxBytes),
}),
kind: "too_large",
},
{ status: 413 },
@ -46,7 +56,10 @@ export function rejectCrossOrigin(request: Request): Response | null {
try {
originHost = new URL(origin).host;
} catch {
return Response.json({ error: "Invalid Origin header.", kind: "forbidden" }, { status: 403 });
return Response.json(
{ error: translate(requestLocale(request), "server.invalidOrigin"), kind: "forbidden" },
{ status: 403 },
);
}
const host = request.headers.get("host");
@ -62,7 +75,7 @@ export function rejectCrossOrigin(request: Request): Response | null {
if (loopback.has(bare(originHost)) && loopback.has(bare(host ?? ""))) return null;
return Response.json(
{ error: "Cross-origin requests are not allowed.", kind: "forbidden" },
{ error: translate(requestLocale(request), "server.crossOrigin"), kind: "forbidden" },
{ status: 403 },
);
}

1093
lib/i18n-dict.ts Normal file

File diff suppressed because it is too large Load diff

127
lib/i18n.ts Normal file
View file

@ -0,0 +1,127 @@
"use client";
/**
* Browser-side locale store + React bindings.
*
* English is the default and the source of truth; Czech is a full translation.
* Only *interface* copy lives in the dictionaries — your notes and the generated
* decks always keep their own language (that is a core feature, not a setting).
*
* The dictionaries themselves live in ./i18n-dict so the server can translate
* its API messages into the same language.
*/
import { useSyncExternalStore } from "react";
import {
DEFAULT_LOCALE,
LOCALE_HEADER,
LOCALES,
normaliseLocale,
translate,
translatePlural,
type Locale,
type Vars,
} from "./i18n-dict";
export { LOCALES, DEFAULT_LOCALE, LOCALE_HEADER, translate, translatePlural };
export type { Locale };
const STORAGE_KEY = "freezmo.locale.v1";
let cached: Locale | null = null;
const listeners = new Set<() => void>();
function read(): Locale {
if (typeof window === "undefined") return DEFAULT_LOCALE;
try {
return normaliseLocale(window.localStorage.getItem(STORAGE_KEY));
} catch {
return DEFAULT_LOCALE;
}
}
function emit(): void {
listeners.forEach((listener) => listener());
}
export const localeStore = {
subscribe(listener: () => void): () => void {
listeners.add(listener);
const onStorage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY) {
cached = read();
emit();
}
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): Locale {
if (cached === null) cached = read();
return cached;
},
getServerSnapshot(): Locale {
return DEFAULT_LOCALE;
},
};
export function getLocale(): Locale {
return localeStore.getSnapshot();
}
export function setLocale(locale: Locale): void {
cached = normaliseLocale(locale);
try {
window.localStorage.setItem(STORAGE_KEY, cached);
} catch {
/* private mode */
}
try {
document.documentElement.lang = cached;
} catch {
/* SSR */
}
emit();
}
/** React hook: re-renders when the locale changes. */
export function useLocale(): Locale {
return useSyncExternalStore(localeStore.subscribe, localeStore.getSnapshot, localeStore.getServerSnapshot);
}
/** Translate a key for the current locale. */
export function t(key: string, vars?: Vars): string {
return translate(localeStore.getSnapshot(), key, vars);
}
/** Plural-aware translate for the current locale. */
export function plural(n: number, base: string, vars?: Vars): string {
return translatePlural(localeStore.getSnapshot(), n, base, vars);
}
/** Header handed to our own API routes so they answer in the chosen language. */
export function localeHeader(): Record<string, string> {
return { [LOCALE_HEADER]: localeStore.getSnapshot() };
}
/**
* Convenience hook for components.
*
* `t` / `plural` are bound to the locale React is currently rendering with
* (the server snapshot during hydration, then the stored choice), so the first
* client render always matches the server HTML. Use the module-level `t` only
* in event handlers and non-React helpers.
*/
export function useI18n() {
const locale = useLocale();
return {
locale,
setLocale,
t: (key: string, vars?: Vars) => translate(locale, key, vars),
plural: (n: number, base: string, vars?: Vars) => translatePlural(locale, n, base, vars),
};
}

View file

@ -1,5 +1,7 @@
"use client";
import { t } from "./i18n";
/** Client-side image helpers: validation, resizing and compression. */
export const MAX_IMAGE_DIMENSION = 1600;
@ -23,8 +25,8 @@ export function isImageFile(file: File): boolean {
}
export function describeFileError(file: File): string | null {
if (!isImageFile(file)) return `"${file.name}" is not an image.`;
if (file.size > MAX_FILE_BYTES) return `"${file.name}" is larger than 20 MB.`;
if (!isImageFile(file)) return t("file.notImage", { name: file.name });
if (file.size > MAX_FILE_BYTES) return t("file.imageTooLarge", { name: file.name });
return null;
}

View file

@ -9,6 +9,7 @@
* This module must only ever be imported from server code (route handlers).
*/
import { DEFAULT_LOCALE, translate, type Locale } from "./i18n-dict";
import { getServerConfig, resolveModelName, type OllamaAuth } from "./config";
export type OllamaMessage = {
@ -41,25 +42,31 @@ export class OllamaError extends Error {
}
}
export function friendlyErrorMessage(error: unknown, baseUrl?: string): string {
export function friendlyErrorMessage(
error: unknown,
baseUrl?: string,
locale: Locale = DEFAULT_LOCALE,
): string {
if (error instanceof OllamaError) {
switch (error.kind) {
case "unreachable":
return `Ollama isn't reachable at ${baseUrl ?? "the configured URL"}. Check your settings and make sure the daemon is running.`;
return translate(locale, "server.ollamaUnreachable", {
url: baseUrl ?? "the configured URL",
});
case "timeout":
return "Ollama took too long to answer. Try fewer/smaller images, a faster model, or retry.";
return translate(locale, "server.ollamaTimeout");
case "auth":
return "Ollama rejected the request. If you are using https://ollama.com, check OLLAMA_API_KEY (create one at ollama.com/settings/keys).";
return translate(locale, "server.ollamaAuth");
case "no_credits":
return error.message;
case "model_missing":
return error.message;
default:
return error.message || "Ollama returned an unexpected response.";
return error.message || translate(locale, "server.ollamaUnexpected");
}
}
if (error instanceof Error) return error.message;
return "Something went wrong while talking to Ollama.";
return translate(locale, "server.ollamaGeneric");
}
type ChatOptions = {

View file

@ -7,6 +7,7 @@
* share state with others — fine for Freezmo, documented in the README.
*/
import { DEFAULT_LOCALE, translate, type Locale } from "./i18n-dict";
type Hits = { timestamps: number[] };
/**
@ -86,12 +87,10 @@ export function clientKey(request: Request): string {
);
}
export function rateLimitResponse(result: RateLimitResult, message?: string): Response {
export function rateLimitResponse(result: RateLimitResult, locale: Locale = DEFAULT_LOCALE): Response {
return Response.json(
{
error:
message ??
`Too many requests — this app allows ${result.limit} per window. Try again in ${result.retryAfter}s.`,
error: translate(locale, "server.rateLimited", { limit: result.limit, retry: result.retryAfter }),
kind: "rate_limited",
retryAfter: result.retryAfter,
},

View file

@ -5,6 +5,7 @@
* Exposes a tiny observable store so every screen stays in sync.
*/
import { t } from "./i18n";
import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas";
import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders";
import { uid } from "./utils";
@ -224,7 +225,7 @@ export function importDeck(raw: string): { deck: Deck } | { error: string } {
try {
parsed = JSON.parse(raw);
} catch {
return { error: "That file is not valid JSON." };
return { error: t("import.notJson") };
}
const bundle = parsed && typeof parsed === "object" && "deck" in parsed ? (parsed as { folder?: unknown }) : null;
@ -232,7 +233,7 @@ export function importDeck(raw: string): { deck: Deck } | { error: string } {
const result = deckSchema.safeParse(candidate);
if (!result.success) {
return { error: "That file doesn't look like a Freezmo deck." };
return { error: t("import.notDeck") };
}
// Recreate the exported folder (or reuse an existing one with the same name).

View file

@ -1,5 +1,6 @@
"use client";
import { t } from "./i18n";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { fetchHealth, type Health } from "./client-api";
@ -25,7 +26,7 @@ export function useHealth() {
try {
setHealth(await fetchHealth());
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Could not reach the Freezmo server.");
setError(loadError instanceof Error ? loadError.message : t("api.unreachableShort"));
} finally {
setLoading(false);
}

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v3";
const CACHE_VERSION = "freezmo-v4";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;