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