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 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue