Public Access
496 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|