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
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue