diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 34bb549..e846313 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "dompurify": "^3.4.1", "katex": "^0.16.45", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -1791,6 +1792,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3075,6 +3089,44 @@ "react": "^19.2.5" } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3142,6 +3194,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2809745..8ece895 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,8 @@ "dompurify": "^3.4.1", "katex": "^0.16.45", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" }, "devDependencies": { "@babel/core": "^7.29.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 56fba7f..ba0624d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,57 +1,72 @@ -import { useState, useEffect, useCallback } from 'react' -import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types' -import { gradeQuestion } from './utils/grading' -import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage' -import { apiFetch, isAuthenticated } from './utils/api' -import { AuthProvider, useAuth } from './contexts/AuthContext' -import Landing from './components/Landing' -import ExamRunner from './components/ExamRunner' -import Results from './components/Results' -import TweaksPanel from './components/TweaksPanel' -import AuthModal from './components/AuthModal' -import EmailVerify from './components/EmailVerify' -import ResetPassword from './components/ResetPassword' -import AdminDashboard from './components/AdminDashboard' +import { useState, useEffect, useCallback } from "react"; +import { + useNavigate, + useLocation, + Routes, + Route, + Navigate, +} from "react-router-dom"; +import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types"; +import { gradeQuestion } from "./utils/grading"; +import { + loadState, + saveState, + clearState, + loadHistory, + appendHistory, + clearHistory, +} from "./utils/storage"; +import { apiFetch, isAuthenticated } from "./utils/api"; +import { slugify } from "./utils/slug"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; +import Landing from "./components/Landing"; +import ExamRoute from "./components/ExamRoute"; +import Results from "./components/Results"; +import TweaksPanel from "./components/TweaksPanel"; +import AuthModal from "./components/AuthModal"; +import EmailVerify from "./components/EmailVerify"; +import ResetPassword from "./components/ResetPassword"; +import AdminDashboard from "./components/AdminDashboard"; -const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' } +const DEFAULT_TWEAKS: Tweaks = { + theme: "light", + fontSize: "medium", + feedbackMode: "auto", +}; const withNotes = (state: ExamState): ExamState => ({ ...state, - notes: state.notes ?? '', -}) + notes: state.notes ?? "", +}); function AppInner() { - const { user, loading: authLoading, logout } = useAuth() - const [screen, setScreen] = useState('landing') - const [exam, setExam] = useState(null) - const [examState, setExamState] = useState(null) - const [history, setHistory] = useState(() => loadHistory()) - const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS) - const [tweaksOpen, setTweaksOpen] = useState(false) - const [authModalOpen, setAuthModalOpen] = useState(false) - const [userMenuOpen, setUserMenuOpen] = useState(false) + const { user, loading: authLoading, logout } = useAuth(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [exam, setExam] = useState(null); + const [examState, setExamState] = useState(null); + const [history, setHistory] = useState(() => loadHistory()); + const [tweaks, setTweaks] = useState(DEFAULT_TWEAKS); + const [tweaksOpen, setTweaksOpen] = useState(false); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); - // Check URL for verify/reset-password routes on mount - useEffect(() => { - const path = globalThis.location.pathname - const search = globalThis.location.search - if (path === '/verify' || search.includes('token=') && path === '/verify') { - setScreen('verify') - } else if (path === '/reset-password') { - setScreen('reset-password') - } else if (path === '/admin') { - setScreen('admin') - } - }, []) - - // Load history from API when authenticated useEffect(() => { if (!authLoading && user && isAuthenticated()) { - apiFetch<{ results: Array<{ - id: number; title: string; score_points: number; total_points: number; - correct_count: number; graded_count: number; passing_score: number; - elapsed_ms: number; completed_at: number; exam_id: number | null; - }> }>('/api/results') + apiFetch<{ + results: Array<{ + id: number; + title: string; + score_points: number; + total_points: number; + correct_count: number; + graded_count: number; + passing_score: number; + elapsed_ms: number; + completed_at: number; + exam_id: number | null; + }>; + }>("/api/results") .then((data) => { const apiHistory: HistoryEntry[] = data.results.map((r) => ({ id: String(r.id), @@ -64,75 +79,111 @@ function AppInner() { scorePoints: r.score_points, passingScore: r.passing_score, exam: { title: r.title, questions: [] } as unknown as Exam, - state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState, + state: { + currentIdx: 0, + answers: {}, + flags: {}, + notes: "", + startedAt: 0, + feedbackMode: "end" as const, + } as ExamState, _remoteId: r.id, - })) - setHistory(apiHistory) + })); + setHistory(apiHistory); }) .catch(() => { - // Fall back to localStorage - setHistory(loadHistory()) - }) + setHistory(loadHistory()); + }); } - }, [user, authLoading]) + }, [user, authLoading]); useEffect(() => { - document.documentElement.dataset['theme'] = tweaks.theme - const sizeMap: Record = { small: '14px', medium: '15px', large: '17px' } - document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px' - }, [tweaks]) + document.documentElement.dataset["theme"] = tweaks.theme; + const sizeMap: Record = { + small: "14px", + medium: "15px", + large: "17px", + }; + document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? "15px"; + }, [tweaks]); useEffect(() => { - if (screen === 'exam' && exam && examState) saveState({ exam, state: examState }) - }, [exam, examState, screen]) + if (pathname.startsWith("/exam/") && exam && examState) + saveState({ exam, state: examState }); + }, [exam, examState, pathname]); const updateTweak = (key: K, value: Tweaks[K]) => - setTweaks((t) => ({ ...t, [key]: value })) + setTweaks((t) => ({ ...t, [key]: value })); - const setExamStateStrict: React.Dispatch> = (nextState) => { + const setExamStateStrict: React.Dispatch> = ( + nextState, + ) => { setExamState((prev) => { - if (!prev) return prev - return typeof nextState === 'function' + if (!prev) return prev; + return typeof nextState === "function" ? (nextState as (prevState: ExamState) => ExamState)(prev) - : nextState - }) - } + : nextState; + }); + }; + + const activateExam = (data: Exam, state: ExamState) => { + setExam(data); + setExamState(withNotes(state)); + }; const loadExam = (data: Exam) => { - const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode - setExam(data) - setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm }) - setScreen('exam') - } + const fm = + tweaks.feedbackMode === "auto" + ? (data.feedbackMode ?? "end") + : tweaks.feedbackMode; + setExam(data); + setExamState({ + currentIdx: 0, + answers: {}, + flags: {}, + notes: "", + revealed: {}, + startedAt: Date.now(), + feedbackMode: fm, + }); + navigate(`/exam/${slugify(data.title)}/q1`); + }; const resume = () => { - const saved = loadState() - if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(withNotes(saved.state)); setScreen('exam') } - } + const saved = loadState(); + if (saved?.exam && saved?.state) { + setExam(saved.exam); + setExamState(withNotes(saved.state)); + navigate( + `/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`, + ); + } + }; const submit = useCallback(() => { - if (!exam || !examState) return - const completedAt = Date.now() - const elapsedMs = completedAt - examState.startedAt - const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id])) + if (!exam || !examState) return; + const completedAt = Date.now(); + const elapsedMs = completedAt - examState.startedAt; + const graded = exam.questions.map((q) => + gradeQuestion(q, examState.answers[q.id]), + ); const entry: HistoryEntry = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, title: exam.title, completedAt, elapsedMs, - gradedCount: graded.filter((r) => r.status !== 'ungraded').length, + gradedCount: graded.filter((r) => r.status !== "ungraded").length, correctCount: graded.filter((r) => r.correct).length, totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0), scorePoints: graded.reduce((a, r) => a + r.points, 0), passingScore: exam.passingScore ?? 60, exam, state: { ...examState, completedAt }, - } + }; - // Save to API if authenticated if (user && isAuthenticated()) { - void apiFetch('/api/results', { - method: 'POST', + void apiFetch("/api/results", { + method: "POST", body: JSON.stringify({ title: entry.title, scorePoints: entry.scorePoints, @@ -146,59 +197,73 @@ function AppInner() { exam: entry.exam, }), }).catch(() => { - // Fallback: save locally - appendHistory(entry) - }) + appendHistory(entry); + }); } else { - appendHistory(entry) + appendHistory(entry); } - // Always update local state for immediate display - setHistory((prev) => [entry, ...prev]) - clearState() - setExamState((s) => s ? { ...s, completedAt } : s) - setScreen('results') - }, [exam, examState, user]) + setHistory((prev) => [entry, ...prev]); + clearState(); + setExamState((s) => (s ? { ...s, completedAt } : s)); + navigate("/results"); + }, [exam, examState, user, navigate]); - const exit = () => { setScreen('landing'); setExam(null); setExamState(null) } - const restart = () => { if (exam) loadExam(exam) } + const exit = () => { + setExam(null); + setExamState(null); + navigate("/"); + }; + + const restart = () => { + if (exam) loadExam(exam); + }; const reviewAttempt = (entry: HistoryEntry) => { - // If this is a remote result, fetch full data - const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId + const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId; if (remoteId && user && isAuthenticated()) { void apiFetch<{ - exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number; - correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number; + exam: Exam; + state: ExamState; + title: string; + scorePoints: number; + totalPoints: number; + correctCount: number; + gradedCount: number; + passingScore: number; + elapsedMs: number; + completedAt: number; }>(`/api/results/${remoteId}`) .then((data) => { - setExam(data.exam) - setExamState(withNotes(data.state)) - setScreen('review') + setExam(data.exam); + setExamState(withNotes(data.state)); + navigate(`/review/${slugify(entry.title)}`); }) .catch(() => { - // Fallback to local data - setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') - }) + setExam(entry.exam); + setExamState(withNotes(entry.state)); + navigate(`/review/${slugify(entry.title)}`); + }); } else { - setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') + setExam(entry.exam); + setExamState(withNotes(entry.state)); + navigate(`/review/${slugify(entry.title)}`); } - } + }; const clearAllHistory = () => { - if (confirm('Clear all past attempts?')) { - clearHistory() - setHistory([]) + if (confirm("Clear all past attempts?")) { + clearHistory(); + setHistory([]); } - } + }; const handleAuthFlowDone = () => { - globalThis.history.replaceState({}, '', '/') - setScreen('landing') - setAuthModalOpen(true) - } + navigate("/"); + setAuthModalOpen(true); + }; - if (screen === 'verify') { + if (pathname === "/verify") { return (
@@ -209,10 +274,10 @@ function AppInner() {
- ) + ); } - if (screen === 'reset-password') { + if (pathname === "/reset-password") { return (
@@ -223,7 +288,7 @@ function AppInner() {
- ) + ); } return ( @@ -235,24 +300,47 @@ function AppInner() { className="brand-name" onClick={exit} aria-label="Home" - style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }} + style={{ + background: "none", + border: "none", + padding: 0, + margin: 0, + cursor: "pointer", + font: "inherit", + color: "inherit", + }} > BrainWave - / practice + + / practice +
- {screen === 'exam' && exam && ( + {pathname.startsWith("/exam/") && exam && ( <> - {exam.title} - + + {exam.title} + + )} - + - {/* Auth buttons */} {!authLoading && !user && ( - )} @@ -266,48 +354,125 @@ function AppInner() { {user.displayName.charAt(0).toUpperCase()} {userMenuOpen && ( -
-
-
{user.displayName}
-
{user.email}
-
-
- {user.role === 'admin' && ( - - )} - +
+
+
{user.displayName}
+
{user.email}
+
+ {user.role === "admin" && ( + + )} + +
)}
)}
- {screen === 'landing' && ( - + + + } + /> + + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + } /> + + + {tweaksOpen && ( + setTweaksOpen(false)} + /> )} - {screen === 'admin' && user?.role === 'admin' && ( - - )} - {screen === 'exam' && exam && examState && ( - - )} - {(screen === 'results' || screen === 'review') && exam && examState && ( - - )} - {tweaksOpen && setTweaksOpen(false)} />} {authModalOpen && ( setAuthModalOpen(false)} - onForgotPassword={() => setScreen('reset-password')} + onForgotPassword={() => navigate("/reset-password")} /> )}
- ) + ); } export default function App() { @@ -315,5 +480,5 @@ export default function App() { - ) + ); } diff --git a/frontend/src/components/ExamRoute.tsx b/frontend/src/components/ExamRoute.tsx new file mode 100644 index 0000000..2b9c501 --- /dev/null +++ b/frontend/src/components/ExamRoute.tsx @@ -0,0 +1,55 @@ +import { useParams, useNavigate } from 'react-router-dom' +import { useEffect } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import ExamRunner from './ExamRunner' +import { loadState } from '../utils/storage' +import { slugify } from '../utils/slug' +import type { Exam, ExamState } from '../types' + +interface Props { + exam: Exam | null + examState: ExamState | null + onActivate: (exam: Exam, state: ExamState) => void + setExamState: Dispatch> + onSubmit: () => void + onExit: () => void +} + +export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly) { + const { slug, n } = useParams<{ slug: string; n: string }>() + const navigate = useNavigate() + + const questionIdx = Math.max(0, (Number(n) || 1) - 1) + + // Deep link: restore from localStorage if no exam is active on mount + useEffect(() => { + if (!exam) { + const saved = loadState() + if (saved?.exam && slugify(saved.exam.title) === slug) { + onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx }) + } else { + navigate('/', { replace: true }) + } + } + // Intentionally runs only on mount — dep changes after mount are handled by the sync effect below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Sync :n URL param to currentIdx (handles browser back/forward navigation) + useEffect(() => { + setExamState((s) => s ? { ...s, currentIdx: questionIdx } : s) + }, [questionIdx, setExamState]) + + if (!exam || !examState) return null + + return ( + navigate(`/exam/${slug}/q${i + 1}`)} + onSubmit={onSubmit} + onExit={onExit} + /> + ) +} diff --git a/frontend/src/components/ExamRunner.tsx b/frontend/src/components/ExamRunner.tsx index fcb44ae..e20bb43 100644 --- a/frontend/src/components/ExamRunner.tsx +++ b/frontend/src/components/ExamRunner.tsx @@ -1,107 +1,143 @@ -import { useState, useEffect, useCallback } from 'react' -import type { Exam, ExamState, Question } from '../types' -import { gradeQuestion } from '../utils/grading' -import { formatTime } from '../utils/formatting' -import Tex from './Tex' -import QuestionMedia from './QuestionMedia' -import QuestionRenderer from './questions/QuestionRenderer' +import { useState, useEffect, useCallback } from "react"; +import type { Exam, ExamState, Question } from "../types"; +import { gradeQuestion } from "../utils/grading"; +import { formatTime } from "../utils/formatting"; +import Tex from "./Tex"; +import QuestionMedia from "./QuestionMedia"; +import QuestionRenderer from "./questions/QuestionRenderer"; interface Props { - exam: Exam - state: ExamState - setState: React.Dispatch> - onSubmit: () => void - onExit: () => void + exam: Exam; + state: ExamState; + setState: React.Dispatch>; + onSubmit: () => void; + onExit: () => void; + onNavigate?: (i: number) => void; } -export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly) { - const { currentIdx, answers, flags, startedAt, feedbackMode } = state - const q = exam.questions[currentIdx] - const total = exam.questions.length - const notes = state.notes ?? '' +export default function ExamRunner({ + exam, + state, + setState, + onSubmit, + onExit, + onNavigate, +}: Readonly) { + const { currentIdx, answers, flags, startedAt, feedbackMode } = state; + const q = exam.questions[currentIdx]; + const total = exam.questions.length; + const notes = state.notes ?? ""; - const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]) - useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed]) + const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]); + useEffect(() => { + setRevealed(!!state.revealed?.[q.id]); + }, [currentIdx, q.id, state.revealed]); const setAnswer = (val: unknown) => - setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } })) + setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } })); - const goTo = useCallback((i: number) => - setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total]) + const goTo = useCallback( + (i: number) => { + const next = Math.max(0, Math.min(total - 1, i)); + setState((s) => ({ ...s, currentIdx: next })); + onNavigate?.(next); + }, + [total, onNavigate], + ); - const toggleFlag = useCallback(() => - setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id]) + const toggleFlag = useCallback( + () => + setState((s) => ({ + ...s, + flags: { ...s.flags, [q.id]: !s.flags[q.id] }, + })), + [q.id], + ); const updateNotes = (value: string) => - setState((s) => ({ ...s, notes: value.slice(0, 1000) })) + setState((s) => ({ ...s, notes: value.slice(0, 1000) })); const checkAnswer = () => { setState((s) => ({ ...s, revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true }, - })) - setRevealed(true) - } + })); + setRevealed(true); + }; - const [now, setNow] = useState(Date.now()) + const [now, setNow] = useState(Date.now()); useEffect(() => { - const t = setInterval(() => setNow(Date.now()), 1000) - return () => clearInterval(t) - }, []) + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); - const elapsedMs = now - startedAt - const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null - const timeUrgent = remainingMs != null && remainingMs < 60_000 + const elapsedMs = now - startedAt; + const remainingMs = exam.timeLimitMinutes + ? exam.timeLimitMinutes * 60_000 - elapsedMs + : null; + const timeUrgent = remainingMs != null && remainingMs < 60_000; useEffect(() => { - if (remainingMs != null && remainingMs <= 0) onSubmit() - }, [remainingMs, onSubmit]) + if (remainingMs != null && remainingMs <= 0) onSubmit(); + }, [remainingMs, onSubmit]); useEffect(() => { const handler = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - if (tag === 'INPUT' || tag === 'TEXTAREA') return - if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1) - if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1) - if (e.key === 'f') toggleFlag() - if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) { - const ci = Number.parseInt(e.key, 10) - 1 - const choices = q.choices - if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } })) + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + if (e.key === "ArrowRight" || e.key === "j") goTo(currentIdx + 1); + if (e.key === "ArrowLeft" || e.key === "k") goTo(currentIdx - 1); + if (e.key === "f") toggleFlag(); + if (/^[1-9]$/.test(e.key) && q.type === "multiple-choice" && q.choices) { + const ci = Number.parseInt(e.key, 10) - 1; + const choices = q.choices; + if (choices[ci]) + setState((s) => ({ + ...s, + answers: { ...s.answers, [q.id]: choices[ci].id }, + })); } - } - globalThis.addEventListener('keydown', handler) - return () => globalThis.removeEventListener('keydown', handler) - }, [currentIdx, q, goTo, toggleFlag, setState]) + }; + globalThis.addEventListener("keydown", handler); + return () => globalThis.removeEventListener("keydown", handler); + }, [currentIdx, q, goTo, toggleFlag, setState]); - const graded = feedbackMode === 'immediate' && revealed - const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0) - const result = graded ? gradeQuestion(q, answers[q.id]) : null + const graded = feedbackMode === "immediate" && revealed; + const answered = + answers[q.id] != null && + (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0); + const result = graded ? gradeQuestion(q, answers[q.id]) : null; const navStatus = (qq: Question): string => { - if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) { - const r = gradeQuestion(qq, answers[qq.id]) - if (r.status === 'correct') return 'correct' - if (r.status === 'wrong') return 'wrong' + if (feedbackMode === "immediate" && state.revealed?.[qq.id]) { + const r = gradeQuestion(qq, answers[qq.id]); + if (r.status === "correct") return "correct"; + if (r.status === "wrong") return "wrong"; // fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay) } - if (flags[qq.id]) return 'flagged' - const a = answers[qq.id] - if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered' - return '' - } + if (flags[qq.id]) return "flagged"; + const a = answers[qq.id]; + if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) + return "answered"; + return ""; + }; const answeredCount = exam.questions.filter((qq) => { - const st = navStatus(qq) - return st === 'answered' || st === 'correct' || st === 'wrong' - }).length + const st = navStatus(qq); + return st === "answered" || st === "correct" || st === "wrong"; + }).length; - const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0) - let answerText = '' - if (!Array.isArray(q.answer) && typeof q.answer === 'string') { - answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer + const totalPossible = exam.questions.reduce( + (acc, qq) => acc + (qq.points ?? 1), + 0, + ); + let answerText = ""; + if (!Array.isArray(q.answer) && typeof q.answer === "string") { + answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer; } else if (Array.isArray(q.answer)) { - answerText = q.answer.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ') + answerText = q.answer + .map((id) => q.choices?.find((c) => c.id === id)?.text ?? id) + .join(", "); } return ( @@ -113,78 +149,155 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
Questions
{exam.questions.map((qq, i) => { - const st = navStatus(qq) + const st = navStatus(qq); return ( - - ) + ); })}
Progress
-
+
{answeredCount} of {total} answered
-
- - +
+ +
- Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')} + + Question {String(currentIdx + 1).padStart(2, "0")} /{" "} + {String(total).padStart(2, "0")} + {q.category && {q.category}} - {q.points ?? 1} {(q.points ?? 1) === 1 ? 'pt' : 'pts'} + + {q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"} +
- {q.prompt} + + {q.prompt} + - + - {graded && result && q.type !== 'essay' && ( -
-
{result.correct ? 'Correct' : 'Not quite'}
+ {graded && result && q.type !== "essay" && ( +
+
+ {result.correct ? "Correct" : "Not quite"} +
- {!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && ( + {!result.correct && + q.answer && + q.type !== "match" && + q.type !== "order" && ( +

+ Answer: {answerText} +

+ )} + {q.explanation && (

- Answer:{' '} - {answerText} + {q.explanation}

)} - {q.explanation &&

{q.explanation}

}
)}
- + - {feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && ( - + {feedbackMode === "immediate" && + !revealed && + q.type !== "essay" && + answered && ( + + )} + {currentIdx < total - 1 ? ( + + ) : ( + )} - {currentIdx < total - 1 - ? - : }
-
- ← → navigate - F flag - {q.type === 'multiple-choice' && q.choices && ( - 1–{q.choices.length} select +
+ + ← →{" "} + navigate + + + F flag + + {q.type === "multiple-choice" && q.choices && ( + + 1– + {q.choices.length} select + )}
@@ -197,7 +310,11 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: {remainingMs != null && (
Remaining
-
{formatTime(Math.max(0, remainingMs))}
+
+ {formatTime(Math.max(0, remainingMs))} +
)}
@@ -207,7 +324,9 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
Mode
- {feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'} + {feedbackMode === "immediate" + ? "Immediate feedback" + : "Grade at end"}
@@ -230,5 +349,5 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: )}
- ) + ); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2040605..d96fa76 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,14 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import 'katex/dist/katex.min.css' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import "katex/dist/katex.min.css"; +import "./index.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( - + + + , -) +); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f781c81..d4f8d56 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,125 +1,124 @@ // src/types.ts export type QuestionType = - | 'multiple-choice' - | 'multiple-select' - | 'short-answer' - | 'translation' - | 'math-input' - | 'essay' - | 'match' - | 'order' + | "multiple-choice" + | "multiple-select" + | "short-answer" + | "translation" + | "math-input" + | "essay" + | "match" + | "order"; export interface Choice { - id: string - text: string + id: string; + text: string; } export interface MatchItem { - id: string - text: string + id: string; + text: string; } export interface QuestionImage { - src: string - alt?: string - caption?: string + src: string; + alt?: string; + caption?: string; } export interface QuestionAudio { - src: string + src: string; } export interface QuestionCode { - language: string - content: string + language: string; + content: string; } export interface QuestionTable { - headers: string[] - rows: string[][] + headers: string[]; + rows: string[][]; } export interface Question { - id: string - type: QuestionType - category?: string - points?: number - prompt: string - explanation?: string - image?: QuestionImage - audio?: QuestionAudio - code?: QuestionCode - table?: QuestionTable - svg?: string - answer?: string | string[] | Record - acceptedForms?: string[] - caseSensitive?: boolean - ignorePunctuation?: boolean - ignoreAccents?: boolean - sourceLanguage?: string - targetLanguage?: string - choices?: Choice[] - minWords?: number - rubric?: string[] - left?: MatchItem[] - right?: MatchItem[] - items?: MatchItem[] + id: string; + type: QuestionType; + category?: string; + points?: number; + prompt: string; + explanation?: string; + image?: QuestionImage; + audio?: QuestionAudio; + code?: QuestionCode; + table?: QuestionTable; + svg?: string; + answer?: string | string[] | Record; + acceptedForms?: string[]; + caseSensitive?: boolean; + ignorePunctuation?: boolean; + ignoreAccents?: boolean; + sourceLanguage?: string; + targetLanguage?: string; + choices?: Choice[]; + minWords?: number; + rubric?: string[]; + left?: MatchItem[]; + right?: MatchItem[]; + items?: MatchItem[]; } export interface Exam { - title: string - subtitle?: string - author?: string - timeLimitMinutes?: number | null - feedbackMode?: 'end' | 'immediate' - passingScore?: number - categories?: string[] - questions: Question[] + title: string; + subtitle?: string; + author?: string; + timeLimitMinutes?: number | null; + feedbackMode?: "end" | "immediate"; + passingScore?: number; + categories?: string[]; + questions: Question[]; } -export type FeedbackMode = 'immediate' | 'end' +export type FeedbackMode = "immediate" | "end"; export interface ExamState { - currentIdx: number - answers: Record - flags: Record - notes: string - revealed?: Record - startedAt: number - completedAt?: number - feedbackMode: FeedbackMode + currentIdx: number; + answers: Record; + flags: Record; + notes: string; + revealed?: Record; + startedAt: number; + completedAt?: number; + feedbackMode: FeedbackMode; } export interface HistoryEntry { - id: string - title: string - completedAt: number - elapsedMs: number - gradedCount: number - correctCount: number - totalPoints: number - scorePoints: number - passingScore: number - exam: Exam - state: ExamState + id: string; + title: string; + completedAt: number; + elapsedMs: number; + gradedCount: number; + correctCount: number; + totalPoints: number; + scorePoints: number; + passingScore: number; + exam: Exam; + state: ExamState; } -export type GradeStatus = 'correct' | 'wrong' | 'ungraded' | 'unanswered' +export type GradeStatus = "correct" | "wrong" | "ungraded" | "unanswered"; export interface GradeResult { - status: GradeStatus - correct: boolean - points: number + status: GradeStatus; + correct: boolean; + points: number; } -export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin' -export type ThemeMode = 'light' | 'dark' -export type FontSize = 'small' | 'medium' | 'large' -export type FeedbackTweak = 'auto' | 'immediate' | 'end' +export type ThemeMode = "light" | "dark"; +export type FontSize = "small" | "medium" | "large"; +export type FeedbackTweak = "auto" | "immediate" | "end"; export interface Tweaks { - theme: ThemeMode - fontSize: FontSize - feedbackMode: FeedbackTweak + theme: ThemeMode; + fontSize: FontSize; + feedbackMode: FeedbackTweak; } diff --git a/frontend/src/utils/slug.test.ts b/frontend/src/utils/slug.test.ts new file mode 100644 index 0000000..f245389 --- /dev/null +++ b/frontend/src/utils/slug.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { slugify } from './slug' + +describe('slugify', () => { + it('lowercases', () => { + expect(slugify('My Exam')).toBe('my-exam') + }) + it('replaces spaces with hyphens', () => { + expect(slugify('hello world')).toBe('hello-world') + }) + it('replaces special chars with hyphens', () => { + expect(slugify('Exam: Advanced!')).toBe('exam-advanced') + }) + it('collapses consecutive hyphens', () => { + expect(slugify('Hello World')).toBe('hello-world') + }) + it('trims leading and trailing hyphens', () => { + expect(slugify('!hello!')).toBe('hello') + }) + it('preserves numbers', () => { + expect(slugify('Exam 2024')).toBe('exam-2024') + }) + it('handles empty string', () => { + expect(slugify('')).toBe('') + }) +}) diff --git a/frontend/src/utils/slug.ts b/frontend/src/utils/slug.ts new file mode 100644 index 0000000..3a84d24 --- /dev/null +++ b/frontend/src/utils/slug.ts @@ -0,0 +1,7 @@ +export function slugify(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0cb67ff..b6797cd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,23 +1,19 @@ -import { defineConfig } from 'vitest/config' -import react, { reactCompilerPreset } from '@vitejs/plugin-react' -import babel from '@rolldown/plugin-babel' +import { defineConfig } from "vitest/config"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import babel from "@rolldown/plugin-babel"; export default defineConfig({ - plugins: [ - react(), - babel({ presets: [reactCompilerPreset()] }) - ], + plugins: [react(), babel({ presets: [reactCompilerPreset()] })], server: { proxy: { - '/api': { - target: 'http://localhost:3001', + "/api": { + target: "http://localhost:3001", changeOrigin: true, }, }, }, test: { - environment: 'node', + environment: "node", globals: true, }, -}) - +});