Initial commit: PastPaper Master full stack
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user