freezmo/components/activities/TrueFalse.tsx
Martin Dvorak 3ea2cea100 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.
2026-10-06 20:56:55 +00:00

90 lines
3 KiB
TypeScript

"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>
}
/>
)}
</>
);
}