Files
brainwave/frontend/src/App.tsx
T
2026-04-25 13:30:34 +02:00

496 lines
14 KiB
TypeScript

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<Exam | null>(null);
const [examState, setExamState] = useState<ExamState | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
const [tweaks, setTweaks] = useState<Tweaks>(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<string, string> = {
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 = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value }));
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (
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 (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<EmailVerify onDone={handleAuthFlowDone} />
</div>
);
}
if (pathname === "/reset-password") {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<ResetPassword onDone={handleAuthFlowDone} />
</div>
);
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<button
className="brand-name"
onClick={exit}
aria-label="Home"
style={{
background: "none",
border: "none",
padding: 0,
margin: 0,
cursor: "pointer",
font: "inherit",
color: "inherit",
}}
>
BrainWave
</button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
/ practice
</span>
</div>
<div className="topbar-meta">
{pathname.startsWith("/exam/") && exam && (
<>
<span className="mono" style={{ fontSize: 12 }}>
{exam.title}
</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>
Exit
</button>
</>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
>
⚙
</button>
{!authLoading && !user && (
<button
className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)}
>
Sign In
</button>
)}
{!authLoading && user && (
<div className="user-menu-wrap">
<button
className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu"
>
{user.displayName.charAt(0).toUpperCase()}
</button>
{userMenuOpen && (
<div className="user-menu">
<div className="user-menu-header">
<div className="user-menu-name">{user.displayName}</div>
<div className="user-menu-email">{user.email}</div>
</div>
<div className="user-menu-divider" />
{user.role === "admin" && (
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
navigate("/admin");
}}
>
Admin Dashboard
</button>
)}
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
logout();
}}
>
Sign Out
</button>
</div>
)}
</div>
)}
</div>
</header>
<Routes>
<Route
path="/"
element={
<Landing
onLoadExam={loadExam}
history={history}
onResume={resume}
onClearHistory={clearAllHistory}
onReviewAttempt={reviewAttempt}
/>
}
/>
<Route
path="/exam/:slug/q:n"
element={
<ExamRoute
exam={exam}
examState={examState}
onActivate={activateExam}
setExamState={setExamStateStrict}
onSubmit={submit}
onExit={exit}
/>
}
/>
<Route
path="/results"
element={
exam && examState ? (
<Results
exam={exam}
state={examState}
onRestart={restart}
onExit={exit}
reviewOnly={false}
/>
) : (
<Navigate to="/" replace />
)
}
/>
<Route
path="/review/:slug"
element={
exam && examState ? (
<Results
exam={exam}
state={examState}
onRestart={restart}
onExit={exit}
reviewOnly={true}
/>
) : (
<Navigate to="/" replace />
)
}
/>
<Route
path="/admin"
element={
user?.role === "admin" ? (
<AdminDashboard onExit={exit} />
) : (
<Navigate to="/" replace />
)
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{tweaksOpen && (
<TweaksPanel
tweaks={tweaks}
update={updateTweak}
onClose={() => setTweaksOpen(false)}
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => navigate("/reset-password")}
/>
)}
</div>
);
}
export default function App() {
return (
<AuthProvider>
<AppInner />
</AuthProvider>
);
}