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.
100 lines
2.8 KiB
TypeScript
100 lines
2.8 KiB
TypeScript
"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}
|
||
</>
|
||
);
|
||
}
|