freezmo/components/activities/MultipleChoice.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

100 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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