Initial commit: PastPaper Master full stack

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zhao
2026-04-21 12:15:35 +07:00
commit 7a09167261
105 changed files with 24799 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import { Link } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
export default function Header({
courseCode,
paperTitle,
}: {
courseCode?: string;
paperTitle?: string;
}) {
const { user, signOut } = useAuth();
return (
<header className="h-14 border-b border-gray-200 bg-white flex items-center px-6 shrink-0">
<Link to="/" className="text-lg font-bold text-blue-600 mr-6">
PastPaper Master
</Link>
{courseCode && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="bg-blue-50 text-blue-700 px-2 py-0.5 rounded font-medium">
{courseCode}
</span>
{paperTitle && <span>{paperTitle}</span>}
<Link
to={`/analytics/${courseCode}`}
className="ml-2 flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded hover:bg-indigo-100 transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
AI Analytics
</Link>
</div>
)}
<div className="ml-auto flex items-center gap-4 text-sm">
<Link to="/" className="text-gray-500 hover:text-gray-800">
My Papers
</Link>
<Link to="/error-book" className="text-gray-500 hover:text-gray-800">
Error Book
</Link>
<Link to="/analytics" className="text-gray-500 hover:text-gray-800">
Analytics
</Link>
<Link to="/upload" className="text-blue-600 hover:text-blue-800 font-medium">
Upload
</Link>
{user ? (
<div className="flex items-center gap-3 pl-4 border-l border-gray-200">
<span className="text-xs text-gray-400">{user.email}</span>
<button
onClick={signOut}
className="text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100"
>
Sign out
</button>
</div>
) : (
<Link
to="/login"
className="text-sm text-blue-600 hover:text-blue-800 font-medium pl-4 border-l border-gray-200"
>
Sign in
</Link>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { myPapers } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
import type { Paper } from "@/types/api";
interface Notification {
paperId: string;
label: string;
}
const POLL_MS = 4000;
export default function ProcessingBanner() {
const { user } = useAuth();
const [processing, setProcessing] = useState<Paper[]>([]);
const [doneNotifs, setDoneNotifs] = useState<Notification[]>([]);
const [expanded, setExpanded] = useState(false);
const knownIds = useRef<Set<string>>(new Set());
// Drag state
const [pos, setPos] = useState({ x: window.innerWidth - 220, y: 24 });
const dragging = useRef(false);
const dragOffset = useRef({ x: 0, y: 0 });
const widgetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!user) return;
let cancelled = false;
const poll = async () => {
try {
const papers = await myPapers();
if (cancelled) return;
const inProgress = papers.filter((p) => p.status === "processing" || p.status === "uploaded");
setProcessing(inProgress);
papers
.filter((p) => p.status === "ready" && knownIds.current.has(p.id))
.forEach((p) => {
knownIds.current.delete(p.id);
const label = `${p.course_code} ${p.year} ${p.term} ${p.exam_type}`;
setDoneNotifs((prev) => [...prev, { paperId: p.id, label }]);
setTimeout(() => {
setDoneNotifs((prev) => prev.filter((n) => n.paperId !== p.id));
}, 8000);
});
inProgress.forEach((p) => knownIds.current.add(p.id));
} catch {
// silent
}
};
poll();
const interval = setInterval(poll, POLL_MS);
return () => { cancelled = true; clearInterval(interval); };
}, [user]);
// Drag handlers
const onMouseDown = (e: React.MouseEvent) => {
// Only drag on the header bar
dragging.current = true;
dragOffset.current = {
x: e.clientX - pos.x,
y: e.clientY - pos.y,
};
e.preventDefault();
};
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
setPos({
x: Math.max(0, Math.min(window.innerWidth - 200, e.clientX - dragOffset.current.x)),
y: Math.max(0, Math.min(window.innerHeight - 60, e.clientY - dragOffset.current.y)),
});
};
const onMouseUp = () => { dragging.current = false; };
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, []);
if (!user || (processing.length === 0 && doneNotifs.length === 0)) return null;
const total = processing.length + doneNotifs.length;
return (
<div
ref={widgetRef}
className="fixed z-50 select-none"
style={{ left: pos.x, top: pos.y }}
>
{/* ── Header / collapsed pill ── */}
<div
onMouseDown={onMouseDown}
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-2 bg-gray-900 text-white text-xs px-3.5 py-2.5 rounded-xl shadow-lg cursor-grab active:cursor-grabbing"
style={{ minWidth: 180 }}
>
<span className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin shrink-0" />
<span className="flex-1 font-medium">
{processing.length > 0
? `${processing.length} processing…`
: `${doneNotifs.length} ready`}
</span>
{doneNotifs.length > 0 && (
<span className="w-4 h-4 flex items-center justify-center bg-green-500 rounded-full text-[10px] font-bold shrink-0">
{doneNotifs.length}
</span>
)}
<span className="text-gray-400 text-[10px] shrink-0">{expanded ? "▲" : "▼"}</span>
</div>
{/* ── Expanded panel ── */}
{expanded && (
<div className="mt-1.5 flex flex-col gap-1.5" style={{ minWidth: 240 }}>
{processing.map((p) => {
const step = p.processing_step;
const progress = p.processing_progress || 0;
const total = p.processing_total || 0;
const pct = total > 0 ? Math.round((progress / total) * 100) : 0;
return (
<div
key={p.id}
className="bg-gray-900 text-white text-xs px-3.5 py-2.5 rounded-xl shadow-lg"
>
<div className="flex items-center gap-2.5 mb-1.5">
<span className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin shrink-0" />
<span className="truncate">
<span className="font-semibold">{p.course_code}</span>{" "}
{p.year} {p.term} {p.exam_type}
</span>
</div>
{step && (
<>
<div className="text-[10px] text-gray-400 mb-1 truncate">{step}</div>
{total > 0 && (
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-400 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
)}
</>
)}
</div>
);
})}
{doneNotifs.map((n) => (
<div
key={n.paperId}
className="flex items-center gap-2.5 bg-green-600 text-white text-xs px-3.5 py-2.5 rounded-xl shadow-lg"
>
<span className="text-sm leading-none"></span>
<span className="flex-1 truncate font-semibold">{n.label}</span>
<Link
to={`/paper/${n.paperId}`}
className="shrink-0 underline font-semibold hover:text-green-100"
onClick={(e) => e.stopPropagation()}
>
Open
</Link>
<button
onClick={(e) => {
e.stopPropagation();
setDoneNotifs((prev) => prev.filter((x) => x.paperId !== n.paperId));
}}
className="shrink-0 text-green-200 hover:text-white"
>
×
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useState } from "react";
const schemes = {
blue: {
border: "border-blue-200",
bg: "bg-blue-50",
text: "text-blue-800",
icon: "text-blue-500",
},
amber: {
border: "border-amber-200",
bg: "bg-amber-50",
text: "text-amber-800",
icon: "text-amber-500",
},
green: {
border: "border-green-200",
bg: "bg-green-50",
text: "text-green-800",
icon: "text-green-500",
},
} as const;
export default function CollapsibleSection({
title,
colorScheme,
defaultOpen = false,
children,
}: {
title: string;
colorScheme: keyof typeof schemes;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const s = schemes[colorScheme];
return (
<div className={`rounded-lg border ${s.border} mb-3`}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between p-3 rounded-t-lg ${s.bg} cursor-pointer`}
>
<span className={`font-semibold text-sm ${s.text}`}>{title}</span>
<svg
className={`w-4 h-4 ${s.icon} transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="p-3">{children}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useMemo } from "react";
import katex from "katex";
/**
* Pre-render all LaTeX in an HTML string at the string level,
* then set innerHTML. This avoids DOM-based auto-render issues
* where delimiters get split across text nodes or special chars
* like # cause silent failures.
*/
function renderLatexInString(html: string): string {
// Strip <code class="latex"> and <pre class="latex"> wrappers
let s = html
.replace(/<code[^>]*class="latex"[^>]*>(.*?)<\/code>/gs, "$1")
.replace(/<pre[^>]*class="latex"[^>]*>(.*?)<\/pre>/gs, "$1");
// 1) Render display math: $$...$$ and \[...\]
s = s.replace(/\$\$([\s\S]+?)\$\$/g, (_match, tex: string) => {
return renderTex(tex.trim(), true);
});
s = s.replace(/\\\[([\s\S]+?)\\\]/g, (_match, tex: string) => {
return renderTex(tex.trim(), true);
});
// 2) Render inline math: $...$ and \(...\)
// Negative lookbehind for \ to avoid matching \$ escapes
// Also avoid matching $$ (already handled above)
s = s.replace(/(?<![\\$])\$(?!\$)((?:[^$\\]|\\.)+?)\$/g, (_match, tex: string) => {
return renderTex(tex, false);
});
s = s.replace(/\\\(([\s\S]+?)\\\)/g, (_match, tex: string) => {
return renderTex(tex, false);
});
return s;
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
}
function renderTex(tex: string, displayMode: boolean): string {
// Decode HTML entities that might appear in DB-sourced HTML
let cleaned = decodeHtmlEntities(tex);
// Sanitize common issues that cause KaTeX to fail:
// 1) # and % inside \text{} — escape them
cleaned = cleaned.replace(/\\text\{([^}]*)\}/g, (_m, inner: string) => {
return "\\text{" + inner.replace(/#/g, "\\#").replace(/%/g, "\\%") + "}";
});
// 2) Standalone # outside \text{} in math — escape it
cleaned = cleaned.replace(/(?<!\\)#(?!\\)/g, "\\#");
try {
return katex.renderToString(cleaned, {
displayMode,
throwOnError: false,
trust: true,
strict: false,
});
} catch {
// Fallback: show the raw LaTeX in a styled span
return `<span class="katex-error" style="color:#E11D48;font-size:0.85em">${tex}</span>`;
}
}
export default function KaTeXRenderer({
html,
className,
}: {
html: string;
className?: string;
}) {
const rendered = useMemo(() => renderLatexInString(html), [html]);
return (
<div
className={`kb-html-content text-sm ${className ?? ""}`}
dangerouslySetInnerHTML={{ __html: rendered }}
/>
);
}

View File

@@ -0,0 +1,15 @@
const statusConfig = {
uploaded: { label: "Uploaded", bg: "bg-gray-100", text: "text-gray-600" },
processing: { label: "Processing...", bg: "bg-blue-100", text: "text-blue-700" },
ready: { label: "Ready", bg: "bg-green-100", text: "text-green-700" },
error: { label: "Error", bg: "bg-red-100", text: "text-red-700" },
} as const;
export default function StatusBadge({ status }: { status: string }) {
const config = statusConfig[status as keyof typeof statusConfig] ?? statusConfig.uploaded;
return (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
{config.label}
</span>
);
}

View File

@@ -0,0 +1,63 @@
import { useRef, useState } from "react";
export default function FilePickerField({
label,
required,
file,
onFileChange,
}: {
label: string;
required?: boolean;
file: File | null;
onFileChange: (file: File | null) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files[0];
if (f?.type === "application/pdf") onFileChange(f);
};
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
${isDragging ? "border-blue-400 bg-blue-50" : "border-gray-300 hover:border-gray-400"}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={(e) => onFileChange(e.target.files?.[0] ?? null)}
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<span className="text-blue-600 font-medium text-sm">{file.name}</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onFileChange(null); }}
className="text-gray-400 hover:text-red-500 text-xs"
>
Remove
</button>
</div>
) : (
<div className="text-gray-400 text-sm">
Click or drag PDF file here
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { uploadPaper } from "@/lib/api";
import FilePickerField from "./FilePickerField";
/** Try to extract course code, year, term, exam type from filename */
function parseFilename(name: string): {
courseCode?: string;
year?: number;
term?: string;
examType?: string;
} {
const result: ReturnType<typeof parseFilename> = {};
// Remove extension
const base = name.replace(/\.[^.]+$/, "").replace(/[_\-]+/g, " ");
// Course code: 2-4 uppercase letters + 4 digits + optional letter (e.g. COMP2211, MATH1014H)
const courseMatch = base.match(/([A-Za-z]{2,4}\s*\d{4}[A-Za-z]?)/i);
if (courseMatch) {
result.courseCode = courseMatch[1].replace(/\s/g, "").toUpperCase();
}
// Year: 4-digit (2019-2029) or 2-digit (19-29)
const year4 = base.match(/\b(20[1-2]\d)\b/);
if (year4) {
result.year = Number(year4[1]);
} else {
const year2 = base.match(/\b(\d{2})\b/);
if (year2) {
const y = Number(year2[1]);
if (y >= 15 && y <= 29) result.year = 2000 + y;
}
}
// Term
const lower = base.toLowerCase();
if (/spring|spr/i.test(lower)) result.term = "spring";
else if (/fall|aut/i.test(lower)) result.term = "fall";
else if (/summer|sum/i.test(lower)) result.term = "summer";
// Exam type
if (/mid/i.test(lower)) result.examType = "midterm";
else if (/final|fin/i.test(lower)) result.examType = "final";
else if (/quiz/i.test(lower)) result.examType = "quiz";
return result;
}
export default function UploadForm() {
const navigate = useNavigate();
const [paperFile, setPaperFile] = useState<File | null>(null);
const [answerFile, setAnswerFile] = useState<File | null>(null);
const [courseCode, setCourseCode] = useState("");
const [year, setYear] = useState(new Date().getFullYear());
const [term, setTerm] = useState("fall");
const [examType, setExamType] = useState("midterm");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [autoFilled, setAutoFilled] = useState(false);
const handlePaperFile = useCallback((file: File | null) => {
setPaperFile(file);
if (!file) { setAutoFilled(false); return; }
const parsed = parseFilename(file.name);
const filled: string[] = [];
if (parsed.courseCode) { setCourseCode(parsed.courseCode); filled.push("course"); }
if (parsed.year) { setYear(parsed.year); filled.push("year"); }
if (parsed.term) { setTerm(parsed.term); filled.push("term"); }
if (parsed.examType) { setExamType(parsed.examType); filled.push("type"); }
setAutoFilled(filled.length > 0);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!paperFile || !courseCode) return;
setSubmitting(true);
setError(null);
try {
const fd = new FormData();
fd.append("paper_file", paperFile);
if (answerFile) fd.append("answer_file", answerFile);
fd.append("course_code", courseCode);
fd.append("year", String(year));
fd.append("term", term);
fd.append("exam_type", examType);
const result = await uploadPaper(fd);
navigate(`/paper/${result.paper_id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="max-w-lg mx-auto space-y-5">
<FilePickerField
label="Paper PDF"
required
file={paperFile}
onFileChange={handlePaperFile}
/>
{autoFilled && (
<div className="text-xs text-green-600 bg-green-50 px-3 py-1.5 rounded-lg -mt-3">
Auto-filled from filename please verify below
</div>
)}
<FilePickerField
label="Answer / Solution PDF (optional)"
file={answerFile}
onFileChange={setAnswerFile}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Course Code <span className="text-red-500">*</span>
</label>
<input
type="text"
value={courseCode}
onChange={(e) => setCourseCode(e.target.value.toUpperCase())}
placeholder="e.g. COMP2011"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Year</label>
<input
type="number"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Term</label>
<select
value={term}
onChange={(e) => setTerm(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="fall">Fall</option>
<option value="spring">Spring</option>
<option value="summer">Summer</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Exam Type</label>
<select
value={examType}
onChange={(e) => setExamType(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="midterm">Midterm</option>
<option value="final">Final</option>
<option value="quiz">Quiz</option>
</select>
</div>
</div>
{error && (
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>
)}
<button
type="submit"
disabled={!paperFile || !courseCode || submitting}
className="w-full bg-blue-600 text-white py-2.5 rounded-lg font-medium text-sm
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? "Uploading..." : "Upload & Analyze"}
</button>
</form>
);
}

View File

@@ -0,0 +1,58 @@
import type { Question } from "@/types/api";
export default function ActionBar({
question,
onGenerateVariant,
isGenerating,
onPhotoOpen,
answerState,
}: {
question: Question | null;
onGenerateVariant: () => void;
isGenerating: boolean;
onPhotoOpen: () => void;
answerState?: "correct" | "wrong" | null;
}) {
if (!question) return null;
const isLong = question.question_type === "long_question" || question.question_type === "long_answer" || question.question_type === "coding";
return (
<div className="border-t border-gray-200 bg-white px-4 py-3 shrink-0 space-y-2">
{/* Answer state feedback (for non-long questions, driven by QuestionDetail) */}
{answerState && (
<div className={`text-center text-sm font-medium py-1.5 rounded-lg ${
answerState === "correct"
? "bg-green-50 text-green-600"
: "bg-red-50 text-red-600"
}`}>
{answerState === "correct" ? "Correct!" : "Added to error book"}
</div>
)}
{/* Long question: Upload handwritten answer */}
{isLong && (
<button
onClick={onPhotoOpen}
className="w-full py-2.5 rounded-lg text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Upload handwritten answer
</button>
)}
{/* Generate variant — always available */}
<button
onClick={onGenerateVariant}
disabled={isGenerating}
className="w-full py-2 rounded-lg text-sm font-medium bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100 disabled:opacity-50 transition-colors"
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<span className="w-3 h-3 border-2 border-purple-600 border-t-transparent rounded-full animate-spin" />
Generating...
</span>
) : "Generate Variant"}
</button>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { Question } from "@/types/api";
import CollapsibleSection from "@/components/shared/CollapsibleSection";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
export default function AiTrioPanel({ question }: { question: Question }) {
return (
<div>
<CollapsibleSection title="Knowledge Reminder" colorScheme="blue" defaultOpen>
<KaTeXRenderer html={question.knowledge_reminder} />
</CollapsibleSection>
<CollapsibleSection title="AI Hint" colorScheme="amber">
<KaTeXRenderer html={question.ai_hint} />
</CollapsibleSection>
<CollapsibleSection title="Solution" colorScheme="green">
<KaTeXRenderer html={question.solution} />
</CollapsibleSection>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
export default function PdfViewer({
fileUrl,
currentPage,
onPageChange,
}: {
fileUrl: string;
currentPage?: number;
onPageChange?: (page: number) => void;
}) {
const [numPages, setNumPages] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const pageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [jumpPage, setJumpPage] = useState("");
const programmaticScroll = useRef(false);
// Resize observer for container width
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Scroll to page when currentPage changes (programmatic)
useEffect(() => {
if (!currentPage || currentPage < 1) return;
const el = pageRefs.current.get(currentPage);
if (el) {
programmaticScroll.current = true;
el.scrollIntoView({ behavior: "smooth", block: "start" });
setTimeout(() => { programmaticScroll.current = false; }, 2000);
}
}, [currentPage]);
// IntersectionObserver to detect visible page on user scroll
useEffect(() => {
if (numPages === 0 || !scrollRef.current) return;
const visiblePages = new Map<number, number>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const pageNum = Number(entry.target.getAttribute("data-page"));
if (entry.isIntersecting) {
visiblePages.set(pageNum, entry.intersectionRatio);
} else {
visiblePages.delete(pageNum);
}
}
// Don't fire callback during programmatic scroll
if (programmaticScroll.current) return;
// Find the page with the highest visibility ratio
let bestPage = 0;
let bestRatio = 0;
for (const [page, ratio] of visiblePages) {
if (ratio > bestRatio) {
bestRatio = ratio;
bestPage = page;
}
}
if (bestPage > 0) {
onPageChange?.(bestPage);
}
},
{
root: scrollRef.current,
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
for (const [, el] of pageRefs.current) {
observer.observe(el);
}
return () => observer.disconnect();
}, [numPages, onPageChange]);
const setPageRef = useCallback((pageNum: number, el: HTMLDivElement | null) => {
if (el) {
el.setAttribute("data-page", String(pageNum));
pageRefs.current.set(pageNum, el);
} else {
pageRefs.current.delete(pageNum);
}
}, []);
const handleJump = () => {
const p = parseInt(jumpPage, 10);
if (p >= 1 && p <= numPages) {
const el = pageRefs.current.get(p);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
setJumpPage("");
};
return (
<div ref={containerRef} className="h-full flex flex-col bg-gray-100">
{/* Page controls */}
<div className="flex items-center justify-center gap-3 py-2 bg-white border-b border-gray-200 text-sm shrink-0">
<span className="text-gray-600">
{numPages} pages
</span>
<span className="text-gray-300">|</span>
<span className="text-gray-600">
Go to{" "}
<input
type="number"
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleJump(); }}
placeholder="#"
className="w-12 text-center border border-gray-300 rounded px-1 py-0.5 text-sm"
min={1}
max={numPages}
/>
</span>
</div>
{/* All pages scrollable */}
<div ref={scrollRef} className="flex-1 overflow-auto">
<Document
file={fileUrl}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
loading={
<div className="flex items-center justify-center h-64 text-gray-400">
Loading PDF...
</div>
}
error={
<div className="flex items-center justify-center h-64 text-red-400">
Failed to load PDF
</div>
}
>
{numPages > 0 &&
Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => (
<div
key={pageNum}
ref={(el) => setPageRef(pageNum, el)}
className="flex justify-center mb-2"
>
<div className="bg-white shadow-sm">
<Page
pageNumber={pageNum}
width={containerWidth > 0 ? containerWidth - 48 : undefined}
renderAnnotationLayer
renderTextLayer
/>
</div>
</div>
))}
</Document>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useState, useRef } from "react";
import { uploadPhoto } from "@/lib/api";
import type { UserAttempt } from "@/types/api";
export default function PhotoUpload({
questionId,
onClose,
onSubmitted,
}: {
questionId: string;
onClose: () => void;
onSubmitted: (promise: Promise<{ attempt: UserAttempt; ocr_text: string; grade: { is_correct: boolean; score_given?: number; feedback: string } }>) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = (f: File) => {
setFile(f);
setPreview(URL.createObjectURL(f));
setError(null);
};
const handleSubmit = () => {
if (!file || submitting) return;
setSubmitting(true);
const promise = uploadPhoto(questionId, file);
// Close modal immediately, let parent handle the async result
onSubmitted(promise);
onClose();
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Upload Answer Photo</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
{!preview ? (
<div
onClick={() => inputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
>
<div className="text-3xl mb-2">📷</div>
<p className="text-sm text-gray-600">Click to take photo or select image</p>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
) : (
<div className="space-y-3">
<img src={preview} alt="Preview" className="w-full rounded-lg border" />
{error && (
<div className="text-sm text-red-600 bg-red-50 rounded-lg p-3">{error}</div>
)}
<div className="flex gap-2">
<button
onClick={() => { setFile(null); setPreview(null); }}
className="flex-1 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"
>
Retake
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="flex-1 py-2 rounded-lg text-sm bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
>
Submit for Grading
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
import { useState, useEffect } from "react";
import type { Question } from "@/types/api";
import { subquestionLabel } from "@/lib/questionGroups";
const typeLabels: Record<string, string> = {
mc: "Multiple Choice",
true_false: "True / False",
fill_blank: "Fill in Blank",
long_question: "Long Question",
long_answer: "Long Answer",
short_answer: "Short Answer",
coding: "Coding",
};
const difficultyColors: Record<string, string> = {
easy: "bg-green-100 text-green-700",
medium: "bg-yellow-100 text-yellow-700",
hard: "bg-red-100 text-red-700",
};
export default function QuestionDetail({
question,
onAnswerResult,
}: {
question: Question;
onAnswerResult?: (isCorrect: boolean, userAnswer: string) => void;
}) {
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const [checked, setChecked] = useState(false);
const [fillAnswer, setFillAnswer] = useState("");
const [fillChecked, setFillChecked] = useState(false);
// True/False: per-statement answers { "a": "True", "b": "False", ... }
const [tfAnswer, setTfAnswer] = useState<"True" | "False" | null>(null);
const [tfChecked, setTfChecked] = useState(false);
// Reset state when question changes
useEffect(() => {
setSelectedOption(null);
setChecked(false);
setFillAnswer("");
setFillChecked(false);
setTfAnswer(null);
setTfChecked(false);
}, [question.id]);
const isCorrectMc = checked && selectedOption === question.correct_option;
const isCorrectFill =
fillChecked &&
question.correct_answer != null &&
fillAnswer.trim().toLowerCase() === question.correct_answer.trim().toLowerCase();
const handleMcCheck = () => {
if (!selectedOption) return;
setChecked(true);
const correct = selectedOption === question.correct_option;
onAnswerResult?.(correct, selectedOption);
};
const handleFillCheck = () => {
if (!fillAnswer.trim()) return;
setFillChecked(true);
const correct =
question.correct_answer != null &&
fillAnswer.trim().toLowerCase() === question.correct_answer.trim().toLowerCase();
onAnswerResult?.(correct, fillAnswer.trim());
};
const getOptionStyle = (label: string) => {
if (!checked) {
return label === selectedOption
? "border-blue-400 bg-blue-50"
: "border-gray-200 hover:bg-gray-50";
}
if (label === question.correct_option) return "border-green-400 bg-green-50";
if (label === selectedOption) return "border-red-400 bg-red-50";
return "border-gray-200 opacity-50";
};
return (
<div className="mb-4">
{/* Header row */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-base font-bold text-gray-900">
Q{question.question_number.match(/^\d+/)?.[0] ?? question.question_number}
</span>
{question.question_number.replace(/^\d+/, "") && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{subquestionLabel(question)}
</span>
)}
<span className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700">
{typeLabels[question.question_type] ?? question.question_type}
</span>
{question.score != null && (
<span className="text-xs text-gray-500">{question.score} pts</span>
)}
{question.difficulty && (
<span
className={`text-xs px-2 py-0.5 rounded ${difficultyColors[question.difficulty] ?? ""}`}
>
{question.difficulty}
</span>
)}
</div>
{/* Topics */}
{question.topics && question.topics.length > 0 && (
<div className="flex gap-1 mb-3 flex-wrap">
{question.topics.map((t) => (
<span key={t} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{t}
</span>
))}
</div>
)}
{/* MC options */}
{question.question_type === "mc" && question.options && (
<>
<div className="mt-3 space-y-1.5">
{question.options.map((opt) => (
<button
key={opt.label}
onClick={() => { if (!checked) setSelectedOption(opt.label); }}
className={`w-full flex items-start gap-2 p-2 rounded-lg border text-sm text-left transition-colors ${getOptionStyle(opt.label)}`}
disabled={checked}
>
<span className={`font-semibold shrink-0 w-6 ${
checked && opt.label === question.correct_option ? "text-green-600" :
checked && opt.label === selectedOption ? "text-red-600" :
opt.label === selectedOption ? "text-blue-600" : "text-blue-600"
}`}>
{opt.label}.
</span>
<span className="text-gray-700">{opt.text}</span>
{checked && opt.label === question.correct_option && (
<span className="ml-auto text-green-600 text-xs font-medium shrink-0">Correct</span>
)}
</button>
))}
</div>
{!checked && selectedOption && (
<button
onClick={handleMcCheck}
className="mt-2 px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Check Answer
</button>
)}
{checked && (
<div className={`mt-2 text-sm font-medium ${isCorrectMc ? "text-green-600" : "text-red-600"}`}>
{isCorrectMc ? "Correct!" : `Wrong — the answer is ${question.correct_option}`}
</div>
)}
</>
)}
{/* True/False */}
{question.question_type === "true_false" && (() => {
// Normalize T/F/True/False to "true"/"false"
const normTF = (v: string | null | undefined): string => {
if (!v) return "";
const l = v.trim().toLowerCase();
if (l === "t" || l === "true") return "true";
if (l === "f" || l === "false") return "false";
return l;
};
const correctNorm = normTF(question.correct_option ?? question.correct_answer);
const correctDisplay = correctNorm === "true" ? "True" : "False";
return (
<>
<div className="mt-3 flex gap-2">
{(["True", "False"] as const).map((val) => {
const isSelected = tfAnswer === val;
const isCorrectVal = tfChecked && normTF(val) === correctNorm;
const isWrongVal = tfChecked && isSelected && !isCorrectVal;
return (
<button
key={val}
onClick={() => { if (!tfChecked) setTfAnswer(val); }}
disabled={tfChecked}
className={`flex-1 py-2 rounded-lg border text-sm font-semibold transition-colors ${
isCorrectVal
? "border-green-400 bg-green-50 text-green-700"
: isWrongVal
? "border-red-400 bg-red-50 text-red-700"
: isSelected
? "border-blue-400 bg-blue-50 text-blue-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{val === "True" ? "T — True" : "F — False"}
</button>
);
})}
</div>
{!tfChecked && tfAnswer && (
<button
onClick={() => {
setTfChecked(true);
const isCorrect = normTF(tfAnswer) === correctNorm;
onAnswerResult?.(isCorrect, tfAnswer);
}}
className="mt-2 px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Check Answer
</button>
)}
{tfChecked && (
<div className={`mt-2 text-sm font-medium ${
normTF(tfAnswer) === correctNorm ? "text-green-600" : "text-red-600"
}`}>
{normTF(tfAnswer) === correctNorm
? "Correct!"
: `Wrong — the answer is ${correctDisplay}`}
</div>
)}
</>
);
})()}
{/* Fill-blank input */}
{question.question_type === "fill_blank" && (
<div className="mt-3">
<div className="flex gap-2">
<input
type="text"
value={fillAnswer}
onChange={(e) => { if (!fillChecked) setFillAnswer(e.target.value); }}
placeholder="Type your answer..."
disabled={fillChecked}
className={`flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
fillChecked
? isCorrectFill ? "border-green-400 bg-green-50" : "border-red-400 bg-red-50"
: "border-gray-300"
}`}
onKeyDown={(e) => { if (e.key === "Enter") handleFillCheck(); }}
/>
{!fillChecked && (
<button
onClick={handleFillCheck}
disabled={!fillAnswer.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Check
</button>
)}
</div>
{fillChecked && (
<div className={`mt-2 text-sm font-medium ${isCorrectFill ? "text-green-600" : "text-red-600"}`}>
{isCorrectFill
? "Correct!"
: `Wrong — the answer is: ${question.correct_answer ?? "N/A"}`}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { Question } from "@/types/api";
import type { QuestionGroup } from "@/lib/questionGroups";
import { subquestionLabel } from "@/lib/questionGroups";
export default function QuestionNav({
groups,
currentGroupKey,
currentQuestionId,
onSelectGroup,
onSelectQuestion,
}: {
groups: QuestionGroup[];
currentGroupKey: string | null;
currentQuestionId: string | null;
onSelectGroup: (groupKey: string) => void;
onSelectQuestion: (questionId: string) => void;
}) {
const activeGroup = groups.find((group) => group.key === currentGroupKey) ?? null;
return (
<div className="border-b border-gray-200 bg-white px-4 py-2 shrink-0">
<div className="flex gap-1.5 overflow-x-auto hide-scrollbar">
{groups.map((group) => (
<button
key={group.key}
onClick={() => onSelectGroup(group.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors
${group.key === currentGroupKey
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{group.label}
</button>
))}
</div>
{activeGroup && activeGroup.questions.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto hide-scrollbar mt-2">
{activeGroup.questions.map((question) => (
<button
key={question.id}
onClick={() => onSelectQuestion(question.id)}
className={`px-2.5 py-1 rounded-md text-[11px] font-medium whitespace-nowrap transition-colors
${question.id === currentQuestionId
? "bg-blue-50 text-blue-700 border border-blue-200"
: "bg-gray-50 text-gray-500 border border-gray-200 hover:bg-gray-100"
}`}
>
{subquestionLabel(question)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { getSimilarQuestions } from "@/lib/api";
import type { Question, SimilarQuestion } from "@/types/api";
const typeLabel: Record<string, string> = {
mc: "MC",
true_false: "T/F",
fill_blank: "Fill",
long_question: "Long",
long_answer: "Long",
short_answer: "Short",
coding: "Code",
};
function matchColor(percent: number): string {
if (percent >= 80) return "bg-green-100 text-green-700";
if (percent >= 60) return "bg-amber-100 text-amber-700";
return "bg-gray-100 text-gray-600";
}
function cleanReason(reason: string): string {
// "Shared topic: foo_bar, baz_qux" → "Shared topic: Foo Bar, Baz Qux"
return reason.replace(/[_]/g, " ").replace(/:\s*(.+)$/, (_, rest) =>
": " + rest.split(",").map((s: string) =>
s.trim().replace(/\b\w/g, (c: string) => c.toUpperCase())
).join(", ")
);
}
export default function SimilarHistoryPanel({ question }: { question: Question }) {
const [items, setItems] = useState<SimilarQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setItems([]);
getSimilarQuestions(question.id)
.then((data) => {
if (cancelled) return;
setItems(data);
setLoading(false);
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load.");
setLoading(false);
});
return () => { cancelled = true; };
}, [question.id]);
return (
<div className="rounded-lg border border-blue-200 mb-3 overflow-hidden">
<button
onClick={() => setIsOpen((open) => !open)}
className="w-full flex items-center justify-between p-3 bg-blue-50"
>
<div className="flex items-center gap-2">
<span className="w-5 h-5 flex items-center justify-center rounded bg-blue-600 text-white text-xs font-bold">S</span>
<span className="font-semibold text-sm text-blue-800">Similar Questions</span>
</div>
<span className="text-xs text-blue-600">{loading ? "…" : items.length}</span>
</button>
{isOpen && (
<div className="p-2 space-y-1.5 bg-white">
{loading && <div className="text-xs text-gray-400 px-1 py-2">Loading</div>}
{!loading && error && (
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</div>
)}
{!loading && !error && items.length === 0 && (
<div className="text-xs text-gray-400 px-1 py-2">No similar questions found.</div>
)}
{items.map((item) => (
<Link
key={item.id}
to={`/paper/${item.paper_id}`}
className="flex items-center gap-2 px-2.5 py-2 rounded-lg border border-gray-100 hover:border-blue-200 hover:bg-blue-50/40 transition-colors"
>
{/* Match % badge */}
<span className={`shrink-0 text-[11px] font-bold px-1.5 py-0.5 rounded ${matchColor(item.match_percent)}`}>
{item.match_percent}%
</span>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold text-gray-700">{item.source}</span>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">Q{item.question_number}</span>
{item.question_type && (
<>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">{typeLabel[item.question_type] ?? item.question_type}</span>
</>
)}
</div>
{/* Topics + reasons in one row */}
<div className="flex gap-1 flex-wrap mt-1">
{item.topics.slice(0, 2).map((topic) => (
<span key={topic} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">
{topic}
</span>
))}
{item.match_reasons
?.filter((r) => !r.startsWith("Same format") && !r.startsWith("Same difficulty"))
.slice(0, 2)
.map((reason) => (
<span key={reason} className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-500">
{cleanReason(reason)}
</span>
))}
</div>
</div>
<span className="text-gray-300 text-xs shrink-0"></span>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from "react";
import type { VariantQuestion } from "@/types/api";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
import CollapsibleSection from "@/components/shared/CollapsibleSection";
export default function VariantDetail({
variant,
}: {
variant: VariantQuestion;
}) {
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const [checked, setChecked] = useState(false);
const [fillAnswer, setFillAnswer] = useState("");
const [fillChecked, setFillChecked] = useState(false);
const isMc = (variant.question_type === "mc" || variant.question_type === "true_false") && variant.options;
const handleMcCheck = () => {
if (!selectedOption) return;
setChecked(true);
};
const handleFillCheck = () => {
if (!fillAnswer.trim()) return;
setFillChecked(true);
};
const isCorrectMc = checked && selectedOption === variant.correct_answer;
const isCorrectFill =
fillChecked &&
fillAnswer.trim().toLowerCase() === variant.correct_answer.trim().toLowerCase();
const getOptionStyle = (label: string) => {
if (!checked) {
return label === selectedOption
? "border-blue-400 bg-blue-50"
: "border-gray-200 hover:bg-gray-50";
}
if (label === variant.correct_answer) return "border-green-400 bg-green-50";
if (label === selectedOption) return "border-red-400 bg-red-50";
return "border-gray-200 opacity-50";
};
return (
<div>
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className="w-5 h-5 flex items-center justify-center bg-purple-600 text-white text-xs font-bold rounded-full">V</span>
<span className="text-sm font-semibold text-gray-900">Similar Question</span>
<span className="text-xs px-2 py-0.5 rounded bg-purple-100 text-purple-700">
{variant.question_type}
</span>
</div>
{/* Question text */}
<div className="text-sm text-gray-800 leading-relaxed bg-purple-50 rounded-lg p-3 border border-purple-200 mb-4">
<KaTeXRenderer html={variant.question_text} />
</div>
{/* MC options */}
{isMc && variant.options && (
<>
<div className="space-y-1.5">
{variant.options.map((opt) => (
<button
key={opt.label}
onClick={() => { if (!checked) setSelectedOption(opt.label); }}
disabled={checked}
className={`w-full flex items-start gap-2 p-2 rounded-lg border text-sm text-left transition-colors ${getOptionStyle(opt.label)}`}
>
<span className="font-semibold shrink-0 w-6 text-blue-600">{opt.label}.</span>
<span className="text-gray-700">{opt.text}</span>
{checked && opt.label === variant.correct_answer && (
<span className="ml-auto text-green-600 text-xs font-medium shrink-0">Correct</span>
)}
</button>
))}
</div>
{!checked && selectedOption && (
<button
onClick={handleMcCheck}
className="mt-2 px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700"
>
Check Answer
</button>
)}
{checked && (
<div className={`mt-2 text-sm font-medium ${isCorrectMc ? "text-green-600" : "text-red-600"}`}>
{isCorrectMc ? "Correct!" : `Wrong — the answer is ${variant.correct_answer}`}
</div>
)}
</>
)}
{/* Non-MC input */}
{!isMc && (
<div className="mb-3">
<div className="flex gap-2">
<input
type="text"
value={fillAnswer}
onChange={(e) => { if (!fillChecked) setFillAnswer(e.target.value); }}
placeholder="Type your answer..."
disabled={fillChecked}
className={`flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
fillChecked
? isCorrectFill ? "border-green-400 bg-green-50" : "border-red-400 bg-red-50"
: "border-gray-300"
}`}
onKeyDown={(e) => { if (e.key === "Enter") handleFillCheck(); }}
/>
{!fillChecked && (
<button
onClick={handleFillCheck}
disabled={!fillAnswer.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
Check
</button>
)}
</div>
{fillChecked && (
<div className={`mt-2 text-sm font-medium ${isCorrectFill ? "text-green-600" : "text-red-600"}`}>
{isCorrectFill ? "Correct!" : `Answer: ${variant.correct_answer}`}
</div>
)}
</div>
)}
{/* AI Trio */}
<div className="mt-4 space-y-2">
{variant.knowledge_reminder && (
<CollapsibleSection title="Knowledge Reminder" colorScheme="blue">
<KaTeXRenderer html={variant.knowledge_reminder} />
</CollapsibleSection>
)}
{variant.ai_hint && (
<CollapsibleSection title="AI Hint" colorScheme="amber">
<KaTeXRenderer html={variant.ai_hint} />
</CollapsibleSection>
)}
<CollapsibleSection title="Solution" colorScheme="green">
<KaTeXRenderer html={variant.solution} />
</CollapsibleSection>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import type { VariantQuestion } from "@/types/api";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
export default function VariantModal({
variant,
onClose,
}: {
variant: VariantQuestion;
onClose: () => void;
}) {
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const [checked, setChecked] = useState(false);
const [fillAnswer, setFillAnswer] = useState("");
const [fillChecked, setFillChecked] = useState(false);
const [showKnowledge, setShowKnowledge] = useState(false);
const [showHint, setShowHint] = useState(false);
const [showSolution, setShowSolution] = useState(false);
const isMc = (variant.question_type === "mc" || variant.question_type === "true_false") && variant.options;
const handleMcCheck = () => {
if (!selectedOption) return;
setChecked(true);
};
const handleFillCheck = () => {
if (!fillAnswer.trim()) return;
setFillChecked(true);
};
const isCorrectMc = checked && selectedOption === variant.correct_answer;
const isCorrectFill =
fillChecked &&
fillAnswer.trim().toLowerCase() === variant.correct_answer.trim().toLowerCase();
const getOptionStyle = (label: string) => {
if (!checked) {
return label === selectedOption
? "border-blue-400 bg-blue-50"
: "border-gray-200 hover:bg-gray-50";
}
if (label === variant.correct_answer) return "border-green-400 bg-green-50";
if (label === selectedOption) return "border-red-400 bg-red-50";
return "border-gray-200 opacity-50";
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Similar Question</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
{/* Question text */}
<div className="text-sm text-gray-800 leading-relaxed bg-gray-50 rounded-lg p-3 border border-gray-200 mb-3">
<KaTeXRenderer html={variant.question_text} />
</div>
{/* MC options */}
{isMc && variant.options && (
<>
<div className="space-y-1.5">
{variant.options.map((opt) => (
<button
key={opt.label}
onClick={() => { if (!checked) setSelectedOption(opt.label); }}
disabled={checked}
className={`w-full flex items-start gap-2 p-2 rounded-lg border text-sm text-left transition-colors ${getOptionStyle(opt.label)}`}
>
<span className="font-semibold shrink-0 w-6 text-blue-600">{opt.label}.</span>
<span className="text-gray-700">{opt.text}</span>
{checked && opt.label === variant.correct_answer && (
<span className="ml-auto text-green-600 text-xs font-medium shrink-0">Correct</span>
)}
</button>
))}
</div>
{!checked && selectedOption && (
<button
onClick={handleMcCheck}
className="mt-2 px-4 py-1.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700"
>
Check Answer
</button>
)}
{checked && (
<div className={`mt-2 text-sm font-medium ${isCorrectMc ? "text-green-600" : "text-red-600"}`}>
{isCorrectMc ? "Correct!" : `Wrong — the answer is ${variant.correct_answer}`}
</div>
)}
</>
)}
{/* Non-MC input */}
{!isMc && (
<div className="mt-1">
<div className="flex gap-2">
<input
type="text"
value={fillAnswer}
onChange={(e) => { if (!fillChecked) setFillAnswer(e.target.value); }}
placeholder="Type your answer..."
disabled={fillChecked}
className={`flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
fillChecked
? isCorrectFill ? "border-green-400 bg-green-50" : "border-red-400 bg-red-50"
: "border-gray-300"
}`}
onKeyDown={(e) => { if (e.key === "Enter") handleFillCheck(); }}
/>
{!fillChecked && (
<button
onClick={handleFillCheck}
disabled={!fillAnswer.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
Check
</button>
)}
</div>
{fillChecked && (
<div className={`mt-2 text-sm font-medium ${isCorrectFill ? "text-green-600" : "text-red-600"}`}>
{isCorrectFill ? "Correct!" : `Answer: ${variant.correct_answer}`}
</div>
)}
</div>
)}
{/* AI Trio: Knowledge / Hint / Solution */}
<div className="mt-4 border-t pt-3 space-y-2">
{variant.knowledge_reminder && (
<div>
<button
onClick={() => setShowKnowledge(!showKnowledge)}
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
{showKnowledge ? "▾ Hide Knowledge" : "▸ Knowledge Reminder"}
</button>
{showKnowledge && (
<div className="mt-2 bg-blue-50 rounded-lg p-3 text-sm border border-blue-200">
<KaTeXRenderer html={variant.knowledge_reminder} />
</div>
)}
</div>
)}
{variant.ai_hint && (
<div>
<button
onClick={() => setShowHint(!showHint)}
className="text-sm text-amber-600 hover:text-amber-800 font-medium"
>
{showHint ? "▾ Hide Hint" : "▸ AI Hint"}
</button>
{showHint && (
<div className="mt-2 bg-amber-50 rounded-lg p-3 text-sm border border-amber-200">
<KaTeXRenderer html={variant.ai_hint} />
</div>
)}
</div>
)}
<div>
<button
onClick={() => setShowSolution(!showSolution)}
className="text-sm text-green-600 hover:text-green-800 font-medium"
>
{showSolution ? "▾ Hide Solution" : "▸ Solution"}
</button>
{showSolution && (
<div className="mt-2 bg-green-50 rounded-lg p-3 text-sm border border-green-200">
<KaTeXRenderer html={variant.solution} />
</div>
)}
</div>
</div>
<button
onClick={onClose}
className="mt-4 w-full py-2 rounded-lg text-sm bg-gray-100 text-gray-700 font-medium hover:bg-gray-200"
>
Close
</button>
</div>
</div>
</div>
);
}