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

30
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Navigate, Routes, Route } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext";
import ProcessingBanner from "./components/layout/ProcessingBanner";
import LoginPage from "./pages/LoginPage";
import HomePage from "./pages/HomePage";
import UploadPage from "./pages/UploadPage";
import WorkbenchPage from "./pages/WorkbenchPage";
import ErrorBookPage from "./pages/ErrorBookPage";
import AnalyticsPage from "./pages/AnalyticsPage";
export default function App() {
const { session, loading } = useAuth();
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><div className="text-gray-400 text-sm">Loading...</div></div>;
return (
<>
<ProcessingBanner />
<Routes>
<Route path="/login" element={session ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/paper/:id" element={<WorkbenchPage />} />
<Route path="/error-book" element={<ErrorBookPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/analytics/:courseCode" element={<AnalyticsPage />} />
</Routes>
</>
);
}

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

View File

@@ -0,0 +1,49 @@
import { createContext, useContext, useEffect, useState } from "react";
import type { Session, User } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
interface AuthContextValue {
session: Session | null;
user: User | null;
loading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue>({
session: null,
user: null,
loading: true,
signOut: async () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
await supabase.auth.signOut();
};
return (
<AuthContext.Provider value={{ session, user: session?.user ?? null, loading, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { getPaper } from "@/lib/api";
import type { Paper } from "@/types/api";
const POLL_INTERVAL = 3000;
export function usePaper(paperId: string) {
const [paper, setPaper] = useState<Paper | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let intervalId: number | null = null;
let cancelled = false;
const fetchPaper = async () => {
try {
const data = await getPaper(paperId);
if (cancelled) return;
setPaper(data);
setLoading(false);
if (data.status === "ready" || data.status === "error") {
if (intervalId !== null) clearInterval(intervalId);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Unknown error");
setLoading(false);
if (intervalId !== null) clearInterval(intervalId);
}
};
fetchPaper();
intervalId = window.setInterval(fetchPaper, POLL_INTERVAL);
return () => {
cancelled = true;
if (intervalId !== null) clearInterval(intervalId);
};
}, [paperId]);
return { paper, loading, error };
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { getQuestions } from "@/lib/api";
import type { Question } from "@/types/api";
export function useQuestions(paperId: string, enabled: boolean) {
const [questions, setQuestions] = useState<Question[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
getQuestions(paperId)
.then((data) => {
if (!cancelled) {
setQuestions(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Unknown error");
setLoading(false);
}
});
return () => { cancelled = true; };
}, [paperId, enabled]);
return { questions, loading, error };
}

190
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,190 @@
import type {
CourseAnalytics,
Paper,
Question,
QuestionVariant,
SimilarQuestion,
UploadResponse,
UserAttempt,
} from "@/types/api";
import { supabase } from "@/lib/supabase";
const API_BASE = "/api";
async function authHeaders(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (!token) return {};
return { Authorization: `Bearer ${token}` };
}
export async function uploadPaper(formData: FormData): Promise<UploadResponse> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/papers/upload`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
}
export async function getPaper(paperId: string): Promise<Paper> {
const res = await fetch(`${API_BASE}/papers/${paperId}`);
if (!res.ok) throw new Error(`Paper not found: ${res.status}`);
return res.json();
}
export async function getQuestions(paperId: string): Promise<Question[]> {
const res = await fetch(`${API_BASE}/papers/${paperId}/questions`);
if (!res.ok) throw new Error(`Questions fetch failed: ${res.status}`);
return res.json();
}
export async function myPapers(): Promise<Paper[]> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/papers/mine`, { headers });
if (!res.ok) throw new Error(`My papers fetch failed: ${res.status}`);
return res.json();
}
export async function listPapers(): Promise<Paper[]> {
const res = await fetch(`${API_BASE}/papers/`);
if (!res.ok) throw new Error(`List papers failed: ${res.status}`);
return res.json();
}
export async function recordAttempt(
questionId: string,
attemptType: string,
userAnswer: string | null,
isCorrect: boolean | null,
): Promise<UserAttempt> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/attempts/`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
question_id: questionId,
attempt_type: attemptType,
user_answer: userAnswer,
is_correct: isCorrect,
}),
});
if (!res.ok) throw new Error(`Attempt save failed: ${res.status}`);
return res.json();
}
export async function uploadPhoto(
questionId: string,
photo: File,
): Promise<{ attempt: UserAttempt; ocr_text: string; grade: { is_correct: boolean; score_given?: number; feedback: string; error_at_step: number | null } }> {
const headers = await authHeaders();
const fd = new FormData();
fd.append("question_id", questionId);
fd.append("photo", photo);
const res = await fetch(`${API_BASE}/attempts/photo`, {
method: "POST",
headers,
body: fd,
});
if (!res.ok) throw new Error(`Photo upload failed: ${res.status}`);
return res.json();
}
export async function getPaperAttempts(paperId: string): Promise<{
question_id: string;
is_correct: boolean;
feedback: string | null;
photo_ocr_text: string | null;
}[]> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/attempts/by-paper/${paperId}`, { headers });
if (!res.ok) return [];
return res.json();
}
export async function generateVariant(questionId: string): Promise<QuestionVariant> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/questions/${questionId}/variant`, {
method: "POST",
headers,
});
if (!res.ok) throw new Error(`Variant generation failed: ${res.status}`);
return res.json();
}
export async function getVariants(questionId: string): Promise<QuestionVariant[]> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/questions/${questionId}/variants`, { headers });
if (!res.ok) throw new Error(`Variants fetch failed: ${res.status}`);
return res.json();
}
export async function updateVariant(variantId: string, data: { favorited?: boolean }): Promise<QuestionVariant> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/questions/variant/${variantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`Variant update failed: ${res.status}`);
return res.json();
}
export async function deleteVariant(variantId: string): Promise<void> {
const headers = await authHeaders();
await fetch(`${API_BASE}/questions/variant/${variantId}`, { method: "DELETE", headers });
}
export async function getFavoriteVariants(): Promise<QuestionVariant[]> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/questions/variants/favorited`, { headers });
if (!res.ok) throw new Error(`Favorited variants fetch failed: ${res.status}`);
return res.json();
}
export async function getErrorBook(courseCode?: string): Promise<UserAttempt[]> {
const headers = await authHeaders();
const params = new URLSearchParams();
if (courseCode) params.set("course_code", courseCode);
const query = params.toString() ? `?${params.toString()}` : "";
const res = await fetch(`${API_BASE}/attempts/error-book${query}`, { headers });
if (!res.ok) throw new Error(`Error book fetch failed: ${res.status}`);
return res.json();
}
export async function updateAttempt(
attemptId: string,
data: { in_error_book?: boolean; mastered?: boolean },
): Promise<UserAttempt> {
const headers = await authHeaders();
const res = await fetch(`${API_BASE}/attempts/${attemptId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`Attempt update failed: ${res.status}`);
return res.json();
}
export async function listCourses(): Promise<string[]> {
const res = await fetch(`${API_BASE}/analytics/courses`);
if (!res.ok) throw new Error(`Courses fetch failed: ${res.status}`);
return res.json();
}
export async function getCourseAnalytics(courseCode: string): Promise<CourseAnalytics> {
const res = await fetch(`${API_BASE}/analytics/course/${courseCode}`);
if (!res.ok) throw new Error(`Analytics fetch failed: ${res.status}`);
return res.json();
}
export async function getSimilarQuestions(
questionId: string,
limit = 6,
): Promise<SimilarQuestion[]> {
const res = await fetch(`${API_BASE}/questions/${questionId}/similar?limit=${limit}`);
if (!res.ok) throw new Error(`Similar question fetch failed: ${res.status}`);
return res.json();
}

View File

@@ -0,0 +1,45 @@
import type { Question } from "@/types/api";
export interface QuestionGroup {
key: string;
label: string;
questions: Question[];
startPage: number;
}
function topLevelKey(questionNumber: string): string {
const match = questionNumber.match(/^\d+/);
return match?.[0] ?? questionNumber;
}
export function groupQuestions(questions: Question[]): QuestionGroup[] {
const groups = new Map<string, QuestionGroup>();
for (const question of questions) {
const key = topLevelKey(question.question_number);
const existing = groups.get(key);
if (existing) {
existing.questions.push(question);
existing.startPage = Math.min(existing.startPage, question.page_number ?? existing.startPage);
continue;
}
groups.set(key, {
key,
label: `Q${key}`,
questions: [question],
startPage: question.page_number ?? 1,
});
}
return Array.from(groups.values()).sort((a, b) => Number(a.key) - Number(b.key));
}
export function subquestionLabel(question: Question): string {
const remainder = question.question_number.replace(/^\d+/, "");
if (!remainder) return "Main";
return remainder
.replace(/^_+/, "")
.split("_")
.filter(Boolean)
.join(".");
}

View File

@@ -0,0 +1,6 @@
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

16
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AuthProvider } from "./contexts/AuthContext";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,521 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import Header from "@/components/layout/Header";
import { getCourseAnalytics, listCourses } from "@/lib/api";
import type { CourseAnalytics, AnalyticsTopicQuestion } from "@/types/api";
const typeLabel: Record<string, string> = {
mc: "Multiple Choice",
true_false: "True / False",
fill_blank: "Fill in Blank",
long_question: "Long Question",
short_answer: "Short Answer",
coding: "Coding",
};
const TYPE_COLORS: Record<string, string> = {
mc: "bg-violet-50 text-violet-700 border-violet-200",
true_false: "bg-amber-50 text-amber-700 border-amber-200",
fill_blank: "bg-teal-50 text-teal-700 border-teal-200",
long_question: "bg-sky-50 text-sky-700 border-sky-200",
short_answer: "bg-rose-50 text-rose-700 border-rose-200",
coding: "bg-emerald-50 text-emerald-700 border-emerald-200",
};
const DIFF_COLORS: Record<string, string> = {
hard: "text-red-600 bg-red-50 border-red-200",
medium: "text-amber-600 bg-amber-50 border-amber-200",
easy: "text-green-600 bg-green-50 border-green-200",
};
type QItem = AnalyticsTopicQuestion;
type Analytics = CourseAnalytics;
const PAGE_SIZE = 8;
export default function AnalyticsPage() {
const { courseCode } = useParams<{ courseCode?: string }>();
const navigate = useNavigate();
const [courses, setCourses] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => { listCourses().then(setCourses).catch(() => {}); }, []);
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
return q ? courses.filter((c) => c.includes(q)) : courses;
}, [courses, search]);
const normalizedCourse = courseCode?.toUpperCase();
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!normalizedCourse) return;
let cancelled = false;
setLoading(true);
setAnalytics(null);
setError(null);
getCourseAnalytics(normalizedCourse)
.then((data) => { if (!cancelled) { setAnalytics(data); setLoading(false); } })
.catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : "Failed"); setLoading(false); } });
return () => { cancelled = true; };
}, [normalizedCourse]);
// ── Course picker ──
if (!normalizedCourse) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-2xl mx-auto px-6 py-12">
<h1 className="text-2xl font-bold text-gray-900 mb-1">Analytics</h1>
<p className="text-sm text-gray-500 mb-6">Select a course to view statistics.</p>
<input
type="text"
placeholder="Search course code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
/>
{filtered.length === 0 ? (
<p className="text-sm text-gray-400">No courses found.</p>
) : (
<div className="grid grid-cols-2 gap-3">
{filtered.map((code) => (
<button key={code} onClick={() => navigate(`/analytics/${code}`)}
className="text-left px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:bg-blue-50 transition-colors">
<span className="font-semibold text-gray-900">{code}</span>
</button>
))}
</div>
)}
</main>
</div>
);
}
// ── Dashboard ──
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<button onClick={() => navigate("/analytics")} className="text-sm text-gray-400 hover:text-gray-600"> All courses</button>
<span className="text-gray-300">/</span>
<h1 className="text-2xl font-bold text-gray-900">{normalizedCourse}</h1>
</div>
{loading && <div className="text-sm text-gray-400">Loading analytics...</div>}
{error && <div className="text-sm text-red-600">{error}</div>}
{!loading && !error && analytics && (
<>
{/* KPI row */}
<section className="grid grid-cols-4 gap-4 mb-6">
<KpiCard label="Papers" value={analytics.kpi.papers} />
<KpiCard label="Questions" value={analytics.kpi.questions} />
<KpiCard label="Topics" value={analytics.kpi.topics} />
<KpiCard label="Avg Difficulty" value={analytics.kpi.difficulty} />
</section>
{/* Main area: left = search, right = charts */}
<section className="grid grid-cols-[5fr_2fr] gap-6">
{/* Left: Global search */}
<GlobalSearch questions={analytics.all_questions} topics={analytics.topic_frequency.map((t) => t.label)} />
{/* Right: Interactive charts + stats */}
<div className="space-y-5">
<InteractiveChart
topicData={analytics.topic_frequency.slice(0, 8).map((t) => ({ label: t.label, value: t.count }))}
typeData={analytics.question_types.map((t) => ({ label: typeLabel[t.label] ?? t.label, value: t.count }))}
diffData={[
{ label: "Easy", value: analytics.difficulty_distribution.easy },
{ label: "Medium", value: analytics.difficulty_distribution.medium },
{ label: "Hard", value: analytics.difficulty_distribution.hard },
].filter((d) => d.value > 0)}
/>
<Panel title="High Yield Topics">
{analytics.high_yield_topics.length === 0 ? (
<div className="text-sm text-gray-400">No data yet.</div>
) : (
<ul className="space-y-2">
{analytics.high_yield_topics.map((t, i) => (
<li key={t} className="flex items-center gap-3 text-sm text-gray-700">
<span className="w-6 h-6 rounded-full bg-red-50 text-red-600 flex items-center justify-center text-xs font-semibold">{i + 1}</span>
<span>{t}</span>
</li>
))}
</ul>
)}
</Panel>
</div>
</section>
</>
)}
</main>
</div>
);
}
// ── Global Search Engine ──
function GlobalSearch({ questions, topics }: { questions: QItem[]; topics: string[] }) {
const [search, setSearch] = useState("");
const [topicFilter, setTopicFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [yearFilter, setYearFilter] = useState<number | null>(null);
const [termFilter, setTermFilter] = useState<string | null>(null);
const [diffFilter, setDiffFilter] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const types = useMemo(() => [...new Set(questions.map((q) => q.question_type))].sort(), [questions]);
const years = useMemo(() => [...new Set(questions.map((q) => q.year).filter(Boolean))].sort((a, b) => (b ?? 0) - (a ?? 0)) as number[], [questions]);
const terms = useMemo(() => {
const order = ["spring", "summer", "fall", "winter"];
return [...new Set(questions.map((q) => q.term).filter(Boolean))].sort((a, b) => order.indexOf(a!) - order.indexOf(b!)) as string[];
}, [questions]);
const diffs = useMemo(() => [...new Set(questions.map((q) => q.difficulty).filter(Boolean))] as string[], [questions]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return questions.filter((item) => {
if (topicFilter && !item.topics?.includes(topicFilter)) return false;
if (typeFilter && item.question_type !== typeFilter) return false;
if (yearFilter && item.year !== yearFilter) return false;
if (termFilter && item.term !== termFilter) return false;
if (diffFilter && item.difficulty !== diffFilter) return false;
if (q && !item.preview.toLowerCase().includes(q) && !item.source.toLowerCase().includes(q) && !item.question_number.toLowerCase().includes(q) && !item.topics?.some((t) => t.toLowerCase().includes(q))) return false;
return true;
});
}, [questions, search, topicFilter, typeFilter, yearFilter, termFilter, diffFilter]);
const activeCount = [topicFilter, typeFilter, yearFilter, termFilter, diffFilter].filter(Boolean).length;
useEffect(() => setVisibleCount(PAGE_SIZE), [search, topicFilter, typeFilter, yearFilter, termFilter, diffFilter]);
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
return (
<div className="bg-white border border-gray-200 rounded-2xl p-6">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Question Search</h2>
{/* Search bar */}
<div className="relative mb-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search questions, topics, papers..."
className="w-full pl-9 pr-3 py-2.5 text-sm border border-gray-200 rounded-xl bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
{/* Filter rows */}
<div className="space-y-2 mb-3">
{/* Topic */}
<FilterRow label="Topic">
<TopicCombobox topics={topics} value={topicFilter} onChange={setTopicFilter} />
</FilterRow>
{/* Type + Year + Term + Difficulty in one row */}
<div className="flex items-center gap-3 flex-wrap">
<FilterRow label="Type">
<div className="flex gap-1 flex-wrap">
{types.map((t) => (
<Pill key={t} label={typeLabel[t] ?? t} active={typeFilter === t}
color={TYPE_COLORS[t]} onClick={() => setTypeFilter(typeFilter === t ? null : t)} />
))}
</div>
</FilterRow>
<FilterRow label="Year">
<div className="flex gap-1 flex-wrap">
{years.map((y) => (
<Pill key={y} label={String(y)} active={yearFilter === y}
onClick={() => setYearFilter(yearFilter === y ? null : y)} />
))}
</div>
</FilterRow>
<FilterRow label="Term">
<div className="flex gap-1 flex-wrap">
{terms.map((t) => (
<Pill key={t} label={t.charAt(0).toUpperCase() + t.slice(1)} active={termFilter === t}
onClick={() => setTermFilter(termFilter === t ? null : t)} />
))}
</div>
</FilterRow>
<FilterRow label="Diff">
<div className="flex gap-1">
{(["easy", "medium", "hard"] as const).filter((d) => diffs.includes(d)).map((d) => (
<Pill key={d} label={d.charAt(0).toUpperCase() + d.slice(1)} active={diffFilter === d}
color={DIFF_COLORS[d]} onClick={() => setDiffFilter(diffFilter === d ? null : d)} />
))}
</div>
</FilterRow>
</div>
</div>
{/* Results count + clear */}
<div className="flex items-center justify-between mb-3 pb-3 border-b border-gray-100">
<span className="text-xs text-gray-400">
{filtered.length} question{filtered.length !== 1 ? "s" : ""}
{activeCount > 0 || search ? " matched" : ""}
</span>
{(activeCount > 0 || search) && (
<button onClick={() => { setTopicFilter(null); setTypeFilter(null); setYearFilter(null); setTermFilter(null); setDiffFilter(null); setSearch(""); }}
className="text-xs text-blue-500 hover:text-blue-700">Clear all</button>
)}
</div>
{/* Results */}
<div className="space-y-2">
{visible.map((q, i) => (
<QuestionCard key={`${q.paper_id}-${q.question_number}-${i}`} question={q} />
))}
</div>
{hasMore && (
<button onClick={() => setVisibleCount((v) => v + PAGE_SIZE)}
className="w-full mt-3 py-2 text-xs text-blue-600 hover:text-blue-700 bg-blue-50 rounded-xl font-medium">
Show more ({filtered.length - visibleCount} remaining)
</button>
)}
{filtered.length === 0 && (
<div className="text-center py-6 text-sm text-gray-400">No questions match your search.</div>
)}
</div>
);
}
// ── Interactive Pie Chart ──
const PIE_PALETTE = [
"#3B82F6", "#8B5CF6", "#F59E0B", "#10B981", "#EF4444",
"#EC4899", "#06B6D4", "#F97316", "#6366F1", "#14B8A6",
];
function InteractiveChart({ topicData, typeData, diffData }: {
topicData: { label: string; value: number }[];
typeData: { label: string; value: number }[];
diffData: { label: string; value: number }[];
}) {
const [view, setView] = useState<"topic" | "type" | "difficulty">("topic");
const [hovered, setHovered] = useState<number | null>(null);
const data = view === "topic" ? topicData : view === "type" ? typeData : diffData;
const colors = view === "difficulty"
? ["#10B981", "#F59E0B", "#EF4444"]
: PIE_PALETTE;
const total = data.reduce((s, d) => s + d.value, 0);
// Build conic-gradient
let cumPct = 0;
const segments = data.map((d, i) => {
const pct = total ? (d.value / total) * 100 : 0;
const start = cumPct;
cumPct += pct;
return { ...d, pct, start, end: cumPct, color: colors[i % colors.length] };
});
const gradient = segments
.map((s) => `${s.color} ${s.start}% ${s.end}%`)
.join(", ");
return (
<section className="bg-white border border-gray-200 rounded-2xl p-5">
{/* Tab switcher */}
<div className="flex gap-1 mb-4">
{(["topic", "type", "difficulty"] as const).map((t) => (
<button key={t} onClick={() => { setView(t); setHovered(null); }}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors ${
view === t ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-500 hover:text-gray-700"
}`}>
{t === "topic" ? "Topics" : t === "type" ? "Types" : "Difficulty"}
</button>
))}
</div>
{/* Pie */}
<div className="flex items-center gap-4">
<div className="relative w-36 h-36 shrink-0">
<div
className="w-full h-full rounded-full"
style={{ background: `conic-gradient(${gradient})` }}
/>
<div className="absolute inset-3 bg-white rounded-full flex items-center justify-center">
{hovered !== null ? (
<div className="text-center">
<div className="text-lg font-bold text-gray-900">{segments[hovered].value}</div>
<div className="text-[9px] text-gray-400">{segments[hovered].pct.toFixed(0)}%</div>
</div>
) : (
<div className="text-center">
<div className="text-lg font-bold text-gray-900">{total}</div>
<div className="text-[9px] text-gray-400">total</div>
</div>
)}
</div>
</div>
{/* Legend */}
<div className="flex-1 space-y-1 max-h-36 overflow-y-auto">
{segments.map((s, i) => (
<div
key={s.label}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
className={`flex items-center gap-2 px-2 py-1 rounded-lg cursor-default transition-colors ${
hovered === i ? "bg-gray-50" : ""
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-xs text-gray-700 flex-1 truncate">{s.label}</span>
<span className="text-xs text-gray-400 tabular-nums">{s.value}</span>
</div>
))}
</div>
</div>
</section>
);
}
// ── Shared components ──
function QuestionCard({ question: q }: { question: QItem }) {
const typeColor = TYPE_COLORS[q.question_type] ?? "bg-gray-50 text-gray-600 border-gray-200";
const cleanPreview = (q.preview || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
.replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "")
.trim();
return (
<Link to={`/paper/${q.paper_id}`}
className="flex items-start gap-3 bg-gray-50 border border-gray-200 rounded-xl px-3.5 py-2.5 hover:border-blue-300 hover:bg-white hover:shadow-sm transition-all group">
<span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5">
{q.question_number}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
<span className="text-xs font-medium text-blue-600">{q.source}</span>
<span className="text-gray-300">·</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${typeColor}`}>
{typeLabel[q.question_type] ?? q.question_type}
</span>
{q.difficulty && (
<>
<span className="text-gray-300">·</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${DIFF_COLORS[q.difficulty] ?? ""}`}>
{q.difficulty}
</span>
</>
)}
{q.topics?.slice(0, 2).map((t) => (
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200">{t}</span>
))}
</div>
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">{cleanPreview || q.preview}</p>
</div>
<span className="shrink-0 text-gray-300 group-hover:text-blue-500 text-sm pt-1"></span>
</Link>
);
}
function FilterRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-gray-400 w-10 shrink-0">{label}</span>
{children}
</div>
);
}
function Pill({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) {
return (
<button onClick={onClick}
className={`text-[10px] px-2 py-1 rounded-full border font-medium transition-colors whitespace-nowrap ${
active ? (color ?? "bg-blue-50 text-blue-700 border-blue-200") : "bg-white text-gray-400 border-gray-200 hover:text-gray-600"
}`}>
{label}
</button>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-white border border-gray-200 rounded-2xl p-5">
<div className="text-2xl font-semibold text-gray-900">{value}</div>
<div className="text-xs uppercase tracking-wide text-gray-400 mt-2">{label}</div>
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-white border border-gray-200 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">{title}</h2>
{children}
</section>
);
}
function TopicCombobox({ topics, value, onChange }: { topics: string[]; value: string | null; onChange: (v: string | null) => void }) {
const [input, setInput] = useState("");
const [open, setOpen] = useState(false);
const filtered = useMemo(() => {
const q = input.toLowerCase();
return q ? topics.filter((t) => t.toLowerCase().includes(q)) : topics;
}, [topics, input]);
const handleSelect = (t: string | null) => {
onChange(t);
setInput(t ?? "");
setOpen(false);
};
return (
<div className="relative">
<div className="flex items-center gap-1">
<input
type="text"
value={value ? (input || value) : input}
onChange={(e) => { setInput(e.target.value); setOpen(true); if (!e.target.value) onChange(null); }}
onFocus={() => setOpen(true)}
placeholder="All Topics"
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-48"
/>
{value && (
<button onClick={() => { onChange(null); setInput(""); }} className="text-gray-400 hover:text-gray-600 text-xs"></button>
)}
</div>
{open && filtered.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-56 max-h-48 overflow-y-auto bg-white border border-gray-200 rounded-lg shadow-lg">
{filtered.map((t) => (
<button
key={t}
onClick={() => handleSelect(t)}
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-blue-50 transition-colors ${value === t ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-700"}`}
>
{t}
</button>
))}
</div>
)}
{open && <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />}
</div>
);
}
function DiffStat({ label, value }: { label: string; value: number }) {
return (
<div className="bg-gray-50 rounded-xl px-3 py-4">
<div className="text-xl font-semibold text-gray-900">{value}</div>
<div className="text-xs uppercase tracking-wide text-gray-400 mt-1">{label}</div>
</div>
);
}

View File

@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import Header from "@/components/layout/Header";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
import { getErrorBook, updateAttempt, getFavoriteVariants, updateVariant } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
import type { UserAttempt, QuestionVariant } from "@/types/api";
const typeLabel: Record<string, string> = {
mc: "Multiple Choice",
true_false: "True / False",
fill_blank: "Fill in Blank",
long_question: "Long Question",
short_answer: "Short Answer",
coding: "Coding",
};
const TYPE_COLORS: Record<string, string> = {
mc: "bg-violet-50 text-violet-700",
true_false: "bg-amber-50 text-amber-700",
fill_blank: "bg-teal-50 text-teal-700",
long_question: "bg-sky-50 text-sky-700",
short_answer: "bg-rose-50 text-rose-700",
coding: "bg-emerald-50 text-emerald-700",
};
const DIFF_COLORS: Record<string, string> = {
easy: "text-green-600",
medium: "text-amber-600",
hard: "text-red-600",
};
export default function ErrorBookPage() {
const { user } = useAuth();
const [entries, setEntries] = useState<UserAttempt[]>([]);
const [favoriteVariants, setFavoriteVariants] = useState<QuestionVariant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [courseFilter, setCourseFilter] = useState<string>("all");
useEffect(() => {
if (!user) { setLoading(false); return; }
let cancelled = false;
setLoading(true);
Promise.all([getErrorBook(), getFavoriteVariants()])
.then(([attempts, variants]) => {
if (cancelled) return;
setEntries(attempts);
setFavoriteVariants(variants);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load error book");
setLoading(false);
});
return () => { cancelled = true; };
}, [user]);
const courses = useMemo(
() => Array.from(new Set(
entries.map((e) => e.paper_questions?.paper?.course_code).filter((v): v is string => Boolean(v)),
)).sort(),
[entries],
);
const filteredEntries = useMemo(() => {
if (courseFilter === "all") return entries;
return entries.filter((e) => e.paper_questions?.paper?.course_code === courseFilter);
}, [courseFilter, entries]);
async function handleMarkMastered(attemptId: string) {
await updateAttempt(attemptId, { mastered: true });
setEntries((prev) => prev.filter((e) => e.id !== attemptId));
}
async function handleRemove(attemptId: string) {
await updateAttempt(attemptId, { in_error_book: false });
setEntries((prev) => prev.filter((e) => e.id !== attemptId));
}
async function handleUnfavoriteVariant(variantId: string) {
await updateVariant(variantId, { favorited: false });
setFavoriteVariants((prev) => prev.filter((v) => v.id !== variantId));
}
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-4xl mx-auto px-6 py-8">
{/* Header */}
<div className="flex items-end justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Error Book</h1>
<p className="text-sm text-gray-500 mt-1">Review your mistakes and track progress.</p>
</div>
<div className="flex gap-3 text-sm">
<StatCard label="To Review" value={filteredEntries.length} color="red" />
<StatCard label="Courses" value={courses.length} color="blue" />
</div>
</div>
{/* Course filter */}
<div className="flex gap-2 mb-6 flex-wrap">
<Pill active={courseFilter === "all"} onClick={() => setCourseFilter("all")} label="All" />
{courses.map((c) => (
<Pill key={c} active={courseFilter === c} onClick={() => setCourseFilter(c)} label={c} />
))}
</div>
{!user && (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
<div className="text-3xl mb-3">🔒</div>
<p className="text-gray-500 mb-4">Sign in to unlock your Error Book</p>
<Link to="/login" className="inline-block px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors">
Sign in
</Link>
</div>
)}
{user && loading && <div className="text-sm text-gray-400">Loading...</div>}
{user && error && <div className="text-sm text-red-600">{error}</div>}
{user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
<div className="text-3xl mb-3">🎉</div>
<p className="text-gray-500">No mistakes yet. Keep practicing!</p>
</div>
)}
{/* Saved variants */}
{favoriteVariants.length > 0 && (
<div className="mb-8">
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Saved Variants ({favoriteVariants.length})
</h2>
<div className="space-y-2">
{favoriteVariants.map((v) => (
<div key={v.id} className="flex items-center gap-3 bg-white border border-yellow-200 rounded-xl px-4 py-3">
<span className="text-yellow-400"></span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-700">Variant of Q{v.source_question_number}</span>
<p className="text-xs text-gray-500 truncate">{v.variant_data.question_text?.replace(/<[^>]*>/g, "").slice(0, 100)}</p>
</div>
<button onClick={() => void handleUnfavoriteVariant(v.id)} className="text-xs text-gray-400 hover:text-red-500">Remove</button>
</div>
))}
</div>
</div>
)}
{/* Error entries */}
<div className="space-y-4">
{filteredEntries.map((entry) => (
<ErrorCard
key={entry.id}
entry={entry}
onMastered={() => void handleMarkMastered(entry.id)}
onRemove={() => void handleRemove(entry.id)}
/>
))}
</div>
</main>
</div>
);
}
function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMastered: () => void; onRemove: () => void }) {
const [showFeedback, setShowFeedback] = useState(true);
const question = entry.paper_questions;
if (!question) return null;
const courseCode = question.paper?.course_code;
const paperId = question.paper?.id;
const paper = question.paper;
const paperInfo = paper ? `${paper.year} ${paper.term} ${paper.exam_type}` : "";
const typeColor = TYPE_COLORS[question.question_type] ?? "bg-gray-100 text-gray-600";
const diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? "";
// Clean preview: strip boilerplate
const preview = (question.question_text || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
.slice(0, 200);
return (
<article className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{/* Header */}
<div className="px-5 pt-4 pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-red-600 text-white text-sm font-bold">
{question.question_number}
</span>
<div>
<div className="flex items-center gap-1.5">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${typeColor}`}>
{typeLabel[question.question_type] ?? question.question_type}
</span>
{question.difficulty && (
<span className={`text-[11px] font-medium ${diffColor}`}>{question.difficulty}</span>
)}
{courseCode && (
<Link to={`/analytics/${courseCode}`} className="text-[11px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 hover:bg-blue-100">
{courseCode}
</Link>
)}
</div>
<div className="text-[11px] text-gray-400 mt-0.5">
{paperId ? <Link to={`/paper/${paperId}`} className="hover:text-blue-600">{paperInfo}</Link> : paperInfo}
{" · "}
{new Date(entry.created_at).toLocaleDateString("en-CA")}
</div>
</div>
</div>
{/* Score badge */}
{entry.feedback && (
<div className="flex items-center gap-1 bg-red-50 border border-red-200 rounded-lg px-2.5 py-1">
<span className="text-red-600 text-sm font-bold"></span>
<span className="text-xs text-red-600 font-medium">Incorrect</span>
</div>
)}
</div>
{/* Question preview */}
<p className="text-sm text-gray-600 mt-3 line-clamp-2">{preview}</p>
{/* Topics */}
{question.topics && question.topics.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{question.topics.slice(0, 4).map((t) => (
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{t}</span>
))}
</div>
)}
</div>
{/* AI Feedback section */}
{entry.feedback && (
<div className="border-t border-gray-100">
<button
onClick={() => setShowFeedback((v) => !v)}
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-medium text-blue-700 bg-blue-50/50 hover:bg-blue-50"
>
<span>AI Feedback</span>
<span>{showFeedback ? "▲" : "▼"}</span>
</button>
{showFeedback && (
<div className="px-5 py-4 bg-white">
<KaTeXRenderer html={entry.feedback} className="text-sm text-gray-700 leading-relaxed" />
</div>
)}
</div>
)}
{/* Actions */}
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-4 bg-gray-50/50">
{paperId && (
<Link to={`/paper/${paperId}`} className="text-xs font-medium text-blue-600 hover:text-blue-700">
Open paper
</Link>
)}
<button onClick={onMastered} className="text-xs font-medium text-green-600 hover:text-green-700">
Mark mastered
</button>
<button onClick={onRemove} className="text-xs font-medium text-gray-400 hover:text-gray-600">
Remove
</button>
</div>
</article>
);
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
const bg = color === "red" ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200";
const text = color === "red" ? "text-red-700" : "text-blue-700";
return (
<div className={`border rounded-xl px-4 py-2.5 ${bg}`}>
<div className={`text-xl font-bold ${text}`}>{value}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-400 mt-0.5">{label}</div>
</div>
);
}
function Pill({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
return (
<button
onClick={onClick}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
active ? "bg-gray-900 text-white border-gray-900" : "bg-white text-gray-600 border-gray-200 hover:border-gray-300"
}`}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,705 @@
import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { listPapers, myPapers } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
import type { Paper } from "@/types/api";
function getWorkedIds(userId: string): string[] {
try {
const raw = localStorage.getItem(`worked_papers_${userId}`);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
const fontSora = { fontFamily: "'Sora', sans-serif" };
const fontMono = { fontFamily: "'IBM Plex Mono', monospace" };
/* ── Feature cards data ── */
const FEATURES = [
{
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
),
title: "AI Analysis",
desc: "Every question gets knowledge reminders, hints, and step-by-step solutions.",
color: "#6366F1",
},
{
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
),
title: "Smart Error Book",
desc: "Auto-collect mistakes with AI feedback. Review, understand, and master.",
color: "#E11D48",
},
{
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
),
title: "Course Analytics",
desc: "Topic frequency, difficulty distribution, and high-yield focus areas.",
color: "#0D9488",
},
{
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
</svg>
),
title: "Variant Generation",
desc: "Generate unlimited similar questions for extra practice on weak topics.",
color: "#7C3AED",
},
];
/* ── Filter options ── */
const COURSE_OPTIONS = ["COMP2011", "COMP2211", "MATH1014", "PHYS1112", "MATH2023", "ELEC2100"];
const TERM_OPTIONS = ["spring", "fall"];
const TYPE_OPTIONS = ["midterm", "final"];
/* ── Chevron SVG ── */
function ChevronDown({ className = "" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
}
/* ── Dropdown select component ── */
function Dropdown({
label,
value,
options,
onChange,
}: {
label: string;
value: string | null;
options: { value: string; label: string }[];
onChange: (v: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const selected = options.find((o) => o.value === value);
return (
<div ref={ref} className="relative" style={{ minWidth: 150 }}>
<div className="text-[11px] font-semibold text-indigo-300 uppercase tracking-wider mb-1.5" style={fontSora}>
{label}
</div>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between bg-white px-3.5 py-2.5 text-sm cursor-pointer whitespace-nowrap"
style={{ borderRadius: 0, ...fontMono }}
>
<span className={`${selected ? "text-slate-800 font-semibold" : "text-slate-400"} mr-2`}>
{selected ? selected.label : `All ${label}s`}
</span>
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div
className="absolute top-full left-0 right-0 mt-1 bg-white shadow-lg z-50 overflow-hidden"
style={{ borderRadius: 0, border: "1px solid #E2E8F0" }}
>
<button
onClick={() => { onChange(null); setOpen(false); }}
className={`w-full text-left px-3.5 py-2 text-sm hover:bg-indigo-50 transition-colors ${
!value ? "text-indigo-600 font-semibold bg-indigo-50/50" : "text-slate-500"
}`}
style={fontMono}
>
All {label}s
</button>
{options.map((o) => (
<button
key={o.value}
onClick={() => { onChange(o.value); setOpen(false); }}
className={`w-full text-left px-3.5 py-2 text-sm hover:bg-indigo-50 transition-colors ${
value === o.value ? "text-indigo-600 font-semibold bg-indigo-50/50" : "text-slate-600"
}`}
style={fontMono}
>
{o.label}
</button>
))}
</div>
)}
</div>
);
}
export default function HomePage() {
const navigate = useNavigate();
const { user, signOut } = useAuth();
const [papers, setPapers] = useState<Paper[]>([]);
const [papersLoading, setPapersLoading] = useState(false);
const [myUploadedPapers, setMyUploadedPapers] = useState<Paper[]>([]);
const [workedPapers, setWorkedPapers] = useState<Paper[]>([]);
const [courseInput, setCourseInput] = useState("");
const [courseFilter, setCourseFilter] = useState<string | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [termFilter, setTermFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const inputRef = useRef<HTMLDivElement>(null);
// Autocomplete suggestions
const suggestions = courseInput.trim()
? COURSE_OPTIONS.filter((c) =>
c.toLowerCase().includes(courseInput.trim().toLowerCase())
)
: [];
// Close suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (inputRef.current && !inputRef.current.contains(e.target as Node)) setShowSuggestions(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
let cancelled = false;
setPapersLoading(true);
listPapers()
.then((data) => {
if (cancelled) return;
setPapers(
data.sort((a, b) => {
if (a.course_code !== b.course_code) return a.course_code.localeCompare(b.course_code);
if (a.year !== b.year) return b.year - a.year;
if (a.term !== b.term) return a.term.localeCompare(b.term);
return a.exam_type.localeCompare(b.exam_type);
}),
);
})
.catch(() => {
if (!cancelled) setPapers([]);
})
.finally(() => {
if (!cancelled) setPapersLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// My Papers
useEffect(() => {
if (!user) return;
let cancelled = false;
myPapers().then((data) => {
if (cancelled) return;
setMyUploadedPapers(data.filter((p) => p.status !== "error"));
}).catch(() => {});
return () => { cancelled = true; };
}, [user]);
useEffect(() => {
if (!user || papers.length === 0) return;
const workedIds = new Set(getWorkedIds(user.id));
setWorkedPapers(papers.filter((p) => workedIds.has(p.id)));
}, [user, papers]);
// Filter papers
const hasFilter = courseFilter || termFilter || typeFilter;
const filteredPapers = papers.filter((p) => {
if (courseFilter && p.course_code !== courseFilter) return false;
if (termFilter && p.term !== termFilter) return false;
if (typeFilter && p.exam_type !== typeFilter) return false;
return true;
});
const selectCourse = (code: string) => {
setCourseInput(code);
setCourseFilter(code);
setShowSuggestions(false);
};
return (
<div className="min-h-screen" style={{ background: "#FAFAFA" }}>
{/* ══════ Nav ══════ */}
<nav className="bg-white border-b border-slate-200">
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-8 h-8 flex items-center justify-center text-white text-sm font-bold"
style={{ background: "#6366F1", borderRadius: 0 }}
>
PM
</div>
<span className="text-lg font-bold text-slate-800" style={fontSora}>
PastPaper Master
</span>
</div>
<div className="flex items-center gap-5 text-sm" style={fontSora}>
<Link to="/" className="text-indigo-600 font-semibold">
Home
</Link>
<Link to="/analytics" className="text-slate-500 hover:text-slate-800 transition-colors">
Analytics
</Link>
<Link to="/error-book" className="text-slate-500 hover:text-slate-800 transition-colors">
Error Book
</Link>
<Link
to="/upload"
className="px-4 py-1.5 text-white text-xs font-semibold"
style={{ background: "#6366F1", borderRadius: 0 }}
>
Upload Paper
</Link>
{user ? (
<div className="flex items-center gap-3 pl-3 border-l border-slate-200">
<span className="text-xs text-slate-400 max-w-[140px] truncate" style={fontMono}>{user.email}</span>
<button
onClick={() => void signOut()}
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
>
Sign out
</button>
</div>
) : (
<Link
to="/login"
className="text-sm text-indigo-600 font-semibold pl-3 border-l border-slate-200 hover:text-indigo-800 transition-colors"
>
Sign in
</Link>
)}
</div>
</div>
</nav>
{/* ══════ Hero + Filter ══════ */}
<section
className="relative overflow-hidden"
style={{ background: "linear-gradient(135deg, #1E1B4B 0%, #312E81 50%, #4338CA 100%)" }}
>
<div className="max-w-[1200px] mx-auto px-6 pt-16 pb-10 text-center relative z-10">
<h1
className="text-4xl font-bold text-white mb-4 leading-tight"
style={fontSora}
>
The Smartest Way to<br />
<span style={{ color: "#A5B4FC" }}>Master Past Papers</span>
</h1>
<p className="text-indigo-200 text-base mb-10 max-w-xl mx-auto" style={fontSora}>
Upload any HKUST past paper. AI breaks down every question with analysis,
hints, and solutions so you study smarter, not harder.
</p>
{/* ── Filter row: Course input + Term dropdown + Type dropdown ── */}
<div className="max-w-[680px] mx-auto">
<div className="flex gap-3 items-end">
{/* Course code input with autocomplete */}
<div ref={inputRef} className="relative flex-1">
<div className="text-[11px] font-semibold text-indigo-300 uppercase tracking-wider mb-1.5 text-left" style={fontSora}>
Course Code
</div>
<div className="flex bg-white" style={{ borderRadius: 0 }}>
<input
type="text"
value={courseInput}
onChange={(e) => {
const v = e.target.value.toUpperCase();
setCourseInput(v);
setCourseFilter(COURSE_OPTIONS.includes(v) ? v : null);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
placeholder="e.g. COMP2011"
className="flex-1 px-3.5 py-2.5 text-sm text-slate-800 outline-none bg-transparent font-semibold"
style={fontMono}
/>
{courseInput && (
<button
onClick={() => { setCourseInput(""); setCourseFilter(null); }}
className="px-2 text-slate-300 hover:text-slate-500 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Autocomplete dropdown */}
{showSuggestions && suggestions.length > 0 && !courseFilter && (
<div
className="absolute top-full left-0 right-0 mt-1 bg-white shadow-lg z-50 overflow-hidden"
style={{ borderRadius: 0, border: "1px solid #E2E8F0" }}
>
{suggestions.map((c) => (
<button
key={c}
onClick={() => selectCourse(c)}
className="w-full text-left px-3.5 py-2.5 text-sm text-slate-700 hover:bg-indigo-50 hover:text-indigo-600 transition-colors"
style={fontMono}
>
<span className="font-semibold">{c.slice(0, courseInput.length)}</span>
{c.slice(courseInput.length)}
</button>
))}
</div>
)}
</div>
{/* Term dropdown */}
<Dropdown
label="Term"
value={termFilter}
options={[
{ value: "spring", label: "Spring" },
{ value: "fall", label: "Fall" },
]}
onChange={setTermFilter}
/>
{/* Exam Type dropdown */}
<Dropdown
label="Exam Type"
value={typeFilter}
options={[
{ value: "midterm", label: "Midterm" },
{ value: "final", label: "Final" },
]}
onChange={setTypeFilter}
/>
{/* Buttons */}
<div className="flex gap-2 items-end">
<div>
<div className="mb-1.5" />
<button
className="px-6 py-2.5 text-white text-sm font-semibold shrink-0"
style={{ background: "#6366F1", borderRadius: 0, ...fontSora }}
>
Search
</button>
</div>
<div>
<div className="mb-1.5" />
<button
onClick={() => {
setAnalyzing(true);
setTimeout(() => {
if (courseFilter) navigate(`/analytics/${courseFilter}`);
else navigate("/analytics");
}, 1200);
}}
disabled={analyzing}
className="px-5 py-2.5 text-sm font-semibold shrink-0 border transition-all flex items-center gap-2"
style={{
borderRadius: 0,
background: analyzing ? "#BE123C" : courseFilter ? "#E11D48" : "transparent",
color: courseFilter || analyzing ? "#fff" : "rgba(165,180,252,0.7)",
borderColor: analyzing ? "#BE123C" : courseFilter ? "#E11D48" : "rgba(165,180,252,0.3)",
...fontSora,
}}
>
{analyzing && (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{analyzing ? "Analyzing..." : "Analyze"}
</button>
</div>
</div>
</div>
{/* ── Results panel ── */}
{hasFilter && (
<div
className="mt-3 text-left max-h-[300px] overflow-y-auto"
style={{ background: "rgba(255,255,255,0.06)", backdropFilter: "blur(8px)", border: "1px solid rgba(255,255,255,0.1)" }}
>
{papersLoading ? (
<div className="p-6 text-center">
<p className="text-indigo-300 text-sm" style={fontSora}>Loading papers...</p>
</div>
) : filteredPapers.length === 0 ? (
<div className="p-6 text-center">
<p className="text-indigo-300 text-sm" style={fontSora}>No papers match these filters</p>
</div>
) : (
<>
<div className="px-4 pt-3 pb-1 flex items-center justify-between">
<span className="text-[11px] font-semibold text-indigo-400 uppercase tracking-wider" style={fontSora}>
{filteredPapers.length} paper{filteredPapers.length > 1 ? "s" : ""} found
</span>
{courseFilter && (
<Link
to={`/analytics/${courseFilter}`}
className="flex items-center gap-1.5 px-3 py-1 text-[11px] font-bold text-white hover:opacity-90 transition-opacity"
style={{ background: "#6366F1", borderRadius: 0, ...fontMono }}
>
<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 · {courseFilter}
</Link>
)}
</div>
{filteredPapers.map((p) => (
<button
key={p.id}
onClick={() => { navigate(`/paper/${p.id}`); }}
className="w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-white/10 cursor-pointer"
style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center shrink-0" style={{ background: "rgba(255,255,255,0.1)" }}>
<svg className="w-4 h-4 text-indigo-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</div>
<div>
<span className="text-sm font-bold text-white" style={fontMono}>{p.course_code}</span>
<span className="text-sm text-indigo-300 capitalize ml-2" style={fontSora}>
{p.year} {p.term} {p.exam_type}
</span>
<div className="flex gap-3 mt-0.5">
{p.question_count != null && (
<span className="text-[11px] text-indigo-400" style={fontMono}>{p.question_count} Qs</span>
)}
{p.difficulty_level && (
<span className="text-[11px] text-indigo-400 capitalize" style={fontMono}>{p.difficulty_level}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 text-[10px] font-bold border ${
p.status === "ready"
? "text-emerald-400 border-emerald-400/40"
: p.status === "processing"
? "text-amber-300 border-amber-300/40"
: "text-indigo-400/60 border-indigo-400/20"
}`}
style={{ borderRadius: 0, ...fontMono }}
>
{p.status.toUpperCase()}
</span>
<svg className="w-4 h-4 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
</button>
))}
</>
)}
</div>
)}
</div>
{/* Quick stats — real data */}
<div className="flex justify-center gap-8 mt-10">
{[
[String(papers.filter(p => p.status === "ready").length), "Past Papers"],
[String(papers.reduce((s, p) => s + (p.question_count || 0), 0)), "Questions Analyzed"],
[String(new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size), "Courses"],
].map(([num, label]) => (
<div key={label} className="text-center">
<div className="text-2xl font-bold text-white" style={fontMono}>{num}</div>
<div className="text-xs text-indigo-300" style={fontSora}>{label}</div>
</div>
))}
</div>
</div>
{/* Decorative grid */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage: "linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
</section>
<main className="max-w-[1200px] mx-auto px-6">
{/* ══════ Features ══════ */}
<section className="py-12">
<h2
className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-6"
style={fontSora}
>
Platform Features
</h2>
<div className="grid grid-cols-4 gap-4">
{FEATURES.map((f) => (
<div
key={f.title}
className="bg-white border border-slate-200 p-5 hover:border-slate-300 transition-colors group"
style={{ borderRadius: 0 }}
>
<div
className="w-10 h-10 flex items-center justify-center text-white mb-4"
style={{ background: f.color, borderRadius: 0 }}
>
{f.icon}
</div>
<h3
className="text-sm font-bold text-slate-800 mb-1.5"
style={fontSora}
>
{f.title}
</h3>
<p className="text-xs text-slate-400 leading-relaxed" style={fontSora}>
{f.desc}
</p>
</div>
))}
</div>
</section>
{/* ══════ My Papers ══════ */}
{user && (
<section className="pb-12">
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-6" style={fontSora}>
My Papers
</h2>
{myUploadedPapers.length === 0 && workedPapers.length === 0 ? (
<div className="bg-white border border-slate-200 px-6 py-8 text-center" style={{ borderRadius: 0 }}>
<p className="text-sm text-slate-400" style={fontSora}>No papers yet. Upload a past paper or open one to get started.</p>
</div>
) : (
<div className="grid grid-cols-2 gap-6">
{/* Uploaded */}
{myUploadedPapers.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3" style={fontSora}>
Uploaded
</div>
<div className="space-y-2">
{myUploadedPapers.map((p) => (
<Link
key={p.id}
to={p.status === "ready" ? `/paper/${p.id}` : "#"}
className="flex items-center justify-between bg-white border border-slate-200 px-4 py-3 hover:border-indigo-300 transition-colors"
style={{ borderRadius: 0 }}
>
<div>
<span className="text-sm font-bold text-slate-800" style={fontMono}>{p.course_code}</span>
<span className="text-sm text-slate-500 capitalize ml-2" style={fontSora}>{p.year} {p.term} {p.exam_type}</span>
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 border ${
p.status === "ready" ? "text-emerald-600 border-emerald-300 bg-emerald-50"
: p.status === "processing" ? "text-amber-600 border-amber-300 bg-amber-50"
: "text-slate-400 border-slate-200"
}`} style={{ borderRadius: 0, ...fontMono }}>
{p.status === "processing" ? (
<span className="flex items-center gap-1">
<span className="w-2 h-2 border border-amber-500 border-t-transparent rounded-full animate-spin inline-block" />
PROCESSING
</span>
) : p.status.toUpperCase()}
</span>
</Link>
))}
</div>
</div>
)}
{/* Worked on */}
{workedPapers.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3" style={fontSora}>
Recently Worked
</div>
<div className="space-y-2">
{workedPapers.map((p) => (
<Link
key={p.id}
to={`/paper/${p.id}`}
className="flex items-center justify-between bg-white border border-slate-200 px-4 py-3 hover:border-indigo-300 transition-colors"
style={{ borderRadius: 0 }}
>
<div>
<span className="text-sm font-bold text-slate-800" style={fontMono}>{p.course_code}</span>
<span className="text-sm text-slate-500 capitalize ml-2" style={fontSora}>{p.year} {p.term} {p.exam_type}</span>
</div>
<svg className="w-4 h-4 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</Link>
))}
</div>
</div>
)}
</div>
)}
</section>
)}
{/* ══════ CTA Banner ══════ */}
<section className="pb-16">
<div
className="p-8 flex items-center justify-between"
style={{ background: "linear-gradient(135deg, #1E1B4B, #312E81)", borderRadius: 0 }}
>
<div>
<h3 className="text-lg font-bold text-white mb-1" style={fontSora}>
Ready to ace your exams?
</h3>
<p className="text-sm text-indigo-300" style={fontSora}>
Upload a past paper and let AI do the heavy lifting.
</p>
</div>
<div className="flex gap-3">
<Link
to="/upload"
className="px-5 py-2.5 text-sm font-semibold text-white"
style={{ background: "#6366F1", borderRadius: 0, ...fontSora }}
>
Upload Paper
</Link>
<Link
to="/analytics"
className="px-5 py-2.5 text-sm font-semibold text-indigo-200 border border-indigo-400 hover:bg-indigo-900/30 transition-colors"
style={{ borderRadius: 0, ...fontSora }}
>
View Analytics
</Link>
</div>
</div>
</section>
</main>
{/* ══════ Footer ══════ */}
<footer className="border-t border-slate-200 bg-white">
<div className="max-w-[1200px] mx-auto px-6 py-6 flex items-center justify-between">
<span className="text-xs text-slate-400" style={fontSora}>
PastPaper Master &middot; HKUST &middot; 2025
</span>
<div className="flex gap-4 text-xs text-slate-400" style={fontSora}>
<span>About</span>
<span>Contact</span>
<span>Privacy</span>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useState } from "react";
import { supabase } from "@/lib/supabase";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"signin" | "signup">("signin");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (mode === "signin") {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
} else {
const { error } = await supabase.auth.signUp({ email, password });
if (error) throw error;
// Auto sign in after signup (requires email confirm disabled in Supabase dashboard)
const { error: signInError } = await supabase.auth.signInWithPassword({ email, password });
if (signInError) throw signInError;
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full max-w-sm">
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900">PastPaper Master</h1>
<p className="text-sm text-gray-500 mt-1">{mode === "signin" ? "Sign in to continue" : "Create your account"}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••"
/>
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? "..." : mode === "signin" ? "Sign in" : "Create account"}
</button>
</form>
<p className="text-center text-xs text-gray-500 mt-4">
{mode === "signin" ? "No account? " : "Already have one? "}
<button
onClick={() => { setMode(mode === "signin" ? "signup" : "signin"); setError(null); }}
className="text-blue-600 hover:underline font-medium"
>
{mode === "signin" ? "Sign up" : "Sign in"}
</button>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import Header from "@/components/layout/Header";
import UploadForm from "@/components/upload/UploadForm";
export default function UploadPage() {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="py-10 px-6">
<h1 className="text-xl font-bold text-center mb-8 text-gray-800">
Upload Past Paper
</h1>
<UploadForm />
</main>
</div>
);
}

View File

@@ -0,0 +1,524 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams } from "react-router-dom";
import Header from "@/components/layout/Header";
import PdfViewer from "@/components/workbench/PdfViewer";
import QuestionNav from "@/components/workbench/QuestionNav";
import QuestionDetail from "@/components/workbench/QuestionDetail";
import AiTrioPanel from "@/components/workbench/AiTrioPanel";
import SimilarHistoryPanel from "@/components/workbench/SimilarHistoryPanel";
import ActionBar from "@/components/workbench/ActionBar";
import PhotoUpload from "@/components/workbench/PhotoUpload";
import VariantDetail from "@/components/workbench/VariantDetail";
import KaTeXRenderer from "@/components/shared/KaTeXRenderer";
import { usePaper } from "@/hooks/usePaper";
import { useQuestions } from "@/hooks/useQuestions";
import { generateVariant, getVariants, updateVariant, deleteVariant, recordAttempt, getPaperAttempts } from "@/lib/api";
import { groupQuestions } from "@/lib/questionGroups";
import { useAuth } from "@/contexts/AuthContext";
import type { QuestionVariant } from "@/types/api";
const WORKED_KEY = (userId: string) => `worked_papers_${userId}`;
const WORKED_THRESHOLD_MS = 3 * 60 * 1000; // 3 minutes
function markWorked(userId: string, paperId: string) {
try {
const raw = localStorage.getItem(WORKED_KEY(userId));
const ids: string[] = raw ? JSON.parse(raw) : [];
if (!ids.includes(paperId)) {
localStorage.setItem(WORKED_KEY(userId), JSON.stringify([...ids, paperId]));
}
} catch { /* silent */ }
}
export default function WorkbenchPage() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const { paper, loading: paperLoading, error: paperError } = usePaper(id!);
const isReady = paper?.status === "ready";
const { questions, loading: questionsLoading } = useQuestions(id!, isReady);
const [currentQuestionId, setCurrentQuestionId] = useState<string | null>(null);
const [showPhoto, setShowPhoto] = useState(false);
// Grading result per question
const [gradingResults, setGradingResults] = useState<Map<string, {
isCorrect: boolean;
feedback: string;
ocrText: string;
scoreGiven?: number;
loading?: boolean;
}>>(new Map());
// Track which grading panels are expanded
const [gradingExpanded, setGradingExpanded] = useState<Set<string>>(new Set());
// Tab state
const [activeTab, setActiveTab] = useState<"questions" | "variants">("questions");
// variants per question: questionId → QuestionVariant[]
const [variantMap, setVariantMap] = useState<Map<string, QuestionVariant[]>>(new Map());
// which question IDs have been fetched from server
const loadedRef = useRef<Set<string>>(new Set());
// generating state
const [isGenerating, setIsGenerating] = useState(false);
// Currently viewing variant (full detail view)
const [activeVariantId, setActiveVariantId] = useState<string | null>(null);
// Cooldown: ignore scroll-based updates for 2s after user clicks a question
const lastUserSelectTime = useRef(0);
const handleQuestionSelect = useCallback((questionId: string) => {
lastUserSelectTime.current = Date.now();
setCurrentQuestionId(questionId);
}, []);
const groups = groupQuestions(questions);
const currentQuestion =
questions.find((question) => question.id === currentQuestionId)
?? questions[0]
?? null;
const currentGroupKey = currentQuestion?.question_number.match(/^\d+/)?.[0] ?? null;
const paperTitle = paper
? `${paper.year} ${paper.term} ${paper.exam_type}`
: undefined;
const currentVariants = variantMap.get(currentQuestion?.id ?? "") ?? [];
const activeVariant = currentVariants.find((v) => v.id === activeVariantId) ?? null;
const handleGroupSelect = useCallback((groupKey: string) => {
lastUserSelectTime.current = Date.now();
const group = groups.find((item) => item.key === groupKey);
if (group?.questions[0]) {
setCurrentQuestionId(group.questions[0].id);
}
}, [groups]);
useEffect(() => {
if (questions.length === 0) {
setCurrentQuestionId(null);
return;
}
setCurrentQuestionId((prev) =>
prev && questions.some((question) => question.id === prev) ? prev : questions[0].id,
);
}, [questions]);
// 3-minute worked tracking
useEffect(() => {
if (!id || !user) return;
const timer = setTimeout(() => markWorked(user.id, id), WORKED_THRESHOLD_MS);
return () => clearTimeout(timer);
}, [id, user]);
// Load historical grading results
useEffect(() => {
if (!id || !user || !isReady) return;
getPaperAttempts(id).then((attempts) => {
const map = new Map<string, { isCorrect: boolean; feedback: string; ocrText: string; scoreGiven?: number }>();
for (const a of attempts) {
map.set(a.question_id, {
isCorrect: a.is_correct,
feedback: a.feedback || "",
ocrText: a.photo_ocr_text || "",
});
}
if (map.size > 0) {
setGradingResults((prev) => {
const next = new Map(prev);
for (const [k, v] of map) {
if (!next.has(k)) next.set(k, v); // don't overwrite current session
}
return next;
});
setGradingExpanded(new Set(map.keys()));
}
}).catch(() => {});
}, [id, user, isReady]);
// Load variants for current question (once per question ID)
useEffect(() => {
if (!currentQuestionId || loadedRef.current.has(currentQuestionId)) return;
loadedRef.current.add(currentQuestionId);
getVariants(currentQuestionId)
.then((data) => {
setVariantMap((prev) => new Map(prev).set(currentQuestionId, data));
})
.catch(() => {});
}, [currentQuestionId]);
// When user scrolls PDF, find the question closest to that page
// But ignore if user just clicked a question (2s cooldown)
const handlePdfPageChange = useCallback(
(page: number) => {
if (questions.length === 0) return;
if (Date.now() - lastUserSelectTime.current < 2000) return;
let best = questions[0];
for (let i = 0; i < questions.length; i++) {
if ((questions[i].page_number ?? 1) <= page) best = questions[i];
}
setCurrentQuestionId(best.id);
},
[questions],
);
// Track answer state per question for ActionBar feedback
const [answerStates, setAnswerStates] = useState<Map<string, "correct" | "wrong">>(new Map());
const handleAnswerResult = async (isCorrect: boolean, userAnswer: string) => {
if (!currentQuestion) return;
const state = isCorrect ? "correct" : "wrong";
setAnswerStates((prev) => new Map(prev).set(currentQuestion.id, state));
try {
const type = currentQuestion.question_type === "mc" ? "select" : "input";
await recordAttempt(currentQuestion.id, type, userAnswer, isCorrect);
// Wrong answer → auto generate variant
if (!isCorrect) {
handleGenerateVariant();
}
} catch {
// silent
}
};
const handleGenerateVariant = async () => {
if (!currentQuestion || isGenerating) return;
setIsGenerating(true);
setActiveTab("variants");
try {
const saved = await generateVariant(currentQuestion.id);
setVariantMap((prev) => {
const existing = prev.get(currentQuestion.id) ?? [];
return new Map(prev).set(currentQuestion.id, [saved, ...existing]);
});
} catch {
// silent
} finally {
setIsGenerating(false);
}
};
const handleToggleFavorite = async (v: QuestionVariant) => {
const updated = await updateVariant(v.id, { favorited: !v.favorited });
setVariantMap((prev) => {
const existing = prev.get(v.source_question_id) ?? [];
return new Map(prev).set(
v.source_question_id,
existing.map((item) => (item.id === v.id ? updated : item)),
);
});
};
const handleDeleteVariant = async (v: QuestionVariant) => {
await deleteVariant(v.id);
if (activeVariantId === v.id) setActiveVariantId(null);
setVariantMap((prev) => {
const existing = prev.get(v.source_question_id) ?? [];
return new Map(prev).set(
v.source_question_id,
existing.filter((item) => item.id !== v.id),
);
});
};
if (paperLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-400 text-sm">Loading...</div>
</div>
);
}
if (paperError || !paper) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-red-500 text-sm">{paperError ?? "Paper not found"}</div>
</div>
);
}
return (
<div className="h-screen flex flex-col">
<Header courseCode={paper.course_code} paperTitle={paperTitle} />
{/* Processing overlay */}
{paper.status === "processing" && (
<div className="flex-1 flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="inline-block w-8 h-8 border-3 border-blue-600 border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-gray-600 text-sm">AI is analyzing the paper...</p>
<p className="text-gray-400 text-xs mt-1">
{paper.question_count
? `${paper.question_count} questions found, generating analysis...`
: "Extracting and structuring questions..."}
</p>
</div>
</div>
)}
{/* Error state */}
{paper.status === "error" && (
<div className="flex-1 flex items-center justify-center bg-gray-50">
<div className="text-center max-w-md">
<p className="text-red-600 font-medium mb-2">Processing Failed</p>
<p className="text-gray-500 text-sm">{paper.error_message}</p>
</div>
</div>
)}
{/* Ready — workbench */}
{paper.status === "ready" && (
<div className="flex-1 flex overflow-hidden">
{/* Left: PDF viewer */}
<div className="w-[60%] border-r border-gray-200">
<PdfViewer
fileUrl={paper.paper_file_url}
currentPage={currentQuestion?.page_number ?? 1}
onPageChange={handlePdfPageChange}
/>
</div>
{/* Right: analysis panel */}
<div className="w-[40%] flex flex-col overflow-hidden">
{questionsLoading ? (
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
Loading questions...
</div>
) : activeVariantId && activeVariant ? (
/* ===== Variant Detail View ===== */
<>
<button
onClick={() => setActiveVariantId(null)}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-blue-600 bg-gray-50 border-b border-gray-200 hover:bg-gray-100 shrink-0"
>
<span></span>
<span>Back to Questions</span>
<span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-medium">
Variant Q{activeVariant.source_question_number}
</span>
</button>
<div className="flex-1 overflow-y-auto p-4">
<VariantDetail variant={activeVariant.variant_data} />
</div>
</>
) : (
/* ===== Normal Tab View ===== */
<>
{/* Tab bar */}
<div className="flex border-b border-gray-200 shrink-0">
<button
onClick={() => setActiveTab("questions")}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
activeTab === "questions"
? "text-gray-900 border-b-2 border-blue-600"
: "text-gray-400 hover:text-gray-600"
}`}
>
Questions
</button>
<button
onClick={() => setActiveTab("variants")}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors flex items-center justify-center gap-1.5 ${
activeTab === "variants"
? "text-gray-900 border-b-2 border-blue-600"
: "text-gray-400 hover:text-gray-600"
}`}
>
Variants
{currentVariants.length > 0 && (
<span className="w-5 h-5 flex items-center justify-center bg-purple-500 text-white text-xs font-bold rounded-full">
{currentVariants.length}
</span>
)}
</button>
</div>
{/* Question nav — always visible */}
<QuestionNav
groups={groups}
currentGroupKey={currentGroupKey}
currentQuestionId={currentQuestion?.id ?? null}
onSelectGroup={handleGroupSelect}
onSelectQuestion={handleQuestionSelect}
/>
{/* Questions tab content */}
{activeTab === "questions" && (
<>
<div className="flex-1 overflow-y-auto p-4">
{currentQuestion && (
<>
<QuestionDetail
question={currentQuestion}
onAnswerResult={handleAnswerResult}
/>
{/* Grading result panel */}
{gradingResults.has(currentQuestion.id) && (() => {
const gr = gradingResults.get(currentQuestion.id)!;
const expanded = gradingExpanded.has(currentQuestion.id);
const toggleExpand = () => setGradingExpanded((prev) => {
const next = new Set(prev);
next.has(currentQuestion.id) ? next.delete(currentQuestion.id) : next.add(currentQuestion.id);
return next;
});
if (gr.loading) {
return (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span className="text-sm font-medium text-blue-700">Grading your answer...</span>
</div>
</div>
);
}
return (
<div className={`mb-4 rounded-lg border ${gr.isCorrect ? "border-green-200" : "border-red-200"}`}>
<button
onClick={toggleExpand}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-t-lg ${gr.isCorrect ? "bg-green-50" : "bg-red-50"}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{gr.isCorrect ? "✓" : "✗"}</span>
<span className={`font-semibold text-sm ${gr.isCorrect ? "text-green-700" : "text-red-700"}`}>
AI Grading: {gr.isCorrect ? "Correct" : "Incorrect"}
{gr.scoreGiven !== undefined && `${gr.scoreGiven} pts`}
</span>
</div>
<span className="text-gray-400 text-xs">{expanded ? "▲" : "▼"}</span>
</button>
{expanded && (
<div className="p-3 border-t border-gray-100 bg-white rounded-b-lg">
{gr.ocrText && (
<details className="mb-3 bg-gray-50 rounded-lg border border-gray-200">
<summary className="px-3 py-2 text-xs font-medium text-gray-500 cursor-pointer">Your Answer (OCR)</summary>
<div className="px-3 pb-3">
<KaTeXRenderer html={gr.ocrText.replace(/\n/g, "<br/>")} className="text-xs text-gray-700" />
</div>
</details>
)}
<KaTeXRenderer html={gr.feedback} className="text-gray-700 text-sm" />
</div>
)}
</div>
);
})()}
<AiTrioPanel question={currentQuestion} />
<SimilarHistoryPanel question={currentQuestion} />
</>
)}
</div>
<ActionBar
question={currentQuestion}
onGenerateVariant={handleGenerateVariant}
isGenerating={isGenerating}
onPhotoOpen={() => setShowPhoto(true)}
answerState={currentQuestion ? answerStates.get(currentQuestion.id) ?? null : null}
/>
</>
)}
{/* Variants tab content */}
{activeTab === "variants" && (
<div className="flex-1 overflow-y-auto p-4">
<div className="mb-3">
<button
onClick={handleGenerateVariant}
disabled={!currentQuestion || 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>
{currentVariants.length === 0 && !isGenerating ? (
<div className="text-center py-12">
<p className="text-gray-400 text-sm">No variants yet for this question.</p>
</div>
) : (
<div className="space-y-3">
{currentVariants.map((v) => (
<div key={v.id} className="bg-gray-50 rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-400">
{new Date(v.created_at).toLocaleDateString("en-CA")}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => void handleToggleFavorite(v)}
title={v.favorited ? "Unfavorite" : "Save to Error Book"}
className={`text-lg leading-none ${v.favorited ? "text-yellow-400" : "text-gray-300 hover:text-yellow-400"}`}
>
</button>
<button
onClick={() => void handleDeleteVariant(v)}
className="text-gray-300 hover:text-red-400 text-sm leading-none"
title="Delete"
>
×
</button>
</div>
</div>
<p className="text-xs text-gray-600 line-clamp-2 mb-3">
{v.variant_data.question_text?.replace(/<[^>]*>/g, "").slice(0, 140)}
</p>
<button
onClick={() => setActiveVariantId(v.id)}
className="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700"
>
Practice
</button>
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)}
{/* Photo upload modal */}
{showPhoto && currentQuestion && (() => {
const qid = currentQuestion.id;
return (
<PhotoUpload
questionId={qid}
onClose={() => setShowPhoto(false)}
onSubmitted={async (promise) => {
// Set loading state
setGradingResults((prev) => new Map(prev).set(qid, { isCorrect: false, feedback: "", ocrText: "", loading: true }));
setGradingExpanded((prev) => new Set(prev).add(qid));
try {
const res = await promise;
const { is_correct, feedback, score_given } = res.grade;
setGradingResults((prev) => new Map(prev).set(qid, {
isCorrect: is_correct,
feedback,
ocrText: res.ocr_text,
scoreGiven: score_given,
loading: false,
}));
// Wrong → auto generate variant
if (!is_correct) {
handleGenerateVariant();
}
} catch {
setGradingResults((prev) => new Map(prev).set(qid, {
isCorrect: false,
feedback: "Grading failed. Please try again.",
ocrText: "",
loading: false,
}));
}
}}
/>
);
})()}
</div>
);
}

View File

@@ -0,0 +1,79 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";
/* ── Google Fonts: Sora (headings) + IBM Plex Mono (data) ── */
@import url("https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
/* Hide scrollbar on horizontal tab rows */
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
/* ── Knowledge Base HTML content styling (from SOS project) ── */
.kb-html-content h1 { font-size: 1.25rem; font-weight: 700; margin: 0.75rem 0 0.5rem; line-height: 1.3; }
.kb-html-content h2 { font-size: 1.1rem; font-weight: 600; margin: 0.75rem 0 0.4rem; color: #1e40af; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; }
.kb-html-content h3 { font-size: 0.95rem; font-weight: 600; margin: 0.6rem 0 0.3rem; color: #374151; }
.kb-html-content h4 { font-size: 0.875rem; font-weight: 600; margin: 0.5rem 0 0.25rem; color: #6b7280; }
.kb-html-content p { margin: 0.3rem 0; line-height: 1.6; }
.kb-html-content p.summary { background: #eff6ff; border-left: 3px solid #3b82f6; padding: 0.5rem 0.75rem; border-radius: 0 0.25rem 0.25rem 0; color: #1e3a5f; margin-bottom: 0.75rem; }
.kb-html-content ul, .kb-html-content ol { margin: 0.3rem 0 0.3rem 1.25rem; line-height: 1.6; }
.kb-html-content ul { list-style: disc; }
.kb-html-content ol { list-style: decimal; }
.kb-html-content li { margin: 0.15rem 0; }
.kb-html-content strong { font-weight: 600; color: #1e293b; }
.kb-html-content blockquote { border-left: 3px solid #d1d5db; padding: 0.4rem 0.75rem; margin: 0.4rem 0; background: #f9fafb; color: #4b5563; font-style: italic; border-radius: 0 0.25rem 0.25rem 0; }
.kb-html-content pre { background: #1e293b; color: #e2e8f0; padding: 0.75rem; border-radius: 0.375rem; overflow-x: auto; margin: 0.4rem 0; font-size: 0.8rem; }
.kb-html-content code { font-family: ui-monospace, monospace; font-size: 0.85em; }
.kb-html-content :not(pre) > code { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 0.2rem; color: #be185d; }
.kb-html-content table { border-collapse: collapse; width: 100%; margin: 0.4rem 0; font-size: 0.8rem; }
.kb-html-content th, .kb-html-content td { border: 1px solid #e5e7eb; padding: 0.35rem 0.5rem; text-align: left; }
.kb-html-content th { background: #f3f4f6; font-weight: 600; }
.kb-html-content section { margin: 0.5rem 0; }
.kb-html-content .tag { display: inline-block; background: #dbeafe; color: #1e40af; padding: 0.1rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; margin: 0.15rem 0.15rem; }
.kb-html-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 0.75rem 0; }
/* ── Example blocks ── */
.kb-html-content .example { background: #fffbeb; border: 1px solid #fbbf24; border-radius: 0.375rem; padding: 0.75rem; margin: 0.6rem 0; }
.kb-html-content .example-title { font-weight: 700; color: #92400e; margin-bottom: 0.4rem; font-size: 0.9rem; }
.kb-html-content .example-solution { border-top: 1px dashed #d97706; padding-top: 0.4rem; }
/* ── LaTeX blocks ── */
.kb-html-content pre.latex { background: #f8fafc; color: #1e293b; border: 1px solid #e2e8f0; text-align: center; font-size: 0.9rem; padding: 0.6rem; }
.kb-html-content code.latex { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 0.2rem; color: #4338ca; font-size: 0.85em; }
/* ── Common error block (used in solution) ── */
.kb-html-content .common-error {
background: #fef2f2;
border: 1px solid #fca5a5;
border-left: 3px solid #ef4444;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
margin: 0.5rem 0;
}
.kb-html-content .common-error::before {
content: "⚠ Common Mistake";
font-weight: 700;
color: #dc2626;
display: block;
margin-bottom: 0.3rem;
font-size: 0.85rem;
}
/* ── Figure description blocks ── */
.kb-html-content .figure-desc {
background: #faf5ff;
border: 1px solid #d8b4fe;
border-left: 3px solid #a855f7;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
margin: 0.5rem 0;
}
/* ── AI Supplement blocks ── */
.kb-html-content .ai-supplement {
background: #f0fdf4;
border: 1px solid #86efac;
border-left: 3px solid #22c55e;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
margin: 0.5rem 0;
}

169
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,169 @@
export interface Paper {
id: string;
user_id: string | null;
course_code: string;
year: number;
term: string;
exam_type: string;
paper_file_url: string;
answer_file_url: string | null;
status: "uploaded" | "processing" | "ready" | "error";
error_message: string | null;
total_score: number | null;
question_count: number | null;
topics_summary: Record<string, number> | null;
difficulty_level: string | null;
processing_step: string | null;
processing_progress: number;
processing_total: number;
created_at: string;
updated_at: string;
}
export interface PaperSummary {
id: string;
course_code: string;
year: number;
term: string;
exam_type: string;
part_label: string | null;
}
export interface Question {
id: string;
paper_id: string;
question_number: string;
parent_question: string | null;
display_order: number;
question_type: string;
question_format?: string | null;
question_text: string;
score: number | null;
page_number: number | null;
page_y_ratio?: number | null;
options: { label: string; text: string }[] | null;
correct_option: string | null;
correct_answer: string | null;
raw_answer_text: string | null;
topics: string[] | null;
topic_primary?: string | null;
analytics_topic?: string | null;
topic_tags?: string[] | null;
skill_tags?: string[] | null;
difficulty: string | null;
knowledge_reminder: string;
ai_hint: string;
solution: string;
created_at: string;
updated_at: string;
paper?: PaperSummary;
}
export interface UploadResponse {
paper_id: string;
status: string;
message: string;
}
export interface UserAttempt {
id: string;
user_id: string;
question_id: string;
attempt_type: string;
user_answer: string | null;
photo_url: string | null;
photo_ocr_text: string | null;
is_correct: boolean | null;
feedback: string | null;
error_at_step: number | null;
in_error_book: boolean;
mastered: boolean;
created_at: string;
paper_questions?: Question;
score_given?: number | null;
}
export interface VariantQuestion {
question_text: string;
question_type: string;
options: { label: string; text: string }[] | null;
correct_answer: string;
ai_hint: string;
knowledge_reminder: string;
solution: string;
}
export interface QuestionVariant {
id: string;
user_id: string;
source_question_id: string;
source_question_number: string;
variant_data: VariantQuestion;
favorited: boolean;
created_at: string;
}
export interface GradeResult {
is_correct: boolean;
feedback: string;
error_at_step: number | null;
}
export interface SimilarQuestion {
id: string;
paper_id: string;
source: string;
question_number: string;
match_percent: number;
match_reasons?: string[];
question_type: Question["question_type"];
question_text: string;
topics: string[];
difficulty: string | null;
knowledge_reminder: string;
ai_hint: string;
solution: string;
}
export interface AnalyticsTopicQuestion {
paper_id: string;
source: string;
question_number: string;
preview: string;
difficulty: string | null;
question_type: string;
year?: number | null;
term?: string | null;
exam_type?: string | null;
topics?: string[];
}
export interface AnalyticsTopicEntry {
label: string;
count: number;
pct: number;
questions: AnalyticsTopicQuestion[];
}
export interface CourseAnalytics {
course_code: string;
kpi: {
papers: number;
questions: number;
topics: number;
difficulty: string;
};
topic_frequency: AnalyticsTopicEntry[];
question_types: Array<{
label: string;
count: number;
pct: number;
}>;
difficulty_distribution: {
easy: number;
medium: number;
hard: number;
};
high_yield_topics: string[];
all_questions: AnalyticsTopicQuestion[];
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />