import { useState, useEffect, useCallback } from "react"; import { flushSync } from "react-dom"; 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 withNotes = (state: ExamState): ExamState => ({ ...state, notes: state.notes ?? "", }); function AppInner() { 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); 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") .then((data) => { const apiHistory: HistoryEntry[] = data.results.map((r) => ({ id: String(r.id), title: r.title, completedAt: r.completed_at * 1000, elapsedMs: r.elapsed_ms, gradedCount: r.graded_count, correctCount: r.correct_count, totalPoints: r.total_points, 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, _remoteId: r.id, })); setHistory(apiHistory); }) .catch(() => { setHistory(loadHistory()); }); } }, [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]); useEffect(() => { 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 })); const setExamStateStrict: React.Dispatch> = ( nextState, ) => { setExamState((prev) => { if (!prev) return prev; return typeof nextState === "function" ? (nextState as (prevState: ExamState) => ExamState)(prev) : 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; flushSync(() => { 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) { flushSync(() => { 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]), ); 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, 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 }, }; if (user && isAuthenticated()) { void apiFetch("/api/results", { method: "POST", body: JSON.stringify({ title: entry.title, scorePoints: entry.scorePoints, totalPoints: entry.totalPoints, correctCount: entry.correctCount, gradedCount: entry.gradedCount, passingScore: entry.passingScore, elapsedMs: entry.elapsedMs, notes: entry.state.notes, state: entry.state, exam: entry.exam, }), }).catch(() => { appendHistory(entry); }); } else { appendHistory(entry); } setHistory((prev) => [entry, ...prev]); clearState(); setExamState((s) => (s ? { ...s, completedAt } : s)); navigate("/results"); }, [exam, examState, user, navigate]); const exit = () => { setExam(null); setExamState(null); navigate("/"); }; const restart = () => { if (exam) loadExam(exam); }; const reviewAttempt = (entry: HistoryEntry) => { 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; }>(`/api/results/${remoteId}`) .then((data) => { flushSync(() => { setExam(data.exam); setExamState(withNotes(data.state)); }); navigate(`/review/${slugify(entry.title)}`); }) .catch(() => { flushSync(() => { setExam(entry.exam); setExamState(withNotes(entry.state)); }); navigate(`/review/${slugify(entry.title)}`); }); } else { flushSync(() => { setExam(entry.exam); setExamState(withNotes(entry.state)); }); navigate(`/review/${slugify(entry.title)}`); } }; const clearAllHistory = () => { if (confirm("Clear all past attempts?")) { clearHistory(); setHistory([]); } }; const handleAuthFlowDone = () => { navigate("/"); setAuthModalOpen(true); }; if (pathname === "/verify") { return (
BrainWave
); } if (pathname === "/reset-password") { return (
BrainWave
); } return (
/ practice
{pathname.startsWith("/exam/") && exam && ( <> {exam.title} )} {!authLoading && !user && ( )} {!authLoading && user && (
{userMenuOpen && (
{user.displayName}
{user.email}
{user.role === "admin" && ( )}
)}
)}
} /> } /> ) : ( ) } /> ) : ( ) } /> ) : ( ) } /> } /> {tweaksOpen && ( setTweaksOpen(false)} /> )} {authModalOpen && ( setAuthModalOpen(false)} onForgotPassword={() => navigate("/reset-password")} /> )}
); } export default function App() { return ( ); }