Add an English/Czech interface language switch
English stays the default; Czech is a full translation. Only the app chrome is translated - notes and generated decks keep their own language, which is a core feature, not a setting. i18n - lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad). English is the default and the fallback for a missing key. - lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the x-freezmo-locale header. The hook's t/plural are bound to the locale React is rendering with, so the first client render matches the server HTML and then re-renders in the stored language; module-level t is only for event handlers and non-React helpers. - <html lang> follows the choice (inline script + setLocale). UI - Settings -> Language segmented control (English / Cestina), plus an EN/CS button on the key gate for first run. - Every screen translated: sidebar, header, tab bar, library, deck cards, folder bar, create flow, all seven activities, study chrome, results, settings + health panel, API-key gate and help, install sheet, offline and the deck-not-found page. - Plural-aware counts everywhere (decks, folders, activities, runs, photos, documents, pages, words, wrong attempts). Server - API routes and guards (generate, grade, health, http-guard, rate-limit, ollama error mapping) answer in the request's language, so error cards and the health panel never fall back to English mid-sentence. Fixes - A hydration mismatch when a non-default locale was stored silently dropped the .dark class after hydration; render-time t() is now bound to the hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor, DeckCard's folder fallback). - Bumped the service worker cache to freezmo-v4 for this deploy. Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals, settings switch) and a rebuilt regression suite 72/72 (English default, library, create, all 7 activity types, results, settings, install sheet, offline, PWA, security, Czech smoke).
This commit is contained in:
parent
4c2834edac
commit
618ad5fb9c
42 changed files with 1892 additions and 509 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -67,6 +68,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
|||
}
|
||||
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
|
|
@ -121,8 +123,8 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
|||
event.stopPropagation();
|
||||
}}
|
||||
disabled={result !== null}
|
||||
aria-label="Type the missing word"
|
||||
placeholder="type it"
|
||||
aria-label={t("fill.typeMissing")}
|
||||
placeholder={t("fill.placeholder")}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
|
|
@ -142,7 +144,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
|||
</PromptCard>
|
||||
|
||||
{activity.options ? (
|
||||
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
|
||||
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label={t("activity.wordBank")}>
|
||||
{activity.options.map((option) => {
|
||||
const state =
|
||||
result === null
|
||||
|
|
@ -168,13 +170,13 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
|||
) : result === null ? (
|
||||
<div className="flex gap-2">
|
||||
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
|
||||
Check answer
|
||||
{t("fill.check")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result === null && !activity.options ? (
|
||||
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
|
||||
<Hint>{t("fill.hint")}</Hint>
|
||||
) : null}
|
||||
|
||||
{result !== null ? (
|
||||
|
|
@ -186,11 +188,11 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
|||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
Expected: <span className="font-bold">{activity.answer}</span>
|
||||
{t("fill.expected")} <span className="font-bold">{activity.answer}</span>
|
||||
{!result && chosen ? (
|
||||
<>
|
||||
{" "}
|
||||
— you wrote <span className="italic">{chosen}</span>
|
||||
{t("fill.youWrote")} <span className="italic">{chosen}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
|
||||
|
|
@ -30,13 +32,13 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
return (
|
||||
<>
|
||||
<ActivityHeader type="flashcard" topic={activity.topic} />
|
||||
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</Hint>
|
||||
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipped((value) => !value)}
|
||||
className="group relative block w-full text-left [perspective:1400px]"
|
||||
aria-label={flipped ? "Flip back to the question" : "Reveal the answer"}
|
||||
aria-label={flipped ? t("flashcard.flipBack") : t("flashcard.reveal")}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotateX: flipped ? 180 : 0 }}
|
||||
|
|
@ -45,18 +47,18 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
>
|
||||
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||
Question
|
||||
{t("flashcard.question")}
|
||||
</span>
|
||||
<QuestionText>{activity.front}</QuestionText>
|
||||
<span className="mt-2 text-xs font-semibold text-frost-400 dark:text-frost-400">
|
||||
tap to flip ↻
|
||||
{t("flashcard.tapFlip")}
|
||||
</span>
|
||||
</PromptCard>
|
||||
|
||||
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
|
||||
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
|
||||
Answer
|
||||
{t("flashcard.answer")}
|
||||
</span>
|
||||
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
|
||||
{activity.back}
|
||||
|
|
@ -69,10 +71,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
{flipped && result === null ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
|
||||
😅 Not yet
|
||||
{t("flashcard.notYet")}
|
||||
</Button>
|
||||
<Button variant="success" size="lg" onClick={() => settle(true)}>
|
||||
🧊 I knew it
|
||||
{t("flashcard.knewIt")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -83,7 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
headline={result ? "Frozen solid!" : "Keep it on the pile"}
|
||||
headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")}
|
||||
onNext={onNext}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
||||
const { t, plural } = useI18n();
|
||||
const terms = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||
[activity],
|
||||
|
|
@ -60,7 +62,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
topic={activity.topic}
|
||||
extra={
|
||||
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
|
||||
{matched.length}/{total} matched
|
||||
{t("matching.matched", { n: matched.length, total })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
@ -68,10 +70,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<Hint>
|
||||
{done
|
||||
? "All matched!"
|
||||
? t("matching.allMatched")
|
||||
: selectedTerm === null
|
||||
? "Tap a term on the left, then its match on the right."
|
||||
: "Now tap the matching definition →"}
|
||||
? t("matching.tapTerm")
|
||||
: t("matching.tapDef")}
|
||||
</Hint>
|
||||
</PromptCard>
|
||||
|
||||
|
|
@ -136,12 +138,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
|
||||
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
|
||||
detail={
|
||||
<span>
|
||||
{misses === 0
|
||||
? "You matched everything first try."
|
||||
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
|
||||
{misses === 0 ? t("matching.firstTry") : plural(misses, "matching.wrongAttempts")}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
|
||||
import {
|
||||
|
|
@ -23,6 +24,7 @@ export function MultipleChoice({
|
|||
onNext,
|
||||
isLast,
|
||||
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const answered = selected !== null;
|
||||
|
||||
|
|
@ -62,7 +64,7 @@ export function MultipleChoice({
|
|||
<QuestionText>{activity.question}</QuestionText>
|
||||
</PromptCard>
|
||||
|
||||
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
|
||||
<div className="flex flex-col gap-2.5" role="group" aria-label={t("activity.answerOptions")}>
|
||||
{activity.options.map((option, index) => (
|
||||
<OptionTile
|
||||
key={option + index}
|
||||
|
|
@ -76,7 +78,7 @@ export function MultipleChoice({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
|
||||
{!answered ? <Hint>{t("mc.tipKeys")}</Hint> : null}
|
||||
|
||||
{answered ? (
|
||||
<FeedbackPanel
|
||||
|
|
@ -88,7 +90,7 @@ export function MultipleChoice({
|
|||
detail={
|
||||
selected === activity.correctIndex ? null : (
|
||||
<>
|
||||
Correct answer:{" "}
|
||||
{t("activity.correctAnswer")}{" "}
|
||||
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { Reorder } from "framer-motion";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn, shuffle } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
||||
const { t } = useI18n();
|
||||
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
||||
const [items, setItems] = useState<string[]>(initial);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
|
@ -48,7 +50,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
<PromptCard className="py-4">
|
||||
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
|
||||
<Hint>
|
||||
{checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
|
||||
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
|
||||
</Hint>
|
||||
</PromptCard>
|
||||
|
||||
|
|
@ -89,7 +91,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
<span className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move "${item}" up`}
|
||||
aria-label={t("ordering.moveUp", { item })}
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||
|
|
@ -98,7 +100,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Move "${item}" down`}
|
||||
aria-label={t("ordering.moveDown", { item })}
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === items.length - 1}
|
||||
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
|
||||
|
|
@ -116,7 +118,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
|
||||
{!checked ? (
|
||||
<Button size="lg" onClick={check} disabled={items.length < 2}>
|
||||
Check order
|
||||
{t("ordering.check")}
|
||||
</Button>
|
||||
) : (
|
||||
<FeedbackPanel
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApiError, gradeAnswer } from "@/lib/client-api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
|
|
@ -26,6 +27,7 @@ type GradeState =
|
|||
| { status: "self-check" };
|
||||
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
|
@ -61,13 +63,13 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
message:
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Grading failed. Check that Ollama is reachable and try again.",
|
||||
: t("short.gradeFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selfCheck = (correct: boolean) => {
|
||||
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
|
||||
setState({ status: "graded", correct, feedback: t("short.selfChecked"), missing: [] });
|
||||
onAnswered(correct, { userAnswer: value.trim() });
|
||||
};
|
||||
|
||||
|
|
@ -79,7 +81,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
</PromptCard>
|
||||
|
||||
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
|
||||
Your answer
|
||||
{t("short.yourAnswer")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`answer-${activity.id}`}
|
||||
|
|
@ -95,15 +97,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
}}
|
||||
disabled={state.status === "grading" || state.status === "graded"}
|
||||
rows={3}
|
||||
placeholder="Type your answer in your own words…"
|
||||
placeholder={t("short.placeholder")}
|
||||
className="w-full resize-none rounded-2xl border-2 border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-frost-400 focus:border-[var(--focus)] disabled:opacity-70 dark:border-[var(--glass-border)] dark:bg-[var(--surface-strong)] dark:text-frost-50 dark:placeholder:text-frost-400"
|
||||
/>
|
||||
|
||||
{state.status === "idle" ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
|
||||
<Hint>{t("short.ctrlEnter")}</Hint>
|
||||
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
|
||||
Submit answer
|
||||
{t("short.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -111,20 +113,20 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
{state.status === "grading" ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
|
||||
<Spinner className="size-5" />
|
||||
Grading your answer…
|
||||
{t("short.grading")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.status === "error" ? (
|
||||
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
|
||||
<p className="font-bold">Couldn't grade that answer</p>
|
||||
<p className="font-bold">{t("short.couldNotGrade")}</p>
|
||||
<p className="mt-1">{state.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => void grade()}>
|
||||
Retry
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
|
||||
Show answer & self-check
|
||||
{t("short.showAnswer")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -132,15 +134,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
|
||||
{state.status === "self-check" ? (
|
||||
<div className="card space-y-3 p-4">
|
||||
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">Model answer</p>
|
||||
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">{t("short.modelAnswer")}</p>
|
||||
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">Key points: {activity.keyPoints.join(" · ")}</p>
|
||||
<p className="text-xs text-frost-500 dark:text-frost-400">{t("short.keyPoints")} {activity.keyPoints.join(" · ")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" onClick={() => selfCheck(false)}>
|
||||
😅 I missed it
|
||||
{t("short.missedIt")}
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => selfCheck(true)}>
|
||||
🧊 I had it right
|
||||
{t("short.hadRight")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -157,7 +159,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
)}
|
||||
>
|
||||
<p className="font-extrabold uppercase tracking-wide">
|
||||
{state.correct ? "Accepted" : "Needs work"}
|
||||
{state.correct ? t("short.accepted") : t("short.needsWork")}
|
||||
</p>
|
||||
<p className="mt-1 font-normal">{state.feedback}</p>
|
||||
{state.missing.length ? (
|
||||
|
|
@ -169,7 +171,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
) : null}
|
||||
{!state.correct ? (
|
||||
<p className="mt-2 font-normal">
|
||||
<span className="font-bold">Model answer: </span>
|
||||
<span className="font-bold">{t("short.modelAnswer")}: </span>
|
||||
{activity.referenceAnswer}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
// The drag is elastic, so the card moves less than the pointer — these
|
||||
|
|
@ -37,7 +39,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
<>
|
||||
<ActivityHeader type="true_false" topic={activity.topic} />
|
||||
<Hint>
|
||||
{result === null ? "Swipe the card right for true, left for false — or tap a button." : "Nice swipe!"}
|
||||
{result === null ? t("tf.hint") : t("tf.niceSwipe")}
|
||||
</Hint>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -55,10 +57,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
>
|
||||
<PromptCard className="relative min-h-44 select-none">
|
||||
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
|
||||
✓ true
|
||||
{t("tf.trueLabel")}
|
||||
</motion.span>
|
||||
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
|
||||
false ✕
|
||||
{t("tf.falseLabel")}
|
||||
</motion.span>
|
||||
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
|
||||
“{activity.statement}”
|
||||
|
|
@ -70,10 +72,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
{result === null ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
|
||||
✕ False
|
||||
{t("tf.falseBtn")}
|
||||
</Button>
|
||||
<Button variant="success" size="lg" onClick={() => answer(true)}>
|
||||
✓ True
|
||||
{t("tf.trueBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -85,7 +87,8 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
|
||||
{t("tf.statementIs.before")}{" "}
|
||||
<span className="font-bold">{activity.answer ? t("tf.true") : t("tf.false")}</span>.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MultipleChoice } from "./MultipleChoice";
|
|||
import { Ordering } from "./Ordering";
|
||||
import { ShortAnswer } from "./ShortAnswer";
|
||||
import { TrueFalse } from "./TrueFalse";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { type ActivityComponentProps } from "./shared";
|
||||
|
||||
export type { ActivityComponentProps } from "./shared";
|
||||
|
|
@ -38,7 +39,7 @@ export function ActivityRenderer(props: ActivityComponentProps) {
|
|||
// Exhaustiveness guard: adding a new activity type without a component
|
||||
// becomes a TypeScript error.
|
||||
const never: never = activity;
|
||||
return <p className="text-sm">Unsupported activity: {JSON.stringify(never)}</p>;
|
||||
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Activity } from "@/lib/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -19,14 +20,14 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
|
|||
isLast: boolean;
|
||||
};
|
||||
|
||||
export const TYPE_META: Record<Activity["type"], { label: string; icon: string; tint: string }> = {
|
||||
flashcard: { label: "Flashcard", icon: "🃏", tint: "tint-sky" },
|
||||
multiple_choice: { label: "Multiple choice", icon: "🔘", tint: "tint-lilac" },
|
||||
true_false: { label: "True or false", icon: "⚖️", tint: "tint-mint" },
|
||||
fill_blank: { label: "Fill the blank", icon: "✏️", tint: "tint-peach" },
|
||||
matching: { label: "Match up", icon: "🧲", tint: "tint-sky" },
|
||||
ordering: { label: "Put in order", icon: "🔢", tint: "tint-lilac" },
|
||||
short_answer: { label: "Short answer", icon: "💬", tint: "tint-rose" },
|
||||
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
|
||||
flashcard: { labelKey: "type.flashcard", icon: "🃏", tint: "tint-sky" },
|
||||
multiple_choice: { labelKey: "type.multipleChoice", icon: "🔘", tint: "tint-lilac" },
|
||||
true_false: { labelKey: "type.trueFalse", icon: "⚖️", tint: "tint-mint" },
|
||||
fill_blank: { labelKey: "type.fillBlank", icon: "✏️", tint: "tint-peach" },
|
||||
matching: { labelKey: "type.matching", icon: "🧲", tint: "tint-sky" },
|
||||
ordering: { labelKey: "type.ordering", icon: "🔢", tint: "tint-lilac" },
|
||||
short_answer: { labelKey: "type.shortAnswer", icon: "💬", tint: "tint-rose" },
|
||||
};
|
||||
export function ActivityHeader({
|
||||
type,
|
||||
|
|
@ -37,6 +38,7 @@ export function ActivityHeader({
|
|||
topic?: string;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const meta = TYPE_META[type];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -47,7 +49,7 @@ export function ActivityHeader({
|
|||
)}
|
||||
>
|
||||
<span aria-hidden>{meta.icon}</span>
|
||||
{meta.label}
|
||||
{t(meta.labelKey)}
|
||||
</span>
|
||||
{topic ? (
|
||||
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
|
||||
|
|
@ -144,6 +146,7 @@ export function FeedbackPanel({
|
|||
detail?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
|
|
@ -166,7 +169,7 @@ export function FeedbackPanel({
|
|||
)}
|
||||
>
|
||||
<span aria-hidden>{correct ? "✅" : "💧"}</span>
|
||||
{headline ?? (correct ? "Nice one!" : "Not quite")}
|
||||
{headline ?? (correct ? t("activity.niceOne") : t("activity.notQuite"))}
|
||||
</p>
|
||||
{correct && xp ? (
|
||||
<motion.span
|
||||
|
|
@ -182,7 +185,7 @@ export function FeedbackPanel({
|
|||
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
|
||||
|
||||
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
|
||||
<span className="font-bold">Why: </span>
|
||||
<span className="font-bold">{t("activity.why")} </span>
|
||||
{explanation}
|
||||
</p>
|
||||
|
||||
|
|
@ -190,7 +193,7 @@ export function FeedbackPanel({
|
|||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<Button onClick={onNext} size="md" className="min-w-28">
|
||||
{isLast ? "See results" : "Next"}
|
||||
{isLast ? t("activity.seeResults") : t("activity.next")}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue