Initial commit: PastPaper Master full stack
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
frontend/src/App.tsx
Normal file
30
frontend/src/App.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/layout/Header.tsx
Normal file
69
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/layout/ProcessingBanner.tsx
Normal file
183
frontend/src/components/layout/ProcessingBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/shared/CollapsibleSection.tsx
Normal file
65
frontend/src/components/shared/CollapsibleSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/shared/KaTeXRenderer.tsx
Normal file
86
frontend/src/components/shared/KaTeXRenderer.tsx
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/shared/StatusBadge.tsx
Normal file
15
frontend/src/components/shared/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/upload/FilePickerField.tsx
Normal file
63
frontend/src/components/upload/FilePickerField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/upload/UploadForm.tsx
Normal file
184
frontend/src/components/upload/UploadForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/workbench/ActionBar.tsx
Normal file
58
frontend/src/components/workbench/ActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/workbench/AiTrioPanel.tsx
Normal file
21
frontend/src/components/workbench/AiTrioPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/workbench/PdfViewer.tsx
Normal file
170
frontend/src/components/workbench/PdfViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/workbench/PhotoUpload.tsx
Normal file
90
frontend/src/components/workbench/PhotoUpload.tsx
Normal 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">×</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>
|
||||
);
|
||||
}
|
||||
260
frontend/src/components/workbench/QuestionDetail.tsx
Normal file
260
frontend/src/components/workbench/QuestionDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/workbench/QuestionNav.tsx
Normal file
56
frontend/src/components/workbench/QuestionNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/components/workbench/SimilarHistoryPanel.tsx
Normal file
130
frontend/src/components/workbench/SimilarHistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/components/workbench/VariantDetail.tsx
Normal file
148
frontend/src/components/workbench/VariantDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/workbench/VariantModal.tsx
Normal file
189
frontend/src/components/workbench/VariantModal.tsx
Normal 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">×</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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/contexts/AuthContext.tsx
Normal file
49
frontend/src/contexts/AuthContext.tsx
Normal 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);
|
||||
}
|
||||
43
frontend/src/hooks/usePaper.ts
Normal file
43
frontend/src/hooks/usePaper.ts
Normal 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 };
|
||||
}
|
||||
33
frontend/src/hooks/useQuestions.ts
Normal file
33
frontend/src/hooks/useQuestions.ts
Normal 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
190
frontend/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
45
frontend/src/lib/questionGroups.ts
Normal file
45
frontend/src/lib/questionGroups.ts
Normal 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(".");
|
||||
}
|
||||
6
frontend/src/lib/supabase.ts
Normal file
6
frontend/src/lib/supabase.ts
Normal 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
16
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
521
frontend/src/pages/AnalyticsPage.tsx
Normal file
521
frontend/src/pages/AnalyticsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
frontend/src/pages/ErrorBookPage.tsx
Normal file
296
frontend/src/pages/ErrorBookPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
705
frontend/src/pages/HomePage.tsx
Normal file
705
frontend/src/pages/HomePage.tsx
Normal 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 · HKUST · 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/LoginPage.tsx
Normal file
90
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/pages/UploadPage.tsx
Normal file
16
frontend/src/pages/UploadPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
524
frontend/src/pages/WorkbenchPage.tsx
Normal file
524
frontend/src/pages/WorkbenchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/styles/globals.css
Normal file
79
frontend/src/styles/globals.css
Normal 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
169
frontend/src/types/api.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user