Freezmo: self-hosted notes-to-activities study app

Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable
feed of AI-generated activities (flashcards, multiple choice, true/false,
fill-in-the-blank, matching, ordering, short answer) using the user's own
Ollama models.

Highlights:
- All AI calls run in server route handlers; the browser never talks to Ollama.
- Bring-your-own-key mode (default): the key lives only in the browser and is
  sent per-request; server, local-daemon and shared-key modes also supported.
- Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF
  page rendering for the vision model; notes stay in their original language.
- ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based
  light + dark themes, folder organisation, PWA (manifest, SW, offline page).
- Strict JSON validation with zod + auto-repair, rate limiting, and request
  guards (cross-origin, body-size, API-key sanitising).
- setup.sh bootstraps deps, .env.local and a production build.

Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
Martin Dvořák 2026-10-06 20:56:55 +00:00
commit 3ea2cea100
83 changed files with 10909 additions and 165 deletions

View file

@ -0,0 +1,202 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
import {
ActivityHeader,
FeedbackPanel,
Hint,
OptionTile,
PromptCard,
type ActivityComponentProps,
} from "./shared";
type FillBlankActivity = Extract<Activity, { type: "fill_blank" }>;
const BLANK = /_{2,}/;
function normalise(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (!a.length || !b.length) return Math.max(a.length, b.length);
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const current = [i];
for (let j = 1; j <= b.length; j++) {
current[j] = Math.min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
previous = current;
}
return previous[b.length];
}
/** Lenient comparison: case/punctuation-insensitive, one typo allowed, "/" alternatives. */
export function isFillBlankCorrect(userValue: string, answer: string): boolean {
const user = normalise(userValue);
if (!user) return false;
const candidates = answer
.split("/")
.map((part) => normalise(part.replace(/\(.*?\)/g, "")))
.filter(Boolean);
return candidates.some((candidate) => {
if (candidate === user) return true;
if (candidate.length >= 5 && user.length >= 4 && levenshtein(candidate, user) <= 1) return true;
// accept the answer with/without a trailing unit or article
const candidateWords = candidate.split(" ");
const userWords = user.split(" ");
if (candidateWords.length > 1 && userWords.length === 1) return candidateWords.includes(user);
return false;
});
}
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
const [value, setValue] = useState("");
const [chosen, setChosen] = useState<string | null>(null);
const [result, setResult] = useState<boolean | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const hasBlank = BLANK.test(activity.sentence);
const [before, after = ""] = hasBlank ? activity.sentence.split(BLANK) : [activity.sentence, ""];
useEffect(() => {
if (active && !hasBlank && !activity.options) inputRef.current?.focus();
}, [active, hasBlank, activity.options]);
const submit = (answerValue: string) => {
if (result !== null) return;
const correct = isFillBlankCorrect(answerValue, activity.answer);
setChosen(answerValue);
setResult(correct);
onAnswered(correct, { userAnswer: answerValue });
};
const displayAnswer = chosen ?? "";
return (
<>
<ActivityHeader type="fill_blank" topic={activity.topic} />
<PromptCard>
<p className="text-lg font-semibold leading-loose text-frost-950 sm:text-xl dark:text-frost-50">
{before}
{activity.options ? (
<span
className={cn(
"mx-1 inline-block min-w-24 rounded-xl border-b-4 px-3 py-0.5 text-center align-baseline",
result === null
? "border-frost-300 bg-frost-50/80 text-frost-500 dark:bg-white/10 dark:text-frost-200"
: result
? "border-emerald-400 bg-emerald-50 text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-100"
: "border-rose-400 bg-rose-50 text-rose-800 dark:bg-rose-400/15 dark:text-rose-100",
)}
>
{displayAnswer || "?"}
</span>
) : (
<input
ref={inputRef}
value={displayAnswer}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !result) {
event.preventDefault();
submit(value);
}
event.stopPropagation();
}}
disabled={result !== null}
aria-label="Type the missing word"
placeholder="type it"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
className={cn(
"mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none backdrop-blur-lg placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30",
result === null
? "border-frost-300 focus:border-frost-500"
: result
? "border-emerald-400 bg-emerald-50 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100"
: "border-rose-400 bg-rose-50 text-rose-900 dark:bg-rose-400/15 dark:text-rose-100",
)}
style={{ width: `${Math.max(5, (displayAnswer.length || 6) + 2)}ch` }}
/>
)}
{after}
</p>
</PromptCard>
{activity.options ? (
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
{activity.options.map((option) => {
const state =
result === null
? "idle"
: normalise(option) === normalise(activity.answer)
? "correct"
: chosen && normalise(chosen) === normalise(option) && result === false
? "wrong"
: "dim";
return (
<OptionTile
key={option}
state={state}
disabled={result !== null}
onClick={() => submit(option)}
className="justify-center text-center"
>
{option}
</OptionTile>
);
})}
</div>
) : result === null ? (
<div className="flex gap-2">
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
Check answer
</Button>
</div>
) : null}
{result === null && !activity.options ? (
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
) : null}
{result !== null ? (
<FeedbackPanel
correct={result}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
onNext={onNext}
detail={
<span>
Expected: <span className="font-bold">{activity.answer}</span>
{!result && chosen ? (
<>
{" "}
— you wrote <span className="italic">{chosen}</span>
</>
) : null}
</span>
}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,92 @@
"use client";
import { motion } from "framer-motion";
import { useState } from "react";
import type { Activity } from "@/lib/schemas";
import { Button } from "@/components/ui";
import {
ActivityHeader,
FeedbackPanel,
Hint,
PromptCard,
QuestionText,
type ActivityComponentProps,
} from "./shared";
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
const [flipped, setFlipped] = useState(false);
const [result, setResult] = useState<boolean | null>(null);
const settle = (correct: boolean) => {
if (result !== null) return;
setResult(correct);
onAnswered(correct);
};
return (
<>
<ActivityHeader type="flashcard" topic={activity.topic} />
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</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"}
>
<motion.div
animate={{ rotateX: flipped ? 180 : 0 }}
transition={{ type: "spring", stiffness: 220, damping: 24 }}
className="relative min-h-56 w-full [transform-style:preserve-3d]"
>
<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
</span>
<QuestionText>{activity.front}</QuestionText>
<span className="mt-2 text-xs font-semibold text-slate-400 dark:text-slate-400">
tap to flip ↻
</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
</span>
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
{activity.back}
</p>
</div>
</div>
</motion.div>
</button>
{flipped && result === null ? (
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
😅 Not yet
</Button>
<Button variant="success" size="lg" onClick={() => settle(true)}>
🧊 I knew it
</Button>
</div>
) : null}
{result !== null ? (
<FeedbackPanel
correct={result}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
headline={result ? "Frozen solid!" : "Keep it on the pile"}
onNext={onNext}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
import {
ActivityHeader,
FeedbackPanel,
Hint,
PromptCard,
type ActivityComponentProps,
} from "./shared";
type MatchingActivity = Extract<Activity, { type: "matching" }>;
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
const terms = useMemo(
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
[activity],
);
const definitions = useMemo(
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.definition }))),
[activity],
);
const [selectedTerm, setSelectedTerm] = useState<number | null>(null);
const [matched, setMatched] = useState<number[]>([]);
const [misses, setMisses] = useState(0);
const [flash, setFlash] = useState<{ term: number; definition: number } | null>(null);
const [done, setDone] = useState(false);
const total = activity.pairs.length;
const complete = matched.length === total;
const onDefinitionTap = (definitionIndex: number) => {
if (done || selectedTerm === null || matched.includes(definitionIndex)) return;
if (selectedTerm === definitionIndex) {
const next = [...matched, definitionIndex];
setMatched(next);
setSelectedTerm(null);
if (next.length === total) {
const correct = misses === 0;
setDone(true);
onAnswered(correct);
}
} else {
setMisses((value) => value + 1);
setFlash({ term: selectedTerm, definition: definitionIndex });
window.setTimeout(() => setFlash(null), 500);
setSelectedTerm(null);
}
};
return (
<>
<ActivityHeader
type="matching"
topic={activity.topic}
extra={
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-200">
{matched.length}/{total} matched
</span>
}
/>
<PromptCard className="py-4">
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
<Hint>
{done
? "All matched!"
: selectedTerm === null
? "Tap a term on the left, then its match on the right."
: "Now tap the matching definition →"}
</Hint>
</PromptCard>
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-2.5">
{terms.map((term) => {
const isMatched = matched.includes(term.index);
const isSelected = selectedTerm === term.index;
const isFlashing = flash?.term === term.index;
return (
<button
key={`term-${term.index}`}
type="button"
disabled={isMatched || done}
onClick={() => setSelectedTerm(isSelected ? null : term.index)}
className={cn(
"min-h-16 rounded-2xl border px-3 py-2 text-left text-sm font-semibold backdrop-blur-xl transition-all",
isMatched
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
: isSelected
? "border-accent-400 bg-accent-50/80 text-frost-900 dark:bg-accent-500/20 dark:text-white"
: "border-white/60 bg-white/55 text-frost-950 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50",
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
)}
>
<span className="mr-1">{isMatched ? "✓" : "•"}</span>
{term.text}
</button>
);
})}
</div>
<div className="flex flex-col gap-2.5">
{definitions.map((definition) => {
const isMatched = matched.includes(definition.index);
const isFlashing = flash?.definition === definition.index;
return (
<button
key={`def-${definition.index}`}
type="button"
disabled={isMatched || done || selectedTerm === null}
onClick={() => onDefinitionTap(definition.index)}
className={cn(
"min-h-16 rounded-2xl border-2 px-3 py-2 text-left text-sm transition-all",
isMatched
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
: "border-white/60 bg-white/55 text-frost-700 hover:bg-white/80 disabled:opacity-60 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-200",
isFlashing && "animate-wiggle border-rose-400 bg-rose-50 dark:bg-rose-400/15",
)}
>
{definition.text}
</button>
);
})}
</div>
</div>
{complete ? (
<FeedbackPanel
correct={misses === 0}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
onNext={onNext}
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
detail={
<span>
{misses === 0
? "You matched everything first try."
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
</span>
}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import type { Activity } from "@/lib/schemas";
import {
ActivityHeader,
FeedbackPanel,
Hint,
OptionTile,
PromptCard,
QuestionText,
type ActivityComponentProps,
} from "./shared";
type MultipleChoiceActivity = Extract<Activity, { type: "multiple_choice" }>;
export function MultipleChoice({
activity,
active,
onAnswered,
onNext,
isLast,
}: ActivityComponentProps<MultipleChoiceActivity>) {
const [selected, setSelected] = useState<number | null>(null);
const answered = selected !== null;
const choose = (index: number) => {
if (answered) return;
setSelected(index);
onAnswered(index === activity.correctIndex);
};
useEffect(() => {
if (!active || answered) return;
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && /^(INPUT|TEXTAREA)$/.test(target.tagName)) return;
const index = Number.parseInt(event.key, 10) - 1;
if (index >= 0 && index < activity.options.length) {
event.preventDefault();
choose(index);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, answered, activity.options.length]);
const stateFor = (index: number): "idle" | "correct" | "wrong" | "dim" => {
if (!answered) return "idle";
if (index === activity.correctIndex) return "correct";
if (index === selected) return "wrong";
return "dim";
};
return (
<>
<ActivityHeader type="multiple_choice" topic={activity.topic} />
<PromptCard>
<QuestionText>{activity.question}</QuestionText>
</PromptCard>
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
{activity.options.map((option, index) => (
<OptionTile
key={option + index}
state={stateFor(index)}
disabled={answered}
onClick={() => choose(index)}
shortcut={String(index + 1)}
>
{option}
</OptionTile>
))}
</div>
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
{answered ? (
<FeedbackPanel
correct={selected === activity.correctIndex}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
onNext={onNext}
detail={
selected === activity.correctIndex ? null : (
<>
Correct answer:{" "}
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
</>
)
}
/>
) : null}
</>
);
}

View file

@ -0,0 +1,132 @@
"use client";
import { Reorder } from "framer-motion";
import { useMemo, useState } from "react";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
import { Button } from "@/components/ui";
import {
ActivityHeader,
FeedbackPanel,
Hint,
PromptCard,
type ActivityComponentProps,
} from "./shared";
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
const initial = useMemo(() => shuffle(activity.items), [activity]);
const [items, setItems] = useState<string[]>(initial);
const [checked, setChecked] = useState(false);
const [correct, setCorrect] = useState(false);
const isRight = (list: string[]) => list.every((item, index) => item === activity.items[index]);
const check = () => {
if (checked) return;
const result = isRight(items);
setChecked(true);
setCorrect(result);
onAnswered(result, { userAnswer: items.join(" → ") });
};
const move = (index: number, direction: -1 | 1) => {
if (checked) return;
const target = index + direction;
if (target < 0 || target >= items.length) return;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
setItems(next);
};
return (
<>
<ActivityHeader type="ordering" topic={activity.topic} />
<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."}
</Hint>
</PromptCard>
{checked ? (
<ol className="flex flex-col gap-2.5">
{activity.items.map((item, index) => {
const wasRight = items[index] === item;
return (
<li
key={`solution-${item}`}
className={cn(
"flex items-center gap-3 rounded-2xl border-2 px-4 py-3 text-sm font-semibold",
wasRight
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
: "border-rose-200/80 bg-rose-50/85 text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/15 dark:text-rose-100",
)}
>
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-white/70 text-xs font-extrabold dark:bg-white/15">
{index + 1}
</span>
<span className="flex-1">{item}</span>
</li>
);
})}
</ol>
) : (
<Reorder.Group axis="y" values={items} onReorder={setItems} className="flex flex-col gap-2.5">
{items.map((item, index) => (
<Reorder.Item
key={item}
value={item}
className="flex cursor-grab items-center gap-2 rounded-2xl border border-white/60 bg-white/60 px-3 py-3 text-sm font-semibold text-frost-950 shadow-sm backdrop-blur-xl active:cursor-grabbing dark:border-white/12 dark:bg-white/[0.08] dark:text-frost-50"
>
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-100 text-xs font-extrabold text-frost-700 dark:bg-white/15 dark:text-frost-100">
{index + 1}
</span>
<span className="flex-1">{item}</span>
<span className="flex flex-col">
<button
type="button"
aria-label={`Move "${item}" up`}
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"
>
▲
</button>
<button
type="button"
aria-label={`Move "${item}" down`}
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"
>
▼
</button>
</span>
<span aria-hidden className="text-slate-400">
⠿
</span>
</Reorder.Item>
))}
</Reorder.Group>
)}
{!checked ? (
<Button size="lg" onClick={check} disabled={items.length < 2}>
Check order
</Button>
) : (
<FeedbackPanel
correct={correct}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
onNext={onNext}
/>
)}
</>
);
}

View file

@ -0,0 +1,189 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ApiError, gradeAnswer } from "@/lib/client-api";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button, Spinner } from "@/components/ui";
import {
ActivityHeader,
FeedbackPanel,
Hint,
PromptCard,
QuestionText,
type ActivityComponentProps,
} from "./shared";
type ShortAnswerActivity = Extract<Activity, { type: "short_answer" }>;
type GradeState =
| { status: "idle" }
| { status: "grading" }
| { status: "graded"; correct: boolean; feedback: string; missing: string[] }
| { status: "error"; message: string }
| { status: "self-check" };
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
const [value, setValue] = useState("");
const [state, setState] = useState<GradeState>({ status: "idle" });
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (active && state.status === "idle") {
const timer = window.setTimeout(() => textareaRef.current?.focus(), 350);
return () => window.clearTimeout(timer);
}
}, [active, state.status]);
const grade = async () => {
if (!value.trim() || state.status === "grading") return;
setState({ status: "grading" });
try {
const result = await gradeAnswer({
question: activity.question,
referenceAnswer: activity.referenceAnswer,
keyPoints: activity.keyPoints,
userAnswer: value.trim(),
language,
});
setState({
status: "graded",
correct: result.correct,
feedback: result.feedback,
missing: result.missing,
});
onAnswered(result.correct, { userAnswer: value.trim() });
} catch (error) {
setState({
status: "error",
message:
error instanceof ApiError
? error.message
: "Grading failed. Check that Ollama is reachable and try again.",
});
}
};
const selfCheck = (correct: boolean) => {
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
onAnswered(correct, { userAnswer: value.trim() });
};
return (
<>
<ActivityHeader type="short_answer" topic={activity.topic} />
<PromptCard>
<QuestionText>{activity.question}</QuestionText>
</PromptCard>
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
Your answer
</label>
<textarea
id={`answer-${activity.id}`}
ref={textareaRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
void grade();
}
}}
disabled={state.status === "grading" || state.status === "graded"}
rows={3}
placeholder="Type your answer in your own words…"
className="w-full resize-none rounded-2xl border-2 border-frost-200 bg-white/90 px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-frost-500 disabled:opacity-70 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50 dark:placeholder:text-slate-400"
/>
{state.status === "idle" ? (
<div className="flex items-center justify-between gap-3">
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
Submit answer
</Button>
</div>
) : null}
{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…
</div>
) : null}
{state.status === "error" ? (
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-bold">Couldn&apos;t grade that answer</p>
<p className="mt-1">{state.message}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => void grade()}>
Retry
</Button>
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
Show answer &amp; self-check
</Button>
</div>
</div>
) : null}
{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 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>
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" onClick={() => selfCheck(false)}>
😅 I missed it
</Button>
<Button variant="success" onClick={() => selfCheck(true)}>
🧊 I had it right
</Button>
</div>
</div>
) : null}
{state.status === "graded" ? (
<>
<div
className={cn(
"rounded-2xl border-2 p-3 text-sm font-semibold",
state.correct
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100"
: "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100",
)}
>
<p className="font-extrabold uppercase tracking-wide">
{state.correct ? "Accepted" : "Needs work"}
</p>
<p className="mt-1 font-normal">{state.feedback}</p>
{state.missing.length ? (
<ul className="mt-2 list-inside list-disc font-normal">
{state.missing.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
{!state.correct ? (
<p className="mt-2 font-normal">
<span className="font-bold">Model answer: </span>
{activity.referenceAnswer}
</p>
) : null}
</div>
<FeedbackPanel
correct={state.correct}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
onNext={onNext}
/>
</>
) : null}
</>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { useState } from "react";
import type { Activity } from "@/lib/schemas";
import { Button } from "@/components/ui";
import {
ActivityHeader,
FeedbackPanel,
Hint,
PromptCard,
type ActivityComponentProps,
} from "./shared";
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
const [result, setResult] = useState<boolean | null>(null);
const x = useMotionValue(0);
const trueOpacity = useTransform(x, [30, 110], [0, 1]);
const falseOpacity = useTransform(x, [-30, -110], [1, 0]);
const answer = (value: boolean) => {
if (result !== null) return;
setResult(value === activity.answer);
onAnswered(value === activity.answer, { userAnswer: value ? "true" : "false" });
};
return (
<>
<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!"}
</Hint>
<div className="relative">
<motion.div
drag={result === null ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.55}
style={{ x }}
onDragEnd={(_, info) => {
if (info.offset.x > 90) answer(true);
else if (info.offset.x < -90) answer(false);
}}
whileTap={{ cursor: "grabbing" }}
className="touch-pan-y"
>
<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
</motion.span>
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
false ✕
</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}”
</p>
</PromptCard>
</motion.div>
</div>
{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
</Button>
<Button variant="success" size="lg" onClick={() => answer(true)}>
✓ True
</Button>
</div>
) : (
<FeedbackPanel
correct={result}
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
onNext={onNext}
detail={
<span>
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
</span>
}
/>
)}
</>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { FillBlank } from "./FillBlank";
import { Flashcard } from "./Flashcard";
import { Matching } from "./Matching";
import { MultipleChoice } from "./MultipleChoice";
import { Ordering } from "./Ordering";
import { ShortAnswer } from "./ShortAnswer";
import { TrueFalse } from "./TrueFalse";
import { type ActivityComponentProps } from "./shared";
export type { ActivityComponentProps } from "./shared";
export { TYPE_META } from "./shared";
/**
* Renders an activity from JSON based on its `type` field.
* Every activity type is a self-contained React component.
*/
export function ActivityRenderer(props: ActivityComponentProps) {
const { activity } = props;
switch (activity.type) {
case "flashcard":
return <Flashcard {...props} activity={activity} />;
case "multiple_choice":
return <MultipleChoice {...props} activity={activity} />;
case "true_false":
return <TrueFalse {...props} activity={activity} />;
case "fill_blank":
return <FillBlank {...props} activity={activity} />;
case "matching":
return <Matching {...props} activity={activity} />;
case "ordering":
return <Ordering {...props} activity={activity} />;
case "short_answer":
return <ShortAnswer {...props} activity={activity} />;
default: {
// 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>;
}
}
}

View file

@ -0,0 +1,204 @@
"use client";
import { motion } from "framer-motion";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
export type ActivityComponentProps<A extends Activity = Activity> = {
activity: A;
/** True when this slide is the one currently filling the screen. */
active: boolean;
/** Language of the deck, so AI grading answers in the same language. */
language?: string;
/** Report the result to the feed (once per attempt). */
onAnswered: (correct: boolean, meta?: { userAnswer?: string }) => void;
/** Scroll the feed to the next slide. */
onNext: () => void;
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 function ActivityHeader({
type,
topic,
extra,
}: {
type: Activity["type"];
topic?: string;
extra?: React.ReactNode;
}) {
const meta = TYPE_META[type];
return (
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm",
meta.tint,
)}
>
<span aria-hidden>{meta.icon}</span>
{meta.label}
</span>
{topic ? (
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
{topic}
</span>
) : null}
{extra}
</div>
);
}
export function PromptCard({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("glass-panel rounded-[1.35rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div>
);
}
export function QuestionText({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<p className={cn("text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50", className)}>
{children}
</p>
);
}
/** Standard tile used for answer options. */
export function OptionTile({
children,
state = "idle",
disabled,
onClick,
className,
shortcut,
}: {
children: React.ReactNode;
state?: "idle" | "selected" | "correct" | "wrong" | "dim";
disabled?: boolean;
onClick?: () => void;
className?: string;
shortcut?: string;
}) {
const states: Record<string, string> = {
idle: "border-white/60 bg-white/55 hover:bg-white/80 dark:border-white/12 dark:bg-white/[0.07] dark:hover:bg-white/[0.12]",
selected: "border-accent-400 bg-accent-50/80 dark:bg-accent-500/20",
correct:
"border-emerald-300 bg-emerald-50/90 text-emerald-950 dark:border-emerald-400/50 dark:bg-emerald-400/15 dark:text-emerald-50",
wrong: "border-rose-300 bg-rose-50/90 text-rose-950 dark:border-rose-400/50 dark:bg-rose-400/15 dark:text-rose-50",
dim: "border-white/40 bg-white/35 text-frost-400 dark:border-white/10 dark:bg-white/[0.04] dark:text-frost-500",
};
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border px-4 py-3.5 text-left text-[15px] font-medium backdrop-blur-xl transition-all duration-200 disabled:cursor-default",
states[state],
!disabled && "active:scale-[0.99]",
className,
)}
>
{shortcut ? (
<span className="grid size-6 shrink-0 place-items-center rounded-lg bg-black/5 text-xs font-bold text-slate-600 dark:bg-white/10 dark:text-slate-300">
{shortcut}
</span>
) : null}
<span className="flex-1">{children}</span>
</button>
);
}
export function FeedbackPanel({
correct,
explanation,
xp,
onNext,
isLast,
headline,
detail,
children,
}: {
correct: boolean;
explanation: string;
xp?: number;
onNext: () => void;
isLast?: boolean;
headline?: string;
detail?: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 32 }}
className={cn(
"rounded-3xl border p-4 shadow-lift backdrop-blur-xl",
correct
? "border-emerald-200/80 bg-gradient-to-b from-emerald-50/95 to-emerald-100/70 dark:border-emerald-400/30 dark:from-emerald-500/15 dark:to-emerald-500/5"
: "border-rose-200/80 bg-gradient-to-b from-rose-50/95 to-rose-100/70 dark:border-rose-400/30 dark:from-rose-500/15 dark:to-rose-500/5",
)}
role="status"
aria-live="polite"
>
<div className="flex items-center justify-between gap-3">
<p
className={cn(
"flex items-center gap-2 text-base font-extrabold",
correct ? "text-emerald-900 dark:text-emerald-200" : "text-rose-900 dark:text-rose-200",
)}
>
<span aria-hidden>{correct ? "✅" : "💧"}</span>
{headline ?? (correct ? "Nice one!" : "Not quite")}
</p>
{correct && xp ? (
<motion.span
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="rounded-full bg-emerald-500 px-3 py-1 text-sm font-extrabold text-white shadow"
>
+{xp} XP
</motion.span>
) : null}
</div>
{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>
{explanation}
</p>
{children}
<div className="mt-4 flex items-center justify-end">
<Button onClick={onNext} size="md" className="min-w-28">
{isLast ? "See results" : "Next"}
<span aria-hidden>→</span>
</Button>
</div>
</motion.div>
);
}
/** Small hint row under a question, e.g. "Tap a term, then its definition". */
export function Hint({ children }: { children: React.ReactNode }) {
return <p className="text-sm text-frost-600 dark:text-frost-300">{children}</p>;
}