Add an English/Czech interface language switch
English stays the default; Czech is a full translation. Only the app chrome is translated - notes and generated decks keep their own language, which is a core feature, not a setting. i18n - lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad). English is the default and the fallback for a missing key. - lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the x-freezmo-locale header. The hook's t/plural are bound to the locale React is rendering with, so the first client render matches the server HTML and then re-renders in the stored language; module-level t is only for event handlers and non-React helpers. - <html lang> follows the choice (inline script + setLocale). UI - Settings -> Language segmented control (English / Cestina), plus an EN/CS button on the key gate for first run. - Every screen translated: sidebar, header, tab bar, library, deck cards, folder bar, create flow, all seven activities, study chrome, results, settings + health panel, API-key gate and help, install sheet, offline and the deck-not-found page. - Plural-aware counts everywhere (decks, folders, activities, runs, photos, documents, pages, words, wrong attempts). Server - API routes and guards (generate, grade, health, http-guard, rate-limit, ollama error mapping) answer in the request's language, so error cards and the health panel never fall back to English mid-sentence. Fixes - A hydration mismatch when a non-default locale was stored silently dropped the .dark class after hydration; render-time t() is now bound to the hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor, DeckCard's folder fallback). - Bumped the service worker cache to freezmo-v4 for this deploy. Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals, settings switch) and a rebuilt regression suite 72/72 (English default, library, create, all 7 activity types, results, settings, install sheet, offline, PWA, security, Czech smoke).
This commit is contained in:
parent
4c2834edac
commit
618ad5fb9c
42 changed files with 1892 additions and 509 deletions
|
|
@ -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'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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue