- {entry.progress ?? "Reading…"}
+ {entry.progress ?? t("upload.reading")}
) : null}
{entry.status === "error" ? (
@@ -492,11 +486,11 @@ export function UploadScreen() {
{entry.status === "ready" && entry.extracted ? (
<>
- {describeExtraction(entry.extracted)}
+ {describeExtraction(entry.extracted, t, plural)}
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
- No readable text — is this a scan without a text layer?
+ {t("upload.noReadable")}
) : null}
{entry.extracted.warnings.map((warning) => (
@@ -510,7 +504,7 @@ export function UploadScreen() {
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 ? (
{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 })}`
: ""}
) : null}
{phase === "preparing" ? (
- Compressing photos…
+ {t("upload.compressing")}
) : null}
{fileError ? (
@@ -550,35 +546,35 @@ export function UploadScreen() {
{/* ---------------------------------------------------------- */}
-
2. Optional extras
+
{t("upload.step2")}
- Paste extra notes or give the AI a hint about what to focus on.
+ {t("upload.step2Help")}
- Pasted notes or text
+ {t("upload.pastedNotes")}
- Topic hint (optional)
+ {t("upload.topicHint")}
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"
/>
@@ -586,14 +582,14 @@ export function UploadScreen() {
{/* ---------------------------------------------------------- */}
- 3. Difficulty & size
+ {t("upload.step3")}
- Freeze into folder
+ {t("upload.freezeInto")}
- {[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...sortedFolders].map((folder) => {
+ {[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => {
const active = folderId === folder.id;
return (
@@ -654,7 +650,7 @@ export function UploadScreen() {
}
}}
>
- Add
+ {t("common.add")}
) : (
@@ -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"
>
-
New folder
+
{t("common.newFolder")}
)}
-
+
{DIFFICULTIES.map((option) => {
const selected = difficulty === option.value;
return (
@@ -689,8 +685,8 @@ export function UploadScreen() {
{option.icon}
-
{option.label}
-
{option.blurb}
+
{t(option.labelKey)}
+
{t(option.blurbKey)}
);
})}
@@ -698,7 +694,7 @@ export function UploadScreen() {
- Activities
+ {t("upload.activities")}
{COUNT_OPTIONS.map((option) => (
{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")}
void generate()} disabled={!canGenerate}>
- Freeze my deck
+ {t("upload.freezeDeck")}
@@ -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 (
@@ -781,20 +778,20 @@ function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCa
{message}
-
+
{STAGE_STEPS.map((step, index) => (
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
- {step.messages[0]}
+ {t(step.keys[0])}
))}
-
{GENERATION_TIPS[tip]}
+
{t(GENERATION_TIPS[tip])}
- Cancel
+ {t("upload.cancel")}
diff --git a/components/activities/FillBlank.tsx b/components/activities/FillBlank.tsx
index 5ffe684..f133729 100644
--- a/components/activities/FillBlank.tsx
+++ b/components/activities/FillBlank.tsx
@@ -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
) {
+ const { t } = useI18n();
const [value, setValue] = useState("");
const [chosen, setChosen] = useState(null);
const [result, setResult] = useState(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
{activity.options ? (
-
+
{activity.options.map((option) => {
const state =
result === null
@@ -168,13 +170,13 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
) : result === null ? (
submit(value)} disabled={!value.trim()} className="flex-1">
- Check answer
+ {t("fill.check")}
) : null}
{result === null && !activity.options ? (
-
Press Enter to check. Small typos are forgiven.
+
{t("fill.hint")}
) : null}
{result !== null ? (
@@ -186,11 +188,11 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
onNext={onNext}
detail={
- Expected: {activity.answer}
+ {t("fill.expected")} {activity.answer}
{!result && chosen ? (
<>
{" "}
- — you wrote {chosen}
+ {t("fill.youWrote")} {chosen}
>
) : null}
diff --git a/components/activities/Flashcard.tsx b/components/activities/Flashcard.tsx
index d252eaf..043775b 100644
--- a/components/activities/Flashcard.tsx
+++ b/components/activities/Flashcard.tsx
@@ -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
;
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) {
+ const { t } = useI18n();
const [flipped, setFlipped] = useState(false);
const [result, setResult] = useState(null);
@@ -30,13 +32,13 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
return (
<>
- {flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}
+ {flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}
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")}
>
- Question
+ {t("flashcard.question")}
{activity.front}
- tap to flip ↻
+ {t("flashcard.tapFlip")}
- Answer
+ {t("flashcard.answer")}
{activity.back}
@@ -69,10 +71,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
{flipped && result === null ? (
settle(false)}>
- 😅 Not yet
+ {t("flashcard.notYet")}
settle(true)}>
- 🧊 I knew it
+ {t("flashcard.knewIt")}
) : 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}
diff --git a/components/activities/Matching.tsx b/components/activities/Matching.tsx
index eb8b6d5..2a6a3e8 100644
--- a/components/activities/Matching.tsx
+++ b/components/activities/Matching.tsx
@@ -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
;
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) {
+ 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={
- {matched.length}/{total} matched
+ {t("matching.matched", { n: matched.length, total })}
}
/>
@@ -68,10 +70,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
{activity.instruction}
{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")}
@@ -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={
- {misses === 0
- ? "You matched everything first try."
- : `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
+ {misses === 0 ? t("matching.firstTry") : plural(misses, "matching.wrongAttempts")}
}
/>
diff --git a/components/activities/MultipleChoice.tsx b/components/activities/MultipleChoice.tsx
index 847aeb0..30922e2 100644
--- a/components/activities/MultipleChoice.tsx
+++ b/components/activities/MultipleChoice.tsx
@@ -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) {
+ const { t } = useI18n();
const [selected, setSelected] = useState(null);
const answered = selected !== null;
@@ -62,7 +64,7 @@ export function MultipleChoice({
{activity.question}
-
+
{activity.options.map((option, index) => (
- {!answered ? Tip: press 1–4 on a keyboard. : null}
+ {!answered ? {t("mc.tipKeys")} : null}
{answered ? (
- Correct answer:{" "}
+ {t("activity.correctAnswer")}{" "}
{activity.options[activity.correctIndex]}
>
)
diff --git a/components/activities/Ordering.tsx b/components/activities/Ordering.tsx
index 245f9da..056cabf 100644
--- a/components/activities/Ordering.tsx
+++ b/components/activities/Ordering.tsx
@@ -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;
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) {
+ const { t } = useI18n();
const initial = useMemo(() => shuffle(activity.items), [activity]);
const [items, setItems] = useState(initial);
const [checked, setChecked] = useState(false);
@@ -48,7 +50,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
{activity.instruction}
- {checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
+ {checked ? t("ordering.correctOrder") : t("ordering.hint")}
@@ -89,7 +91,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
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
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 ? (
- Check order
+ {t("ordering.check")}
) : (
) {
+ const { t } = useI18n();
const [value, setValue] = useState("");
const [state, setState] = useState({ status: "idle" });
const textareaRef = useRef(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
- Your answer
+ {t("short.yourAnswer")}
{state.status === "idle" ? (
- Ctrl/⌘ + Enter to submit.
+ {t("short.ctrlEnter")}
void grade()} disabled={!value.trim()}>
- Submit answer
+ {t("short.submit")}
) : null}
@@ -111,20 +113,20 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{state.status === "grading" ? (
- Grading your answer…
+ {t("short.grading")}
) : null}
{state.status === "error" ? (
-
Couldn't grade that answer
+
{t("short.couldNotGrade")}
{state.message}
void grade()}>
- Retry
+ {t("common.retry")}
setState({ status: "self-check" })}>
- Show answer & self-check
+ {t("short.showAnswer")}
@@ -132,15 +134,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{state.status === "self-check" ? (
-
Model answer
+
{t("short.modelAnswer")}
{activity.referenceAnswer}
-
Key points: {activity.keyPoints.join(" · ")}
+
{t("short.keyPoints")} {activity.keyPoints.join(" · ")}
selfCheck(false)}>
- 😅 I missed it
+ {t("short.missedIt")}
selfCheck(true)}>
- 🧊 I had it right
+ {t("short.hadRight")}
@@ -157,7 +159,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
)}
>
- {state.correct ? "Accepted" : "Needs work"}
+ {state.correct ? t("short.accepted") : t("short.needsWork")}
{state.feedback}
{state.missing.length ? (
@@ -169,7 +171,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
) : null}
{!state.correct ? (
- Model answer:
+ {t("short.modelAnswer")}:
{activity.referenceAnswer}
) : null}
diff --git a/components/activities/TrueFalse.tsx b/components/activities/TrueFalse.tsx
index 7a27ce1..c92f1a5 100644
--- a/components/activities/TrueFalse.tsx
+++ b/components/activities/TrueFalse.tsx
@@ -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;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps) {
+ const { t } = useI18n();
const [result, setResult] = useState(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
<>
- {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")}
@@ -55,10 +57,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
>
- ✓ true
+ {t("tf.trueLabel")}
- false ✕
+ {t("tf.falseLabel")}
“{activity.statement}”
@@ -70,10 +72,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
{result === null ? (
answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
- ✕ False
+ {t("tf.falseBtn")}
answer(true)}>
- ✓ True
+ {t("tf.trueBtn")}
) : (
@@ -85,7 +87,8 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
onNext={onNext}
detail={
- This statement is {activity.answer ? "true" : "false"} .
+ {t("tf.statementIs.before")}{" "}
+ {activity.answer ? t("tf.true") : t("tf.false")} .
}
/>
diff --git a/components/activities/index.tsx b/components/activities/index.tsx
index 8b95251..2836f14 100644
--- a/components/activities/index.tsx
+++ b/components/activities/index.tsx
@@ -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 Unsupported activity: {JSON.stringify(never)}
;
+ return {t("activity.unsupported", { json: JSON.stringify(never) })}
;
}
}
}
diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx
index a9c1b90..fc83e8b 100644
--- a/components/activities/shared.tsx
+++ b/components/activities/shared.tsx
@@ -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 = {
isLast: boolean;
};
-export const TYPE_META: Record = {
- 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 = {
+ 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 (
@@ -47,7 +49,7 @@ export function ActivityHeader({
)}
>
{meta.icon}
- {meta.label}
+ {t(meta.labelKey)}
{topic ? (
@@ -144,6 +146,7 @@ export function FeedbackPanel({
detail?: React.ReactNode;
children?: React.ReactNode;
}) {
+ const { t } = useI18n();
return (
{correct ? "✅" : "💧"}
- {headline ?? (correct ? "Nice one!" : "Not quite")}
+ {headline ?? (correct ? t("activity.niceOne") : t("activity.notQuite"))}
{correct && xp ? (
{detail}
: null}
- Why:
+ {t("activity.why")}
{explanation}
@@ -190,7 +193,7 @@ export function FeedbackPanel({
- {isLast ? "See results" : "Next"}
+ {isLast ? t("activity.seeResults") : t("activity.next")}
→
diff --git a/lib/client-api.ts b/lib/client-api.ts
index d924771..cfb4b04 100644
--- a/lib/client-api.ts
+++ b/lib/client-api.ts
@@ -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 {
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 & { 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 {
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 {
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 {
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 {
return {
result: "offline",
message:
- body.error ?? "Couldn't reach Ollama just now. Check your connection and try again.",
+ body.error ?? t("api.ollamaUnreachable"),
};
}
diff --git a/lib/config.ts b/lib/config.ts
index 8df9895..c5fcd52 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -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";
diff --git a/lib/documents.ts b/lib/documents.ts
index cdcd7b9..f827989 100644
--- a/lib/documents.ts
+++ b/lib/documents.ts
@@ -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);
}
}
diff --git a/lib/folders.ts b/lib/folders.ts
index de61cde..686f85e 100644
--- a/lib/folders.ts
+++ b/lib/folders.ts
@@ -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)));
diff --git a/lib/http-guard.ts b/lib/http-guard.ts
index 0737b32..36dde87 100644
--- a/lib/http-guard.ts
+++ b/lib/http-guard.ts
@@ -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 },
);
}
diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts
new file mode 100644
index 0000000..77032e7
--- /dev/null
+++ b/lib/i18n-dict.ts
@@ -0,0 +1,1093 @@
+/**
+ * Pure dictionary + lookup helpers, shared by the browser and the server.
+ *
+ * No React and no DOM here on purpose: API routes import this module to answer
+ * in the same language the UI is currently set to (the browser sends
+ * `x-freezmo-locale` with every request).
+ */
+
+export type Locale = "en" | "cs";
+
+export const LOCALES: Array<{ id: Locale; label: string; short: string }> = [
+ { id: "en", label: "English", short: "EN" },
+ { id: "cs", label: "Čeština", short: "CS" },
+];
+
+export const DEFAULT_LOCALE: Locale = "en";
+
+/** Header the browser uses to ask for a language in API responses. */
+export const LOCALE_HEADER = "x-freezmo-locale";
+
+export type Vars = Record;
+
+function interpolate(template: string, vars?: Vars): string {
+ if (!vars) return template;
+ return template.replace(/\{(\w+)\}/g, (match, key: string) =>
+ Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match,
+ );
+}
+
+/** Translate a key for an explicit locale (falls back to English, then the key). */
+export function translate(locale: Locale, key: string, vars?: Vars): string {
+ const table = locale === "cs" ? cs : en;
+ const template = table[key] ?? en[key] ?? key;
+ return interpolate(template, vars);
+}
+
+/**
+ * Plural-aware lookup. Keys live as ` .one` / ` .few` / ` .many`;
+ * Czech uses the 1 / 2-4 / 5+ rule, English only 1 / rest. `{n}` is provided.
+ */
+export function translatePlural(locale: Locale, n: number, base: string, vars?: Vars): string {
+ const form =
+ locale === "cs"
+ ? n === 1
+ ? "one"
+ : n >= 2 && n <= 4
+ ? "few"
+ : "many"
+ : n === 1
+ ? "one"
+ : "many";
+ const table = locale === "cs" ? cs : en;
+ const template = table[`${base}.${form}`] ?? table[`${base}.many`] ?? en[`${base}.many`] ?? base;
+ return interpolate(template, { n, ...vars });
+}
+
+/** Normalise anything a request sends ("cs-CZ", "CS", "en") to a supported locale. */
+export function normaliseLocale(value: string | null | undefined): Locale {
+ if (!value) return DEFAULT_LOCALE;
+ const key = value.trim().toLowerCase().split(/[-_,;]/)[0];
+ return key === "cs" || key === "cz" ? "cs" : "en";
+}
+
+const en: Record = {
+ /* common ---------------------------------------------------------- */
+ "common.cancel": "Cancel",
+ "common.save": "Save",
+ "common.add": "Add",
+ "common.delete": "Delete",
+ "common.rename": "Rename",
+ "common.close": "Close",
+ "common.retry": "Retry",
+ "common.settings": "Settings",
+ "common.library": "Library",
+ "common.newDeck": "New deck",
+ "common.folder": "Folder",
+ "common.folders": "Folders",
+ "common.newFolder": "New folder",
+ "common.folderName": "Folder name",
+ "common.newFolderName": "New folder name",
+ "common.unorganized": "Unorganized",
+ "common.allDecks": "All decks",
+ "common.lightMode": "Light mode",
+ "common.darkMode": "Dark mode",
+ "common.switchToLight": "Switch to light mode",
+ "common.switchToDark": "Switch to dark mode",
+ "common.menu": "Menu",
+
+ /* header ---------------------------------------------------------- */
+ "header.openMenu": "Open menu",
+ "header.home": "Freezmo home",
+ "header.tagline": "study feed",
+ "header.openSettings": "Open settings and Ollama status",
+
+ /* tab bar --------------------------------------------------------- */
+ "tabbar.main": "Main",
+
+ /* sidebar --------------------------------------------------------- */
+ "sidebar.collapse": "Collapse sidebar",
+ "sidebar.expand": "Expand sidebar",
+ "sidebar.library": "Library",
+ "sidebar.label": "Sidebar",
+ "sidebar.closeMenu": "Close menu",
+
+ /* language -------------------------------------------------------- */
+ "language.label": "Language",
+ "language.note": "Interface language. Your notes and the generated decks keep their own language.",
+
+ /* settings -------------------------------------------------------- */
+ "settings.title": "Settings",
+ "settings.subtitle.before": "Models come from",
+ "settings.subtitle.after": "on the server; your API key stays in this browser.",
+ "settings.close": "Close settings",
+ "settings.apiKey": "Ollama API key",
+ "settings.savedInBrowser": "saved in this browser",
+ "settings.required": "required",
+ "settings.notNeeded": "not needed",
+ "settings.pasteKey": "paste your key from ollama.com/settings/keys",
+ "settings.hide": "Hide",
+ "settings.show": "Show",
+ "settings.keyNote":
+ "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",
+ "settings.keyNoteAfter": "in",
+ "settings.keyNoteEnd": "and use",
+ "settings.keyNoteEnd2": "instead.",
+ "settings.testConnection": "Test connection",
+ "settings.removeKey": "Remove key from this browser",
+ "settings.savedTesting": "Saved — testing…",
+ "settings.installApp": "Install app",
+ "settings.addToHome": "Add Freezmo to your home screen",
+ "settings.checking": "Checking Ollama…",
+
+ /* health ---------------------------------------------------------- */
+ "health.ok": "Ollama is reachable and both models are available.",
+ "health.needsKey": "Waiting for your API key.",
+ "health.rejected": "Ollama rejected your API key.",
+ "health.modelMissing": "Ollama is reachable, but a configured model is missing.",
+ "health.unreachable": "Ollama isn't reachable.",
+ "health.auth": "Auth",
+ "health.authUserKey": "Your own key",
+ "health.authServer": "Server key (.env)",
+ "health.authLocal": "Local daemon sign-in",
+ "health.endpoint": "Endpoint",
+ "health.mode": "Mode",
+ "health.modeCloud": "Direct cloud API",
+ "health.modeLocal": "Local daemon",
+ "health.key": "Key",
+ "health.keyMissing": "missing",
+ "health.keyAccepted": "accepted",
+ "health.keyRejected": "rejected",
+ "health.keyNotNeeded": "not needed",
+ "health.keyUnknown": "unknown",
+ "health.visionModel": "Vision model (reads photos)",
+ "health.textModel": "Text model (builds & grades)",
+ "health.available": "available",
+ "health.notFound": "not found",
+ "health.unknown": "unknown",
+ "health.configuredAs": "configured as {model}",
+ "health.createKey.before": "Create a key at",
+ "health.createKey.after": "and paste it above. Tip: with the local daemon you can run",
+ "health.createKey.end": "instead and set",
+
+ /* library --------------------------------------------------------- */
+ "library.title": "Your icebox",
+ "library.subtitle": "Decks frozen from your notes, saved in this browser.",
+ "library.import": "Import JSON",
+ "library.folderCreated": "Folder created — decks you freeze while it's selected land here.",
+ "library.imported": "Imported “{title}”.",
+ "library.couldNotRead": "Could not read that file.",
+ "library.folderDeleted": "Folder deleted — its decks moved to Unorganized.",
+ "library.deckMoved": "Deck moved.",
+ "library.deckMovedUnorganized": "Deck moved to Unorganized.",
+ "library.noDecks": "No decks yet",
+ "library.makeFirst": "Make your first deck",
+ "library.emptyBody": "Freeze a page of notes into a feed of flashcards and quizzes.",
+ "library.emptyHint": "Using your own Ollama models —",
+ "library.emptySettings": "check the connection in Settings",
+ "library.renameFolderAria": "Folder name",
+ "library.nothingUnfiled": "Nothing unfiled right now.",
+ "library.emptyFolder": "Empty folder — freeze a deck into it or move one over from Unorganized.",
+ "library.stats.best": "best {n}%",
+ "library.deleteWarning.one": "The {n} deck inside will move to Unorganized — nothing is deleted.",
+ "library.deleteWarning.few": "The {n} decks inside will move to Unorganized — nothing is deleted.",
+ "library.deleteWarning.many": "The {n} decks inside will move to Unorganized — nothing is deleted.",
+ "stats.decks.one": "{n} deck",
+ "stats.decks.few": "{n} decks",
+ "stats.decks.many": "{n} decks",
+ "stats.folders.one": "{n} folder",
+ "stats.folders.few": "{n} folders",
+ "stats.folders.many": "{n} folders",
+ "stats.activities.one": "{n} activity",
+ "stats.activities.few": "{n} activities",
+ "stats.activities.many": "{n} activities",
+
+ /* deck card ------------------------------------------------------- */
+ "deck.activities.one": "{n} activity",
+ "deck.activities.few": "{n} activities",
+ "deck.activities.many": "{n} activities",
+ "deck.runs.one": "{n} run",
+ "deck.runs.few": "{n} runs",
+ "deck.runs.many": "{n} runs",
+ "deck.notPlayed": "Not played yet",
+ "deck.best": "Best {n}%",
+ "deck.bestLabel": "Best",
+ "deck.study": "Study",
+ "deck.actions": "Deck actions",
+ "deck.titleLabel": "Deck title",
+ "deck.rename": "Rename",
+ "deck.export": "Export JSON",
+ "deck.regenerate": "Regenerate deck",
+ "deck.reallyDelete": "Really delete",
+ "deck.delete": "Delete deck",
+ "deck.newFolder": "+ New folder",
+ "deck.addMore": "Add more activities",
+ "deck.generate": "Generate",
+ "deck.noSource": "No stored source text for this deck (imported decks don't carry it).",
+ "deck.noSourceExtend": "This deck has no stored source notes, so it can't be extended.",
+ "deck.noSourceRegenerate": "This deck has no stored source notes, so it can't be regenerated.",
+ "deck.couldNotAdd": "Could not add more activities.",
+ "deck.couldNotRegenerate": "Could not regenerate the deck.",
+ "deck.regenerated": "Deck regenerated",
+ "deck.added.one": "Added {n} activity",
+ "deck.added.few": "Added {n} activities",
+ "deck.added.many": "Added {n} activities",
+ "deck.addingActivities": "Adding activities",
+ "deck.regeneratingDeck": "Regenerating deck",
+ "deck.addingNew": "Adding new activities…",
+ "deck.regeneratingWhole": "Regenerating the whole deck…",
+
+ /* upload ---------------------------------------------------------- */
+ "upload.title": "Turn your notes into a study feed",
+ "upload.intro":
+ "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.",
+ "upload.errNeedsKey": "API key required",
+ "upload.errRate": "Slow down a moment",
+ "upload.errAuth": "Ollama rejected your key",
+ "upload.errFailed": "Generation failed",
+ "upload.openSettings": "Open settings",
+ "upload.addPhotosAria": "Add photos of your notes",
+ "upload.step1": "1. Add your notes",
+ "upload.step1Help":
+ "Drag & drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word, PowerPoint, OpenDocument and text files. Up to {images} photos and {documents} documents.",
+ "upload.chooseFiles": "Choose files",
+ "upload.camera": "Camera",
+ "upload.dropHere": "Drop your notes here",
+ "upload.formats": "Photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · images resized to 1600px before sending",
+ "upload.remove": "Remove {name}",
+ "upload.addMorePhotos": "Add more photos",
+ "upload.reading": "Reading…",
+ "upload.noReadable": "No readable text — is this a scan without a text layer?",
+ "upload.words.one": "{n} word",
+ "upload.words.few": "{n} words",
+ "upload.words.many": "{n} words",
+ "upload.pages.one": "{n} page",
+ "upload.pages.few": "{n} pages",
+ "upload.pages.many": "{n} pages",
+ "upload.pageImages.one": "{n} page image",
+ "upload.pageImages.few": "{n} page images",
+ "upload.pageImages.many": "{n} page images",
+ "upload.truncated": "truncated",
+ "upload.photoSummary.one": "{n} photo · {size} after compression",
+ "upload.photoSummary.few": "{n} photos · {size} after compression",
+ "upload.photoSummary.many": "{n} photos · {size} after compression",
+ "upload.docSummary.one": "{n} document",
+ "upload.docSummary.few": "{n} documents",
+ "upload.docSummary.many": "{n} documents",
+ "upload.docImages.one": "{n} page image",
+ "upload.docImages.few": "{n} page images",
+ "upload.docImages.many": "{n} page images",
+ "upload.skipImages.one": "{n} image will be skipped (max {max} per deck)",
+ "upload.skipImages.few": "{n} images will be skipped (max {max} per deck)",
+ "upload.skipImages.many": "{n} images will be skipped (max {max} per deck)",
+ "upload.compressing": "Compressing photos…",
+ "upload.step2": "2. Optional extras",
+ "upload.step2Help": "Paste extra notes or give the AI a hint about what to focus on.",
+ "upload.pastedNotes": "Pasted notes or text",
+ "upload.pastedPlaceholder": "Paste lecture text, a summary, or any extra facts here…",
+ "upload.topicHint": "Topic hint (optional)",
+ "upload.topicPlaceholder": "e.g. focus on the formulas, or on dates",
+ "upload.step3": "3. Difficulty & size",
+ "upload.freezeInto": "Freeze into folder",
+ "upload.folderPlaceholder": "e.g. History",
+ "upload.difficultyAria": "Difficulty",
+ "upload.easy": "Easy",
+ "upload.easyBlurb": "Recall & recognition",
+ "upload.medium": "Medium",
+ "upload.mediumBlurb": "Understanding & application",
+ "upload.hard": "Hard",
+ "upload.hardBlurb": "Analysis & tricky edges",
+ "upload.activities": "Activities",
+ "upload.readingDocs": "Reading your documents…",
+ "upload.ready": "Ready when you are — generation can take a minute.",
+ "upload.addToStart": "Add a photo or a document (or some text) to start.",
+ "upload.freezeDeck": "Freeze my deck",
+ "upload.generationProgress": "Generation progress",
+ "upload.cancel": "Cancel",
+ "upload.unsupported":
+ "“{name}” is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.",
+ "upload.tooManyDocs": "Up to {n} documents per deck.",
+ "upload.tooManyPhotos": "Up to {n} photos per deck.",
+ "upload.couldNotReadFile": "Could not read “{name}”.",
+ "upload.couldNotReadDoc": "Could not read this file.",
+ "upload.genericError":
+ "Something went wrong while generating your deck. Check your Ollama settings and try again.",
+ "upload.warming": "Warming up…",
+ "upload.vision1": "Reading your notes…",
+ "upload.vision2": "Transcribing handwriting…",
+ "upload.vision3": "Spotting definitions…",
+ "upload.gen1": "Finding the key ideas…",
+ "upload.gen2": "Writing good questions…",
+ "upload.gen3": "Building your activities…",
+ "upload.tip1": "Tip: swipe up and down through your deck like a feed.",
+ "upload.tip2": "Tip: matching questions only give full XP for a perfect match.",
+ "upload.tip3": "Tip: your decks are saved in this browser — no account needed.",
+ "upload.tip4": "Tip: export a deck as JSON to share it with a friend.",
+
+ /* study feed ------------------------------------------------------ */
+ "feed.back": "Back to your library",
+ "feed.activityOf": "Activity {n} of {total}",
+ "feed.streak": "Answer streak: {n}",
+ "feed.xp": "XP earned: {n}",
+ "feed.retry": "Retry · ",
+ "feed.swipeUp": "Swipe up for the next activity ↑",
+ "feed.prev": "Previous activity",
+ "feed.next": "Next activity",
+ "feed.results": "Results",
+
+ /* results --------------------------------------------------------- */
+ "results.perfect": "Perfect freeze! 🧊",
+ "results.nice": "Nicely done!",
+ "results.start": "Good start — keep going!",
+ "results.acedAll": "You aced all {n} activities in “{title}”.",
+ "results.gotOf": "You got {correct} of {total} right{retry}.",
+ "results.retrySuffix": " on the retry run",
+ "results.xpEarned": "{n} XP earned",
+ "results.bestStreak": "Best streak {n}",
+ "results.difficulty": "{level} difficulty",
+ "results.best": "🏆 Best {n}%",
+ "results.missed": "Missed items ({n})",
+ "results.yourAnswer": "Your answer: {answer}",
+ "results.answer": "Answer:",
+ "results.retryMissed": "Retry missed ones ({n})",
+ "results.studyAgain": "Study this deck again",
+ "results.studyWhole": "Study the whole deck again",
+ "results.regenerate": "Regenerate deck",
+ "results.regenerating": "Regenerating…",
+ "results.export": "Export JSON",
+ "results.score": "score",
+ "results.true": "True",
+ "results.false": "False",
+
+ /* gate ------------------------------------------------------------ */
+ "gate.title": "Connect your Ollama key",
+ "gate.subtitle": "One step to freeze your first deck.",
+ "gate.apiKeyLabel": "Ollama API key",
+ "gate.placeholder": "Paste your Ollama API key",
+ "gate.hide": "Hide API key",
+ "gate.show": "Show API key",
+ "gate.checking": "Checking…",
+ "gate.continue": "Continue",
+ "gate.howGet": "How do I get a key?",
+ "gate.howGetAria": "How do I get an Ollama API key?",
+ "gate.stays": "Your key stays in this browser. Our server never stores it.",
+ "gate.helpTitle": "How to get your Ollama API key",
+ "gate.freeTier.before": "You only need the",
+ "gate.freeTier.strong": "free cloud tier",
+ "gate.freeTier.after": ". No payment and no credit card required.",
+ "gate.step1.before": "Go to",
+ "gate.step1.after": "and sign in, or create a free account.",
+ "gate.step2.before": "Open Settings → API keys at",
+ "gate.step2.after": ".",
+ "gate.step3.before": "Click",
+ "gate.step3.strong": "“Create new key”",
+ "gate.step3.after": ", give it any name, and copy it.",
+ "gate.step4": "Paste it into Freezmo and press Continue.",
+ "gate.footnote":
+ "The free tier is enough for generating decks. You can swap or remove your key anytime in Settings.",
+ "gate.gotIt": "Got it",
+
+ /* install sheet --------------------------------------------------- */
+ "install.titleIos": "Install Freezmo on iPhone",
+ "install.titleAndroid": "Install Freezmo on Android",
+ "install.install": "Install",
+ "install.installing": "Installing…",
+ "install.orManually": "Or install manually:",
+ "install.maybeLater": "Maybe later",
+ "install.openIn": "Open this page in",
+ "install.tap": "Tap",
+ "install.tapConfirm": "Tap",
+ "install.inBrowserBar": "in the browser bar",
+ "install.then": ", then",
+ "install.choose": "Choose",
+ "install.toConfirm": "to confirm",
+ "install.or": "or",
+ "install.inTopRight": "in the top right of the browser",
+ "install.moreOptions": "More options",
+ "install.share": "Share",
+ "install.more": "More",
+ "install.addToHome": "Add to Home Screen",
+ "install.add": "Add",
+ "install.installApp": "Install app",
+ "install.addToHomeScreen": "Add to Home screen",
+
+ /* deck page ------------------------------------------------------- */
+ "deckPage.loading": "Loading your deck…",
+ "deckPage.notFound": "Deck not found",
+ "deckPage.notFoundBody":
+ "This deck isn't in this browser's library. It may have been deleted, or it was created in a different browser profile.",
+ "deckPage.back": "Back to library",
+ "deckPage.create": "Create a deck",
+
+ /* offline --------------------------------------------------------- */
+ "offline.title": "You're offline",
+ "offline.body":
+ "Freezmo needs a connection for generating decks and grading answers. Your saved decks are still in this browser — reconnect and reload.",
+
+ /* activity types -------------------------------------------------- */
+ "type.flashcard": "Flashcard",
+ "type.multipleChoice": "Multiple choice",
+ "type.trueFalse": "True or false",
+ "type.fillBlank": "Fill the blank",
+ "type.matching": "Match up",
+ "type.ordering": "Put in order",
+ "type.shortAnswer": "Short answer",
+
+ /* activities shared ----------------------------------------------- */
+ "activity.answerOptions": "Answer options",
+ "activity.wordBank": "Word bank",
+ "activity.niceOne": "Nice one!",
+ "activity.notQuite": "Not quite",
+ "activity.why": "Why:",
+ "activity.seeResults": "See results",
+ "activity.next": "Next",
+ "activity.correctAnswer": "Correct answer:",
+ "activity.unsupported": "Unsupported activity: {json}",
+
+ /* flashcard ------------------------------------------------------- */
+ "flashcard.tapToFlip": "Tap the card to flip it.",
+ "flashcard.didYouKnow": "Did you know it before flipping?",
+ "flashcard.question": "Question",
+ "flashcard.answer": "Answer",
+ "flashcard.tapFlip": "tap to flip ↻",
+ "flashcard.flipBack": "Flip back to the question",
+ "flashcard.reveal": "Reveal the answer",
+ "flashcard.notYet": "😅 Not yet",
+ "flashcard.knewIt": "🧊 I knew it",
+ "flashcard.frozenSolid": "Frozen solid!",
+ "flashcard.keepPile": "Keep it on the pile",
+
+ /* multiple choice ------------------------------------------------ */
+ "mc.tipKeys": "Tip: press 1–4 on a keyboard.",
+
+ /* true/false ------------------------------------------------------ */
+ "tf.hint": "Swipe the card right for true, left for false — or tap a button.",
+ "tf.niceSwipe": "Nice swipe!",
+ "tf.trueLabel": "✓ true",
+ "tf.falseLabel": "false ✕",
+ "tf.falseBtn": "✕ False",
+ "tf.trueBtn": "✓ True",
+ "tf.statementIs.before": "This statement is",
+ "tf.true": "true",
+ "tf.false": "false",
+
+ /* fill blank ------------------------------------------------------ */
+ "fill.typeMissing": "Type the missing word",
+ "fill.placeholder": "type it",
+ "fill.check": "Check answer",
+ "fill.hint": "Press Enter to check. Small typos are forgiven.",
+ "fill.expected": "Expected:",
+ "fill.youWrote": "— you wrote",
+
+ /* matching -------------------------------------------------------- */
+ "matching.matched": "{n}/{total} matched",
+ "matching.allMatched": "All matched!",
+ "matching.tapTerm": "Tap a term on the left, then its match on the right.",
+ "matching.tapDef": "Now tap the matching definition →",
+ "matching.perfect": "Perfect match!",
+ "matching.slips": "Matched — with a few slips",
+ "matching.firstTry": "You matched everything first try.",
+ "matching.wrongAttempts.one": "You made {n} wrong attempt.",
+ "matching.wrongAttempts.few": "You made {n} wrong attempts.",
+ "matching.wrongAttempts.many": "You made {n} wrong attempts.",
+
+ /* ordering -------------------------------------------------------- */
+ "ordering.hint": "Drag the cards, or use ▲▼, then check your order.",
+ "ordering.correctOrder": "Here is the correct order:",
+ "ordering.check": "Check order",
+ "ordering.moveUp": "Move “{item}” up",
+ "ordering.moveDown": "Move “{item}” down",
+
+ /* short answer ---------------------------------------------------- */
+ "short.placeholder": "Type your answer in your own words…",
+ "short.ctrlEnter": "Ctrl/⌘ + Enter to submit.",
+ "short.submit": "Submit answer",
+ "short.grading": "Grading your answer…",
+ "short.couldNotGrade": "Couldn't grade that answer",
+ "short.showAnswer": "Show answer & self-check",
+ "short.modelAnswer": "Model answer",
+ "short.keyPoints": "Key points:",
+ "short.missedIt": "😅 I missed it",
+ "short.hadRight": "🧊 I had it right",
+ "short.accepted": "Accepted",
+ "short.needsWork": "Needs work",
+ "short.selfChecked": "Self-checked — you judged this one yourself.",
+ "short.yourAnswer": "Your answer",
+ "short.gradeFailed": "Grading failed. Check that Ollama is reachable and try again.",
+
+ /* client API errors ----------------------------------------------- */
+ "api.cancelled": "Generation was cancelled.",
+ "api.unreachable": "Could not reach the Freezmo server. Is it still running?",
+ "api.unreachableShort": "Could not reach the Freezmo server.",
+ "api.httpError": "The server returned HTTP {status}.",
+ "api.empty": "The server sent an empty response.",
+ "api.almost": "Almost there…",
+ "api.noDeck": "No deck came back from the server. Check your Ollama settings and try again.",
+ "api.incomplete": "The server finished without sending a deck.",
+ "api.noActivities": "The generated deck had no usable activities. Try again.",
+ "api.gradingCancelled": "Grading was cancelled.",
+ "api.gradingFailed": "Grading failed with HTTP {status}.",
+ "api.healthFailed": "The health check failed with HTTP {status}.",
+ "api.healthUnexpected": "The Freezmo server sent an unexpected health response.",
+ "api.serverUnexpected": "The Freezmo server answered unexpectedly. Try again in a moment.",
+ "api.keyRejected": "Ollama rejected this key. Check for typos, or create a new one at ollama.com/settings/keys.",
+ "api.ollamaUnreachable": "Couldn't reach Ollama just now. Check your connection and try again.",
+
+ /* server-side messages (API routes) -------------------------------- */
+ "server.bodyNotJson": "Request body was not valid JSON.",
+ "server.invalidRequest": "Invalid request: {detail}",
+ "server.needContent": "Add at least one photo of your notes or paste some text to build a deck.",
+ "server.invalidGrade": "Invalid grading request.",
+ "server.bodyTooLarge": "Request body is too large ({size}). Limit is {limit}.",
+ "server.invalidOrigin": "Invalid Origin header.",
+ "server.crossOrigin": "Cross-origin requests are not allowed.",
+ "server.rateLimited": "Too many requests — this app allows {limit} per window. Try again in {retry}s.",
+ "server.authMissingKey":
+ "Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
+ "server.authServerKeyMissing":
+ "This server is configured to use its own Ollama API key (OLLAMA_AUTH=server), but OLLAMA_API_KEY is empty in .env.local.",
+ "server.authLocalCloud":
+ "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.",
+ "server.ollamaUnreachable":
+ "Ollama isn't reachable at {url}. Check your settings and make sure the daemon is running.",
+ "server.ollamaTimeout": "Ollama took too long to answer. Try fewer/smaller images, a faster model, or retry.",
+ "server.ollamaAuth":
+ "Ollama rejected the request. If you are using https://ollama.com, check OLLAMA_API_KEY (create one at ollama.com/settings/keys).",
+ "server.ollamaUnexpected": "Ollama returned an unexpected response.",
+ "server.ollamaGeneric": "Something went wrong while talking to Ollama.",
+ "server.ollamaTimeoutList": "Timed out while listing Ollama models.",
+ "server.ollamaUnauthorised": "Ollama refused the request (unauthorised).",
+ "server.ollamaNotJson": "Ollama sent a response that wasn't JSON.",
+ "server.ollamaNoContent": "Ollama sent a response with no message content.",
+ "server.healthNoKey":
+ "No Ollama API key yet. Add your key in the settings dialog — it is stored only in this browser.",
+ "server.healthKeyRejected": "Ollama rejected this API key. Create a new one at ollama.com/settings/keys and paste it again.",
+ "server.healthNoCredits":
+ "The key works, but {model} is not included in this account's plan. Add usage credits or switch to a free-tier model in .env.local.",
+ "server.healthModelNoteLocal": "Not in the daemon's model list. Run `ollama pull {model}` or check your sign-in.",
+ "server.healthModelNoteCloud": "Not returned by {url}/api/tags. Check the model name and your key's plan.",
+ "server.healthModelsMissing": "Ollama is reachable, but at least one configured model is not available.",
+
+ /* folders / files -------------------------------------------------- */
+ "folder.nameRequired": "Give the folder a name.",
+ "folder.nameTooLong": "That name is a bit long (60 characters max).",
+ "folder.nameTaken": "Another folder already has that name.",
+ "file.notImage": "“{name}” is not an image.",
+ "file.imageTooLarge": "“{name}” is larger than 20 MB.",
+ "file.notDocument": "“{name}” is not a supported document (use PDF, DOCX, PPTX, ODT/ODP or text).",
+ "file.documentTooLarge": "“{name}” is larger than 40 MB.",
+ "import.notJson": "That file is not valid JSON.",
+ "import.notDeck": "That file doesn't look like a Freezmo deck.",
+ "doc.readingPage": "Reading page {n} of {total}…",
+ "doc.readingWord": "Reading the Word document…",
+ "doc.readingSlides": "Reading the slides…",
+ "doc.readingOpen": "Reading the OpenDocument file…",
+ "doc.readingText": "Reading the text file…",
+};
+
+const cs: Record = {
+ /* common ---------------------------------------------------------- */
+ "common.cancel": "Zrušit",
+ "common.save": "Uložit",
+ "common.add": "Přidat",
+ "common.delete": "Smazat",
+ "common.rename": "Přejmenovat",
+ "common.close": "Zavřít",
+ "common.retry": "Zkusit znovu",
+ "common.settings": "Nastavení",
+ "common.library": "Knihovna",
+ "common.newDeck": "Nová sada",
+ "common.folder": "Složka",
+ "common.folders": "Složky",
+ "common.newFolder": "Nová složka",
+ "common.folderName": "Název složky",
+ "common.newFolderName": "Název nové složky",
+ "common.unorganized": "Nezařazené",
+ "common.allDecks": "Všechny sady",
+ "common.lightMode": "Světlý režim",
+ "common.darkMode": "Tmavý režim",
+ "common.switchToLight": "Přepnout na světlý režim",
+ "common.switchToDark": "Přepnout na tmavý režim",
+ "common.menu": "Menu",
+
+ /* header ---------------------------------------------------------- */
+ "header.openMenu": "Otevřít menu",
+ "header.home": "Domů — Freezmo",
+ "header.tagline": "studijní feed",
+ "header.openSettings": "Otevřít nastavení a stav Ollamy",
+
+ /* tab bar --------------------------------------------------------- */
+ "tabbar.main": "Hlavní",
+
+ /* sidebar --------------------------------------------------------- */
+ "sidebar.collapse": "Sbalit panel",
+ "sidebar.expand": "Rozbalit panel",
+ "sidebar.library": "Knihovna",
+ "sidebar.label": "Postranní panel",
+ "sidebar.closeMenu": "Zavřít menu",
+
+ /* language -------------------------------------------------------- */
+ "language.label": "Jazyk",
+ "language.note": "Jazyk rozhraní. Tvoje poznámky a vytvořené sady si zachovávají vlastní jazyk.",
+
+ /* settings -------------------------------------------------------- */
+ "settings.title": "Nastavení",
+ "settings.subtitle.before": "Modely pocházejí z",
+ "settings.subtitle.after": "na serveru; tvůj API klíč zůstává v tomto prohlížeči.",
+ "settings.close": "Zavřít nastavení",
+ "settings.apiKey": "API klíč Ollama",
+ "settings.savedInBrowser": "uložen v tomto prohlížeči",
+ "settings.required": "vyžadován",
+ "settings.notNeeded": "není potřeba",
+ "settings.pasteKey": "vlož klíč z ollama.com/settings/keys",
+ "settings.hide": "Skrýt",
+ "settings.show": "Zobrazit",
+ "settings.keyNote":
+ "Ukládá se jen do místního úložiště tohoto prohlížeče a posílá se s každým požadavkem, aby server mohl komunikovat s Ollamou. Nikdy se neukládá na server, nezapisuje do logů ani do cookies. Radši místní daemon? Nastav",
+ "settings.keyNoteAfter": "v",
+ "settings.keyNoteEnd": "a použij",
+ "settings.keyNoteEnd2": ".",
+ "settings.testConnection": "Otestovat připojení",
+ "settings.removeKey": "Odstranit klíč z prohlížeče",
+ "settings.savedTesting": "Uloženo — testuji…",
+ "settings.installApp": "Nainstalovat aplikaci",
+ "settings.addToHome": "Přidat Freezmo na domovskou obrazovku",
+ "settings.checking": "Kontroluji Ollamu…",
+
+ /* health ---------------------------------------------------------- */
+ "health.ok": "Ollama je dostupná a oba modely jsou k dispozici.",
+ "health.needsKey": "Čeká se na tvůj API klíč.",
+ "health.rejected": "Ollama odmítla tvůj API klíč.",
+ "health.modelMissing": "Ollama je dostupná, ale nakonfigurovaný model chybí.",
+ "health.unreachable": "Ollama není dostupná.",
+ "health.auth": "Autorizace",
+ "health.authUserKey": "Vlastní klíč",
+ "health.authServer": "Klíč serveru (.env)",
+ "health.authLocal": "Přihlášení místního daemonu",
+ "health.endpoint": "Endpoint",
+ "health.mode": "Režim",
+ "health.modeCloud": "Přímé cloudové API",
+ "health.modeLocal": "Místní daemon",
+ "health.key": "Klíč",
+ "health.keyMissing": "chybí",
+ "health.keyAccepted": "přijat",
+ "health.keyRejected": "odmítnut",
+ "health.keyNotNeeded": "není potřeba",
+ "health.keyUnknown": "neznámý",
+ "health.visionModel": "Vizuální model (čte fotky)",
+ "health.textModel": "Textový model (tvoří a známkuje)",
+ "health.available": "dostupný",
+ "health.notFound": "nenalezen",
+ "health.unknown": "neznámý",
+ "health.configuredAs": "nakonfigurováno jako {model}",
+ "health.createKey.before": "Vytvoř klíč na",
+ "health.createKey.after": "a vlož ho výše. Tip: s místním daemonem můžeš spustit",
+ "health.createKey.end": "a nastavit",
+
+ /* library --------------------------------------------------------- */
+ "library.title": "Tvoje mraznička",
+ "library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.",
+ "library.import": "Importovat JSON",
+ "library.folderCreated": "Složka vytvořena — sady, které zmrazíš, když je vybraná, přistanou tady.",
+ "library.imported": "Importováno „{title}“.",
+ "library.couldNotRead": "Tento soubor se nepodařilo přečíst.",
+ "library.folderDeleted": "Složka smazána — její sady se přesunuly do Nezařazených.",
+ "library.deckMoved": "Sada přesunuta.",
+ "library.deckMovedUnorganized": "Sada přesunuta do Nezařazených.",
+ "library.noDecks": "Zatím žádné sady",
+ "library.makeFirst": "Vytvoř si první sadu",
+ "library.emptyBody": "Zmraz stránku poznámek do feedu kartiček a kvízů.",
+ "library.emptyHint": "S tvými vlastními modely Ollama —",
+ "library.emptySettings": "zkontroluj připojení v Nastavení",
+ "library.renameFolderAria": "Název složky",
+ "library.nothingUnfiled": "Teď tu nic nezařazeného není.",
+ "library.emptyFolder": "Prázdná složka — zmraz do ní sadu nebo ji sem přesuň z Nezařazených.",
+ "library.stats.best": "nejlepší {n} %",
+ "library.deleteWarning.one": "Sada uvnitř ({n}) se přesune do Nezařazených — nic se nesmaže.",
+ "library.deleteWarning.few": "Sady uvnitř ({n}) se přesunou do Nezařazených — nic se nesmaže.",
+ "library.deleteWarning.many": "Sad uvnitř ({n}) se přesune do Nezařazených — nic se nesmaže.",
+ "stats.decks.one": "{n} sada",
+ "stats.decks.few": "{n} sady",
+ "stats.decks.many": "{n} sad",
+ "stats.folders.one": "{n} složka",
+ "stats.folders.few": "{n} složky",
+ "stats.folders.many": "{n} složek",
+ "stats.activities.one": "{n} aktivita",
+ "stats.activities.few": "{n} aktivity",
+ "stats.activities.many": "{n} aktivit",
+
+ /* deck card ------------------------------------------------------- */
+ "deck.activities.one": "{n} aktivita",
+ "deck.activities.few": "{n} aktivity",
+ "deck.activities.many": "{n} aktivit",
+ "deck.runs.one": "{n} běh",
+ "deck.runs.few": "{n} běhy",
+ "deck.runs.many": "{n} běhů",
+ "deck.notPlayed": "Zatím nehráno",
+ "deck.best": "Nejlepší {n} %",
+ "deck.bestLabel": "Nejlepší",
+ "deck.study": "Studovat",
+ "deck.actions": "Akce sady",
+ "deck.titleLabel": "Název sady",
+ "deck.rename": "Přejmenovat",
+ "deck.export": "Exportovat JSON",
+ "deck.regenerate": "Znovu vytvořit sadu",
+ "deck.reallyDelete": "Opravdu smazat",
+ "deck.delete": "Smazat sadu",
+ "deck.newFolder": "+ Nová složka",
+ "deck.addMore": "Přidat další aktivity",
+ "deck.generate": "Vygenerovat",
+ "deck.noSource": "Tato sada nemá uložený zdrojový text (importované sady ho nenesou).",
+ "deck.noSourceExtend": "Tato sada nemá uložené zdrojové poznámky, takže ji nelze rozšířit.",
+ "deck.noSourceRegenerate": "Tato sada nemá uložené zdrojové poznámky, takže ji nelze znovu vytvořit.",
+ "deck.couldNotAdd": "Další aktivity se nepodařilo přidat.",
+ "deck.couldNotRegenerate": "Sadu se nepodařilo znovu vytvořit.",
+ "deck.regenerated": "Sada znovu vytvořena",
+ "deck.added.one": "Přidána {n} aktivita",
+ "deck.added.few": "Přidány {n} aktivity",
+ "deck.added.many": "Přidáno {n} aktivit",
+ "deck.addingActivities": "Přidávám aktivity",
+ "deck.regeneratingDeck": "Znovu vytvářím sadu",
+ "deck.addingNew": "Přidávám nové aktivity…",
+ "deck.regeneratingWhole": "Znovu vytvářím celou sadu…",
+
+ /* upload ---------------------------------------------------------- */
+ "upload.title": "Proměň poznámky ve studijní feed",
+ "upload.intro":
+ "Vyfoť nebo přetáhni fotky svých poznámek. Freezmo je přečte tvým vlastním vizuálním modelem Ollama a postaví prolistovatelnou sadu mini her.",
+ "upload.errNeedsKey": "Je vyžadován API klíč",
+ "upload.errRate": "Zpomal na chvíli",
+ "upload.errAuth": "Ollama odmítla tvůj klíč",
+ "upload.errFailed": "Vytváření selhalo",
+ "upload.openSettings": "Otevřít nastavení",
+ "upload.addPhotosAria": "Přidat fotky poznámek",
+ "upload.step1": "1. Přidej poznámky",
+ "upload.step1Help":
+ "Přetáhni soubory, vlož screenshot (Ctrl/⌘+V) nebo použij foťák. Fotky, PDF, Word, PowerPoint, OpenDocument a textové soubory. Až {images} fotek a {documents} dokumentů.",
+ "upload.chooseFiles": "Vybrat soubory",
+ "upload.camera": "Foťák",
+ "upload.dropHere": "Přetáhni poznámky sem",
+ "upload.formats": "Fotky, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · obrázky se před odesláním zmenší na 1600 px",
+ "upload.remove": "Odebrat {name}",
+ "upload.addMorePhotos": "Přidat další fotky",
+ "upload.reading": "Čtu…",
+ "upload.noReadable": "Žádný čitelný text — není to sken bez textové vrstvy?",
+ "upload.words.one": "{n} slovo",
+ "upload.words.few": "{n} slova",
+ "upload.words.many": "{n} slov",
+ "upload.pages.one": "{n} strana",
+ "upload.pages.few": "{n} strany",
+ "upload.pages.many": "{n} stran",
+ "upload.pageImages.one": "{n} obrázek stránky",
+ "upload.pageImages.few": "{n} obrázky stránek",
+ "upload.pageImages.many": "{n} obrázků stránek",
+ "upload.truncated": "zkráceno",
+ "upload.photoSummary.one": "{n} fotka · {size} po komprimaci",
+ "upload.photoSummary.few": "{n} fotky · {size} po komprimaci",
+ "upload.photoSummary.many": "{n} fotek · {size} po komprimaci",
+ "upload.docSummary.one": "{n} dokument",
+ "upload.docSummary.few": "{n} dokumenty",
+ "upload.docSummary.many": "{n} dokumentů",
+ "upload.docImages.one": "{n} obrázek stránky",
+ "upload.docImages.few": "{n} obrázky stránek",
+ "upload.docImages.many": "{n} obrázků stránek",
+ "upload.skipImages.one": "{n} obrázek bude vynechán (max {max} na sadu)",
+ "upload.skipImages.few": "{n} obrázky budou vynechány (max {max} na sadu)",
+ "upload.skipImages.many": "{n} obrázků bude vynecháno (max {max} na sadu)",
+ "upload.compressing": "Komprimuji fotky…",
+ "upload.step2": "2. Nepovinné doplňky",
+ "upload.step2Help": "Vlož další poznámky nebo AI napověz, na co se zaměřit.",
+ "upload.pastedNotes": "Vložené poznámky nebo text",
+ "upload.pastedPlaceholder": "Vlož sem text z přednášky, shrnutí nebo další fakta…",
+ "upload.topicHint": "Nápověda k tématu (nepovinné)",
+ "upload.topicPlaceholder": "např. zaměř se na vzorce nebo na data",
+ "upload.step3": "3. Obtížnost a velikost",
+ "upload.freezeInto": "Zmrazit do složky",
+ "upload.folderPlaceholder": "např. Dějepis",
+ "upload.difficultyAria": "Obtížnost",
+ "upload.easy": "Snadná",
+ "upload.easyBlurb": "Vybavování a rozpoznávání",
+ "upload.medium": "Střední",
+ "upload.mediumBlurb": "Porozumění a použití",
+ "upload.hard": "Těžká",
+ "upload.hardBlurb": "Analýza a záludnosti",
+ "upload.activities": "Aktivity",
+ "upload.readingDocs": "Čtu dokumenty…",
+ "upload.ready": "Můžeme začít — vytváření může chvíli trvat.",
+ "upload.addToStart": "Začni přidáním fotky, dokumentu nebo textu.",
+ "upload.freezeDeck": "Zmrazit sadu",
+ "upload.generationProgress": "Průběh vytváření",
+ "upload.cancel": "Zrušit",
+ "upload.unsupported":
+ "„{name}“ není podporovaný formát. Přidej fotky nebo soubor PDF, DOCX, PPTX, ODT/ODP, TXT či MD.",
+ "upload.tooManyDocs": "Až {n} dokumentů na sadu.",
+ "upload.tooManyPhotos": "Až {n} fotek na sadu.",
+ "upload.couldNotReadFile": "Soubor „{name}“ se nepodařilo přečíst.",
+ "upload.couldNotReadDoc": "Tento soubor se nepodařilo přečíst.",
+ "upload.genericError":
+ "Při vytváření sady se něco pokazilo. Zkontroluj nastavení Ollamy a zkus to znovu.",
+ "upload.warming": "Zahřívám…",
+ "upload.vision1": "Čtu poznámky…",
+ "upload.vision2": "Přepisuji rukopis…",
+ "upload.vision3": "Hledám definice…",
+ "upload.gen1": "Hledám klíčové myšlenky…",
+ "upload.gen2": "Píšu dobré otázky…",
+ "upload.gen3": "Stavím aktivity…",
+ "upload.tip1": "Tip: prolistuj sadu nahoru a dolů jako feed.",
+ "upload.tip2": "Tip: přiřazovací otázky dají plné XP jen za perfektní shodu.",
+ "upload.tip3": "Tip: sady se ukládají v tomto prohlížeči — účet není potřeba.",
+ "upload.tip4": "Tip: exportuj sadu jako JSON a pošli ji kamarádovi.",
+
+ /* study feed ------------------------------------------------------ */
+ "feed.back": "Zpět do knihovny",
+ "feed.activityOf": "Aktivita {n} z {total}",
+ "feed.streak": "Série odpovědí: {n}",
+ "feed.xp": "Získané XP: {n}",
+ "feed.retry": "Opakování · ",
+ "feed.swipeUp": "Přejeď nahoru na další aktivitu ↑",
+ "feed.prev": "Předchozí aktivita",
+ "feed.next": "Další aktivita",
+ "feed.results": "Výsledky",
+
+ /* results --------------------------------------------------------- */
+ "results.perfect": "Perfektní zmrazení! 🧊",
+ "results.nice": "Pěkně!",
+ "results.start": "Dobrý začátek — jen dál!",
+ "results.acedAll": "Zvládl jsi všech {n} aktivit v sadě „{title}“.",
+ "results.gotOf": "Máš {correct} z {total} správně{retry}.",
+ "results.retrySuffix": " v opakování",
+ "results.xpEarned": "Získáno {n} XP",
+ "results.bestStreak": "Nejlepší série {n}",
+ "results.difficulty": "Obtížnost: {level}",
+ "results.best": "🏆 Nejlepší {n} %",
+ "results.missed": "Chybné položky ({n})",
+ "results.yourAnswer": "Tvoje odpověď: {answer}",
+ "results.answer": "Odpověď:",
+ "results.retryMissed": "Zopakovat chybné ({n})",
+ "results.studyAgain": "Studovat sadu znovu",
+ "results.studyWhole": "Studovat celou sadu znovu",
+ "results.regenerate": "Znovu vytvořit sadu",
+ "results.regenerating": "Znovu vytvářím…",
+ "results.export": "Exportovat JSON",
+ "results.score": "skóre",
+ "results.true": "Pravda",
+ "results.false": "Nepravda",
+
+ /* gate ------------------------------------------------------------ */
+ "gate.title": "Připoj svůj klíč Ollama",
+ "gate.subtitle": "Jeden krok k tvé první zmrazené sadě.",
+ "gate.apiKeyLabel": "API klíč Ollama",
+ "gate.placeholder": "Vlož svůj API klíč Ollama",
+ "gate.hide": "Skrýt API klíč",
+ "gate.show": "Zobrazit API klíč",
+ "gate.checking": "Kontroluji…",
+ "gate.continue": "Pokračovat",
+ "gate.howGet": "Jak získám klíč?",
+ "gate.howGetAria": "Jak získám API klíč Ollama?",
+ "gate.stays": "Tvůj klíč zůstává v tomto prohlížeči. Náš server ho nikdy neukládá.",
+ "gate.helpTitle": "Jak získat API klíč Ollama",
+ "gate.freeTier.before": "Potřebuješ jen",
+ "gate.freeTier.strong": "bezplatnou cloudovou úroveň",
+ "gate.freeTier.after": ". Žádná platba ani kreditní karta.",
+ "gate.step1.before": "Přejdi na",
+ "gate.step1.after": "a přihlas se, nebo si vytvoř zdarma účet.",
+ "gate.step2.before": "Otevři Settings → API keys na",
+ "gate.step2.after": ".",
+ "gate.step3.before": "Klikni na",
+ "gate.step3.strong": "„Create new key“",
+ "gate.step3.after": ", dej mu libovolný název a zkopíruj ho.",
+ "gate.step4": "Vlož ho do Freezma a stiskni Pokračovat.",
+ "gate.footnote":
+ "Bezplatná úroveň na vytváření sad stačí. Klíč můžeš kdykoli změnit nebo odebrat v Nastavení.",
+ "gate.gotIt": "Rozumím",
+
+ /* install sheet --------------------------------------------------- */
+ "install.titleIos": "Nainstalovat Freezmo na iPhone",
+ "install.titleAndroid": "Nainstalovat Freezmo na Android",
+ "install.install": "Nainstalovat",
+ "install.installing": "Instaluji…",
+ "install.orManually": "Nebo nainstaluj ručně:",
+ "install.maybeLater": "Možná později",
+ "install.openIn": "Otevři tuto stránku v",
+ "install.tap": "Klepni na",
+ "install.tapConfirm": "Klepnutím na",
+ "install.inBrowserBar": "v liště prohlížeče",
+ "install.then": "a pak na",
+ "install.choose": "Zvol",
+ "install.toConfirm": "potvrď",
+ "install.or": "nebo",
+ "install.inTopRight": "vpravo nahoře v prohlížeči",
+ "install.moreOptions": "Další možnosti",
+ "install.share": "Sdílet",
+ "install.more": "Další",
+ "install.addToHome": "Přidat na domovskou obrazovku",
+ "install.add": "Přidat",
+ "install.installApp": "Nainstalovat aplikaci",
+ "install.addToHomeScreen": "Přidat na domovskou obrazovku",
+
+ /* deck page ------------------------------------------------------- */
+ "deckPage.loading": "Načítám sadu…",
+ "deckPage.notFound": "Sada nenalezena",
+ "deckPage.notFoundBody":
+ "Tato sada není v knihovně tohoto prohlížeče. Možná byla smazána, nebo vznikla v jiném profilu prohlížeče.",
+ "deckPage.back": "Zpět do knihovny",
+ "deckPage.create": "Vytvořit sadu",
+
+ /* offline --------------------------------------------------------- */
+ "offline.title": "Jsi offline",
+ "offline.body":
+ "Freezmo potřebuje připojení k vytváření sad a známkování odpovědí. Uložené sady máš pořád v tomto prohlížeči — připoj se a načti stránku znovu.",
+
+ /* activity types -------------------------------------------------- */
+ "type.flashcard": "Kartička",
+ "type.multipleChoice": "Výběr z možností",
+ "type.trueFalse": "Pravda nebo nepravda",
+ "type.fillBlank": "Doplň slovo",
+ "type.matching": "Přiřaď k sobě",
+ "type.ordering": "Seřaď",
+ "type.shortAnswer": "Krátká odpověď",
+
+ /* activities shared ----------------------------------------------- */
+ "activity.answerOptions": "Možnosti odpovědi",
+ "activity.wordBank": "Nabídka slov",
+ "activity.niceOne": "Pěkně!",
+ "activity.notQuite": "Skoro",
+ "activity.why": "Proč:",
+ "activity.seeResults": "Zobrazit výsledky",
+ "activity.next": "Další",
+ "activity.correctAnswer": "Správná odpověď:",
+ "activity.unsupported": "Nepodporovaná aktivita: {json}",
+
+ /* flashcard ------------------------------------------------------- */
+ "flashcard.tapToFlip": "Klepnutím na kartu ji otočíš.",
+ "flashcard.didYouKnow": "Věděl jsi to ještě před otočením?",
+ "flashcard.question": "Otázka",
+ "flashcard.answer": "Odpověď",
+ "flashcard.tapFlip": "klepni pro otočení ↻",
+ "flashcard.flipBack": "Otočit zpět na otázku",
+ "flashcard.reveal": "Odhalit odpověď",
+ "flashcard.notYet": "😅 Ještě ne",
+ "flashcard.knewIt": "🧊 Věděl jsem to",
+ "flashcard.frozenSolid": "Zmrazeno na pevno!",
+ "flashcard.keepPile": "Nech to na hromádce",
+
+ /* multiple choice ------------------------------------------------ */
+ "mc.tipKeys": "Tip: stiskni 1–4 na klávesnici.",
+
+ /* true/false ------------------------------------------------------ */
+ "tf.hint": "Přejeď kartu doprava pro pravdu, doleva pro nepravdu — nebo klepni na tlačítko.",
+ "tf.niceSwipe": "Pěkný přejezd!",
+ "tf.trueLabel": "✓ pravda",
+ "tf.falseLabel": "nepravda ✕",
+ "tf.falseBtn": "✕ Nepravda",
+ "tf.trueBtn": "✓ Pravda",
+ "tf.statementIs.before": "Toto tvrzení je",
+ "tf.true": "pravda",
+ "tf.false": "nepravda",
+
+ /* fill blank ------------------------------------------------------ */
+ "fill.typeMissing": "Napiš chybějící slovo",
+ "fill.placeholder": "napiš to",
+ "fill.check": "Zkontrolovat odpověď",
+ "fill.hint": "Stiskni Enter pro kontrolu. Malé překlepy se prominou.",
+ "fill.expected": "Očekáváno:",
+ "fill.youWrote": "— napsal jsi",
+
+ /* matching -------------------------------------------------------- */
+ "matching.matched": "{n}/{total} přiřazeno",
+ "matching.allMatched": "Vše přiřazeno!",
+ "matching.tapTerm": "Klepni vlevo na pojem a pak vpravo na jeho definici.",
+ "matching.tapDef": "Teď klepni na odpovídající definici →",
+ "matching.perfect": "Perfektní přiřazení!",
+ "matching.slips": "Přiřazeno — s pár chybami",
+ "matching.firstTry": "Vše jsi přiřadil na první pokus.",
+ "matching.wrongAttempts.one": "Udělal jsi {n} chybný pokus.",
+ "matching.wrongAttempts.few": "Udělal jsi {n} chybné pokusy.",
+ "matching.wrongAttempts.many": "Udělal jsi {n} chybných pokusů.",
+
+ /* ordering -------------------------------------------------------- */
+ "ordering.hint": "Přetahuj karty nebo použij ▲▼ a pak zkontroluj pořadí.",
+ "ordering.correctOrder": "Toto je správné pořadí:",
+ "ordering.check": "Zkontrolovat pořadí",
+ "ordering.moveUp": "Posunout „{item}“ nahoru",
+ "ordering.moveDown": "Posunout „{item}“ dolů",
+
+ /* short answer ---------------------------------------------------- */
+ "short.placeholder": "Napiš odpověď vlastními slovy…",
+ "short.ctrlEnter": "Ctrl/⌘ + Enter pro odeslání.",
+ "short.submit": "Odeslat odpověď",
+ "short.grading": "Známkuji tvoji odpověď…",
+ "short.couldNotGrade": "Tuto odpověď nešlo oznámkovat",
+ "short.showAnswer": "Zobrazit odpověď a sebehodnocení",
+ "short.modelAnswer": "Vzorová odpověď",
+ "short.keyPoints": "Klíčové body:",
+ "short.missedIt": "😅 Netrefil jsem se",
+ "short.hadRight": "🧊 Měl jsem to správně",
+ "short.accepted": "Přijato",
+ "short.needsWork": "Ještě zapracovat",
+ "short.selfChecked": "Sebehodnocení — tuhle odpověď jsi posoudil sám.",
+ "short.yourAnswer": "Tvoje odpověď",
+ "short.gradeFailed": "Známkování selhalo. Zkontroluj, že je Ollama dostupná, a zkus to znovu.",
+
+ /* client API errors ----------------------------------------------- */
+ "api.cancelled": "Vytváření bylo zrušeno.",
+ "api.unreachable": "Nepodařilo se připojit k serveru Freezmo. Běží ještě?",
+ "api.unreachableShort": "Nepodařilo se připojit k serveru Freezmo.",
+ "api.httpError": "Server vrátil HTTP {status}.",
+ "api.empty": "Server poslal prázdnou odpověď.",
+ "api.almost": "Už to bude…",
+ "api.noDeck": "Ze serveru nepřišla žádná sada. Zkontroluj nastavení Ollamy a zkus to znovu.",
+ "api.incomplete": "Server skončil, aniž by poslal sadu.",
+ "api.noActivities": "Vygenerovaná sada neměla žádné použitelné aktivity. Zkus to znovu.",
+ "api.gradingCancelled": "Známkování bylo zrušeno.",
+ "api.gradingFailed": "Známkování selhalo s HTTP {status}.",
+ "api.healthFailed": "Kontrola stavu selhala s HTTP {status}.",
+ "api.healthUnexpected": "Server Freezmo poslal neočekávanou odpověď o stavu.",
+ "api.serverUnexpected": "Server Freezmo odpověděl neočekávaně. Zkus to za chvíli znovu.",
+ "api.keyRejected": "Ollama tento klíč odmítla. Zkontroluj překlepy, nebo si vytvoř nový na ollama.com/settings/keys.",
+ "api.ollamaUnreachable": "Teď se nepodařilo připojit k Ollamě. Zkontroluj připojení a zkus to znovu.",
+
+ /* server-side messages (API routes) -------------------------------- */
+ "server.bodyNotJson": "Tělo požadavku nebylo platné JSON.",
+ "server.invalidRequest": "Neplatný požadavek: {detail}",
+ "server.needContent": "Přidej aspoň jednu fotku poznámek nebo vlož text, ze kterého sada vznikne.",
+ "server.invalidGrade": "Neplatný požadavek na známkování.",
+ "server.bodyTooLarge": "Tělo požadavku je příliš velké ({size}). Limit je {limit}.",
+ "server.invalidOrigin": "Neplatná hlavička Origin.",
+ "server.crossOrigin": "Požadavky z jiné domény nejsou povoleny.",
+ "server.rateLimited": "Příliš mnoho požadavků — aplikace povoluje {limit} za okno. Zkus to znovu za {retry} s.",
+ "server.authMissingKey":
+ "Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.",
+ "server.authServerKeyMissing":
+ "Tento server má používat vlastní API klíč Ollama (OLLAMA_AUTH=server), ale OLLAMA_API_KEY je v .env.local prázdný.",
+ "server.authLocalCloud":
+ "Tento server má OLLAMA_AUTH=local, ale míří na https://ollama.com. Přihlášený místní daemon se s cloudovou adresou použít nedá — nastav OLLAMA_AUTH=user-key, nebo přepiš OLLAMA_BASE_URL na http://localhost:11434.",
+ "server.ollamaUnreachable":
+ "Ollama není dostupná na {url}. Zkontroluj nastavení a jestli daemon běží.",
+ "server.ollamaTimeout": "Ollamě to trvalo příliš dlouho. Zkus méně nebo menší obrázky, rychlejší model, případně to zopakuj.",
+ "server.ollamaAuth":
+ "Ollama požadavek odmítla. Pokud používáš https://ollama.com, zkontroluj OLLAMA_API_KEY (vytvoř si ho na ollama.com/settings/keys).",
+ "server.ollamaUnexpected": "Ollama vrátila neočekávanou odpověď.",
+ "server.ollamaGeneric": "Při komunikaci s Ollamou se něco pokazilo.",
+ "server.ollamaTimeoutList": "Vypršel čas při načítání modelů Ollama.",
+ "server.ollamaUnauthorised": "Ollama požadavek odmítla (neautorizováno).",
+ "server.ollamaNotJson": "Ollama poslala odpověď, která nebyla JSON.",
+ "server.ollamaNoContent": "Ollama poslala odpověď bez obsahu zprávy.",
+ "server.healthNoKey":
+ "Zatím žádný API klíč Ollama. Přidej klíč v nastavení — ukládá se jen v tomto prohlížeči.",
+ "server.healthKeyRejected": "Ollama tento API klíč odmítla. Vytvoř si nový na ollama.com/settings/keys a vlož ho znovu.",
+ "server.healthNoCredits":
+ "Klíč funguje, ale {model} není součástí tarifu tohoto účtu. Přidej kredity, nebo v .env.local přepni na model zdarma.",
+ "server.healthModelNoteLocal": "Není v seznamu modelů daemonu. Spusť `ollama pull {model}` nebo zkontroluj přihlášení.",
+ "server.healthModelNoteCloud": "Není v odpovědi {url}/api/tags. Zkontroluj název modelu a tarif klíče.",
+ "server.healthModelsMissing": "Ollama je dostupná, ale aspoň jeden nakonfigurovaný model není k dispozici.",
+
+ /* folders / files -------------------------------------------------- */
+ "folder.nameRequired": "Dej složce název.",
+ "folder.nameTooLong": "Ten název je trochu dlouhý (max 60 znaků).",
+ "folder.nameTaken": "Jiná složka už tenhle název má.",
+ "file.notImage": "„{name}“ není obrázek.",
+ "file.imageTooLarge": "„{name}“ je větší než 20 MB.",
+ "file.notDocument": "„{name}“ není podporovaný dokument (použij PDF, DOCX, PPTX, ODT/ODP nebo text).",
+ "file.documentTooLarge": "„{name}“ je větší než 40 MB.",
+ "import.notJson": "Tento soubor není platný JSON.",
+ "import.notDeck": "Tento soubor nevypadá jako sada Freezmo.",
+ "doc.readingPage": "Čtu stranu {n} z {total}…",
+ "doc.readingWord": "Čtu dokument Word…",
+ "doc.readingSlides": "Čtu snímky…",
+ "doc.readingOpen": "Čtu soubor OpenDocument…",
+ "doc.readingText": "Čtu textový soubor…",
+};
+
+/** Used by tests/tools that want the raw tables. */
+export const dictionaries = { en, cs };
diff --git a/lib/i18n.ts b/lib/i18n.ts
new file mode 100644
index 0000000..1f1b0d6
--- /dev/null
+++ b/lib/i18n.ts
@@ -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 {
+ 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),
+ };
+}
diff --git a/lib/image.ts b/lib/image.ts
index 17916f8..199154e 100644
--- a/lib/image.ts
+++ b/lib/image.ts
@@ -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;
}
diff --git a/lib/ollama.ts b/lib/ollama.ts
index 0639a7d..77bc3d0 100644
--- a/lib/ollama.ts
+++ b/lib/ollama.ts
@@ -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 = {
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
index a6c5223..120206b 100644
--- a/lib/rate-limit.ts
+++ b/lib/rate-limit.ts
@@ -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,
},
diff --git a/lib/storage.ts b/lib/storage.ts
index 0f9f92e..36b6e4c 100644
--- a/lib/storage.ts
+++ b/lib/storage.ts
@@ -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).
diff --git a/lib/use-health.ts b/lib/use-health.ts
index fd912e1..40a6221 100644
--- a/lib/use-health.ts
+++ b/lib/use-health.ts
@@ -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);
}
diff --git a/public/sw.js b/public/sw.js
index eb2c435..7044c35 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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`;