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:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
202
components/activities/FillBlank.tsx
Normal file
202
components/activities/FillBlank.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
components/activities/Flashcard.tsx
Normal file
92
components/activities/Flashcard.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
components/activities/Matching.tsx
Normal file
151
components/activities/Matching.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
components/activities/MultipleChoice.tsx
Normal file
100
components/activities/MultipleChoice.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
components/activities/Ordering.tsx
Normal file
132
components/activities/Ordering.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
189
components/activities/ShortAnswer.tsx
Normal file
189
components/activities/ShortAnswer.tsx
Normal 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'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 & 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
components/activities/TrueFalse.tsx
Normal file
90
components/activities/TrueFalse.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
components/activities/index.tsx
Normal file
44
components/activities/index.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
components/activities/shared.tsx
Normal file
204
components/activities/shared.tsx
Normal 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>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue