feat: replace screen state machine with React Router v7 URL routing
Release Pipeline / build-and-release (push) Successful in 5m24s

- Install react-router-dom
- Add slugify utility with full TDD test suite (7 tests)
- Wrap app in BrowserRouter in main.tsx
- Add optional onNavigate prop to ExamRunner.goTo
- Create ExamRoute component (deep-link restore + back/forward sync)
- Migrate App.tsx: replace screen useState with <Routes>, all setScreen()
  calls replaced with navigate(), add activateExam for deep-link activation,
  use useLocation for verify/reset-password special layouts
- Remove unused AppScreen type from types.ts
This commit is contained in:
LaurensBartelds committed 2026-04-24 23:55:52 +02:00
1 parent e97d90e316
commit 4eeacc38c5
10 files changed
+796 -367

No files matched your search

+59 -1
View File
@@ -11,7 +11,8 @@
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"katex": "^0.16.45", "katex": "^0.16.45",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
@@ -1791,6 +1792,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3075,6 +3089,44 @@
"react": "^19.2.5" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3142,6 +3194,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+2 -1
View File
@@ -15,7 +15,8 @@
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"katex": "^0.16.45", "katex": "^0.16.45",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
+320 -155
View File
@@ -1,57 +1,72 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types' import {
import { gradeQuestion } from './utils/grading' useNavigate,
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage' useLocation,
import { apiFetch, isAuthenticated } from './utils/api' Routes,
import { AuthProvider, useAuth } from './contexts/AuthContext' Route,
import Landing from './components/Landing' Navigate,
import ExamRunner from './components/ExamRunner' } from "react-router-dom";
import Results from './components/Results' import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
import TweaksPanel from './components/TweaksPanel' import { gradeQuestion } from "./utils/grading";
import AuthModal from './components/AuthModal' import {
import EmailVerify from './components/EmailVerify' loadState,
import ResetPassword from './components/ResetPassword' saveState,
import AdminDashboard from './components/AdminDashboard' 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 => ({ const withNotes = (state: ExamState): ExamState => ({
...state, ...state,
notes: state.notes ?? '', notes: state.notes ?? "",
}) });
function AppInner() { function AppInner() {
const { user, loading: authLoading, logout } = useAuth() const { user, loading: authLoading, logout } = useAuth();
const [screen, setScreen] = useState<AppScreen>('landing') const navigate = useNavigate();
const [exam, setExam] = useState<Exam | null>(null) const { pathname } = useLocation();
const [examState, setExamState] = useState<ExamState | null>(null) const [exam, setExam] = useState<Exam | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory()) const [examState, setExamState] = useState<ExamState | null>(null);
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS) const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
const [tweaksOpen, setTweaksOpen] = useState(false) const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS);
const [authModalOpen, setAuthModalOpen] = useState(false) const [tweaksOpen, setTweaksOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = 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(() => { useEffect(() => {
if (!authLoading && user && isAuthenticated()) { if (!authLoading && user && isAuthenticated()) {
apiFetch<{ results: Array<{ apiFetch<{
id: number; title: string; score_points: number; total_points: number; results: Array<{
correct_count: number; graded_count: number; passing_score: number; id: number;
elapsed_ms: number; completed_at: number; exam_id: number | null; title: string;
}> }>('/api/results') 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) => { .then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({ const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id), id: String(r.id),
@@ -64,75 +79,111 @@ function AppInner() {
scorePoints: r.score_points, scorePoints: r.score_points,
passingScore: r.passing_score, passingScore: r.passing_score,
exam: { title: r.title, questions: [] } as unknown as Exam, 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, _remoteId: r.id,
})) }));
setHistory(apiHistory) setHistory(apiHistory);
}) })
.catch(() => { .catch(() => {
// Fall back to localStorage setHistory(loadHistory());
setHistory(loadHistory()) });
})
} }
}, [user, authLoading]) }, [user, authLoading]);
useEffect(() => { useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme document.documentElement.dataset["theme"] = tweaks.theme;
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' } const sizeMap: Record<string, string> = {
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px' small: "14px",
}, [tweaks]) medium: "15px",
large: "17px",
};
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? "15px";
}, [tweaks]);
useEffect(() => { useEffect(() => {
if (screen === 'exam' && exam && examState) saveState({ exam, state: examState }) if (pathname.startsWith("/exam/") && exam && examState)
}, [exam, examState, screen]) saveState({ exam, state: examState });
}, [exam, examState, pathname]);
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value })) setTweaks((t) => ({ ...t, [key]: value }));
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => { const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (
nextState,
) => {
setExamState((prev) => { setExamState((prev) => {
if (!prev) return prev if (!prev) return prev;
return typeof nextState === 'function' return typeof nextState === "function"
? (nextState as (prevState: ExamState) => ExamState)(prev) ? (nextState as (prevState: ExamState) => ExamState)(prev)
: nextState : nextState;
}) });
} };
const activateExam = (data: Exam, state: ExamState) => {
setExam(data);
setExamState(withNotes(state));
};
const loadExam = (data: Exam) => { const loadExam = (data: Exam) => {
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode const fm =
setExam(data) tweaks.feedbackMode === "auto"
setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm }) ? (data.feedbackMode ?? "end")
setScreen('exam') : 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 resume = () => {
const saved = loadState() const saved = loadState();
if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(withNotes(saved.state)); setScreen('exam') } 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(() => { const submit = useCallback(() => {
if (!exam || !examState) return if (!exam || !examState) return;
const completedAt = Date.now() const completedAt = Date.now();
const elapsedMs = completedAt - examState.startedAt const elapsedMs = completedAt - examState.startedAt;
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id])) const graded = exam.questions.map((q) =>
gradeQuestion(q, examState.answers[q.id]),
);
const entry: HistoryEntry = { const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
title: exam.title, title: exam.title,
completedAt, completedAt,
elapsedMs, elapsedMs,
gradedCount: graded.filter((r) => r.status !== 'ungraded').length, gradedCount: graded.filter((r) => r.status !== "ungraded").length,
correctCount: graded.filter((r) => r.correct).length, correctCount: graded.filter((r) => r.correct).length,
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0), totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
scorePoints: graded.reduce((a, r) => a + r.points, 0), scorePoints: graded.reduce((a, r) => a + r.points, 0),
passingScore: exam.passingScore ?? 60, passingScore: exam.passingScore ?? 60,
exam, exam,
state: { ...examState, completedAt }, state: { ...examState, completedAt },
} };
// Save to API if authenticated
if (user && isAuthenticated()) { if (user && isAuthenticated()) {
void apiFetch('/api/results', { void apiFetch("/api/results", {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
title: entry.title, title: entry.title,
scorePoints: entry.scorePoints, scorePoints: entry.scorePoints,
@@ -146,59 +197,73 @@ function AppInner() {
exam: entry.exam, exam: entry.exam,
}), }),
}).catch(() => { }).catch(() => {
// Fallback: save locally appendHistory(entry);
appendHistory(entry) });
})
} else { } else {
appendHistory(entry) appendHistory(entry);
} }
// Always update local state for immediate display setHistory((prev) => [entry, ...prev]);
setHistory((prev) => [entry, ...prev]) clearState();
clearState() setExamState((s) => (s ? { ...s, completedAt } : s));
setExamState((s) => s ? { ...s, completedAt } : s) navigate("/results");
setScreen('results') }, [exam, examState, user, navigate]);
}, [exam, examState, user])
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) } const exit = () => {
const restart = () => { if (exam) loadExam(exam) } setExam(null);
setExamState(null);
navigate("/");
};
const restart = () => {
if (exam) loadExam(exam);
};
const reviewAttempt = (entry: HistoryEntry) => { 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()) { if (remoteId && user && isAuthenticated()) {
void apiFetch<{ void apiFetch<{
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number; exam: Exam;
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number; state: ExamState;
title: string;
scorePoints: number;
totalPoints: number;
correctCount: number;
gradedCount: number;
passingScore: number;
elapsedMs: number;
completedAt: number;
}>(`/api/results/${remoteId}`) }>(`/api/results/${remoteId}`)
.then((data) => { .then((data) => {
setExam(data.exam) setExam(data.exam);
setExamState(withNotes(data.state)) setExamState(withNotes(data.state));
setScreen('review') navigate(`/review/${slugify(entry.title)}`);
}) })
.catch(() => { .catch(() => {
// Fallback to local data setExam(entry.exam);
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') setExamState(withNotes(entry.state));
}) navigate(`/review/${slugify(entry.title)}`);
});
} else { } else {
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') setExam(entry.exam);
setExamState(withNotes(entry.state));
navigate(`/review/${slugify(entry.title)}`);
} }
} };
const clearAllHistory = () => { const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) { if (confirm("Clear all past attempts?")) {
clearHistory() clearHistory();
setHistory([]) setHistory([]);
} }
} };
const handleAuthFlowDone = () => { const handleAuthFlowDone = () => {
globalThis.history.replaceState({}, '', '/') navigate("/");
setScreen('landing') setAuthModalOpen(true);
setAuthModalOpen(true) };
}
if (screen === 'verify') { if (pathname === "/verify") {
return ( return (
<div className="shell"> <div className="shell">
<header className="topbar"> <header className="topbar">
@@ -209,10 +274,10 @@ function AppInner() {
</header> </header>
<EmailVerify onDone={handleAuthFlowDone} /> <EmailVerify onDone={handleAuthFlowDone} />
</div> </div>
) );
} }
if (screen === 'reset-password') { if (pathname === "/reset-password") {
return ( return (
<div className="shell"> <div className="shell">
<header className="topbar"> <header className="topbar">
@@ -223,7 +288,7 @@ function AppInner() {
</header> </header>
<ResetPassword onDone={handleAuthFlowDone} /> <ResetPassword onDone={handleAuthFlowDone} />
</div> </div>
) );
} }
return ( return (
@@ -235,24 +300,47 @@ function AppInner() {
className="brand-name" className="brand-name"
onClick={exit} onClick={exit}
aria-label="Home" 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 BrainWave
</button> </button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span> <span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
/ practice
</span>
</div> </div>
<div className="topbar-meta"> <div className="topbar-meta">
{screen === 'exam' && exam && ( {pathname.startsWith("/exam/") && exam && (
<> <>
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span> <span className="mono" style={{ fontSize: 12 }}>
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button> {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> <button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
>
⚙
</button>
{/* Auth buttons */}
{!authLoading && !user && ( {!authLoading && !user && (
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}> <button
className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)}
>
Sign In Sign In
</button> </button>
)} )}
@@ -266,48 +354,125 @@ function AppInner() {
{user.displayName.charAt(0).toUpperCase()} {user.displayName.charAt(0).toUpperCase()}
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div className="user-menu"> <div className="user-menu">
<div className="user-menu-header"> <div className="user-menu-header">
<div className="user-menu-name">{user.displayName}</div> <div className="user-menu-name">{user.displayName}</div>
<div className="user-menu-email">{user.email}</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); setScreen('admin'); globalThis.history.pushState({}, '', '/admin') }}>
Admin Dashboard
</button>
)}
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
Sign Out
</button>
</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>
)} )}
</div> </div>
</header> </header>
{screen === 'landing' && ( <Routes>
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} /> <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)}
/>
)} )}
{screen === 'admin' && user?.role === 'admin' && (
<AdminDashboard onExit={exit} />
)}
{screen === 'exam' && exam && examState && (
<ExamRunner exam={exam} state={examState} setState={setExamStateStrict} onSubmit={submit} onExit={exit} />
)}
{(screen === 'results' || screen === 'review') && exam && examState && (
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
)}
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
{authModalOpen && ( {authModalOpen && (
<AuthModal <AuthModal
onClose={() => setAuthModalOpen(false)} onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => setScreen('reset-password')} onForgotPassword={() => navigate("/reset-password")}
/> />
)} )}
</div> </div>
) );
} }
export default function App() { export default function App() {
@@ -315,5 +480,5 @@ export default function App() {
<AuthProvider> <AuthProvider>
<AppInner /> <AppInner />
</AuthProvider> </AuthProvider>
) );
} }
+55
View File
@@ -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<SetStateAction<ExamState>>
onSubmit: () => void
onExit: () => void
}
export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
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 (
<ExamRunner
exam={exam}
state={examState}
setState={setExamState}
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
onSubmit={onSubmit}
onExit={onExit}
/>
)
}
+226 -107
View File
@@ -1,107 +1,143 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import type { Exam, ExamState, Question } from '../types' import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from '../utils/grading' import { gradeQuestion } from "../utils/grading";
import { formatTime } from '../utils/formatting' import { formatTime } from "../utils/formatting";
import Tex from './Tex' import Tex from "./Tex";
import QuestionMedia from './QuestionMedia' import QuestionMedia from "./QuestionMedia";
import QuestionRenderer from './questions/QuestionRenderer' import QuestionRenderer from "./questions/QuestionRenderer";
interface Props { interface Props {
exam: Exam exam: Exam;
state: ExamState state: ExamState;
setState: React.Dispatch<React.SetStateAction<ExamState>> setState: React.Dispatch<React.SetStateAction<ExamState>>;
onSubmit: () => void onSubmit: () => void;
onExit: () => void onExit: () => void;
onNavigate?: (i: number) => void;
} }
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly<Props>) { export default function ExamRunner({
const { currentIdx, answers, flags, startedAt, feedbackMode } = state exam,
const q = exam.questions[currentIdx] state,
const total = exam.questions.length setState,
const notes = state.notes ?? '' onSubmit,
onExit,
onNavigate,
}: Readonly<Props>) {
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]) const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed]) useEffect(() => {
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const setAnswer = (val: unknown) => 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) => const goTo = useCallback(
setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total]) (i: number) => {
const next = Math.max(0, Math.min(total - 1, i));
setState((s) => ({ ...s, currentIdx: next }));
onNavigate?.(next);
},
[total, onNavigate],
);
const toggleFlag = useCallback(() => const toggleFlag = useCallback(
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id]) () =>
setState((s) => ({
...s,
flags: { ...s.flags, [q.id]: !s.flags[q.id] },
})),
[q.id],
);
const updateNotes = (value: string) => const updateNotes = (value: string) =>
setState((s) => ({ ...s, notes: value.slice(0, 1000) })) setState((s) => ({ ...s, notes: value.slice(0, 1000) }));
const checkAnswer = () => { const checkAnswer = () => {
setState((s) => ({ setState((s) => ({
...s, ...s,
revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true }, 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(() => { useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000) const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t) return () => clearInterval(t);
}, []) }, []);
const elapsedMs = now - startedAt const elapsedMs = now - startedAt;
const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null const remainingMs = exam.timeLimitMinutes
const timeUrgent = remainingMs != null && remainingMs < 60_000 ? exam.timeLimitMinutes * 60_000 - elapsedMs
: null;
const timeUrgent = remainingMs != null && remainingMs < 60_000;
useEffect(() => { useEffect(() => {
if (remainingMs != null && remainingMs <= 0) onSubmit() if (remainingMs != null && remainingMs <= 0) onSubmit();
}, [remainingMs, onSubmit]) }, [remainingMs, onSubmit]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1) if (e.key === "ArrowRight" || e.key === "j") goTo(currentIdx + 1);
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1) if (e.key === "ArrowLeft" || e.key === "k") goTo(currentIdx - 1);
if (e.key === 'f') toggleFlag() if (e.key === "f") toggleFlag();
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) { if (/^[1-9]$/.test(e.key) && q.type === "multiple-choice" && q.choices) {
const ci = Number.parseInt(e.key, 10) - 1 const ci = Number.parseInt(e.key, 10) - 1;
const choices = q.choices const choices = q.choices;
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } })) if (choices[ci])
setState((s) => ({
...s,
answers: { ...s.answers, [q.id]: choices[ci].id },
}));
} }
} };
globalThis.addEventListener('keydown', handler) globalThis.addEventListener("keydown", handler);
return () => globalThis.removeEventListener('keydown', handler) return () => globalThis.removeEventListener("keydown", handler);
}, [currentIdx, q, goTo, toggleFlag, setState]) }, [currentIdx, q, goTo, toggleFlag, setState]);
const graded = feedbackMode === 'immediate' && revealed const graded = feedbackMode === "immediate" && revealed;
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0) const answered =
const result = graded ? gradeQuestion(q, answers[q.id]) : null 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 => { const navStatus = (qq: Question): string => {
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) { if (feedbackMode === "immediate" && state.revealed?.[qq.id]) {
const r = gradeQuestion(qq, answers[qq.id]) const r = gradeQuestion(qq, answers[qq.id]);
if (r.status === 'correct') return 'correct' if (r.status === "correct") return "correct";
if (r.status === 'wrong') return 'wrong' if (r.status === "wrong") return "wrong";
// fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay) // fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay)
} }
if (flags[qq.id]) return 'flagged' if (flags[qq.id]) return "flagged";
const a = answers[qq.id] const a = answers[qq.id];
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered' if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0))
return '' return "answered";
} return "";
};
const answeredCount = exam.questions.filter((qq) => { const answeredCount = exam.questions.filter((qq) => {
const st = navStatus(qq) const st = navStatus(qq);
return st === 'answered' || st === 'correct' || st === 'wrong' return st === "answered" || st === "correct" || st === "wrong";
}).length }).length;
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0) const totalPossible = exam.questions.reduce(
let answerText = '' (acc, qq) => acc + (qq.points ?? 1),
if (!Array.isArray(q.answer) && typeof q.answer === 'string') { 0,
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer );
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)) { } 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 ( return (
@@ -113,78 +149,155 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
<div className="sidebar-section-label">Questions</div> <div className="sidebar-section-label">Questions</div>
<div className="nav-list"> <div className="nav-list">
{exam.questions.map((qq, i) => { {exam.questions.map((qq, i) => {
const st = navStatus(qq) const st = navStatus(qq);
return ( return (
<button key={qq.id} className={`nav-item${i === currentIdx ? ' active' : ''}`} onClick={() => goTo(i)}> <button
<span className="nav-item-num">{String(i + 1).padStart(2, '0')}</span> key={qq.id}
<span className="nav-item-title"> className={`nav-item${i === currentIdx ? " active" : ""}`}
{qq.category ? `${qq.category} · ` : ''}{qq.type.replace('-', ' ')} onClick={() => goTo(i)}
>
<span className="nav-item-num">
{String(i + 1).padStart(2, "0")}
</span> </span>
<span className={`nav-dot${st ? ' ' + st : ''}`} /> <span className="nav-item-title">
{qq.category ? `${qq.category} · ` : ""}
{qq.type.replace("-", " ")}
</span>
<span className={`nav-dot${st ? " " + st : ""}`} />
</button> </button>
) );
})} })}
</div> </div>
<div className="sidebar-section-label">Progress</div> <div className="sidebar-section-label">Progress</div>
<div className="mono" style={{ fontSize: 13, color: 'var(--ink-2)' }}> <div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
{answeredCount} of {total} answered {answeredCount} of {total} answered
</div> </div>
<div className="progress-bar"> <div className="progress-bar">
<div style={{ width: `${(answeredCount / total) * 100}%` }} /> <div style={{ width: `${(answeredCount / total) * 100}%` }} />
</div> </div>
<div style={{ marginTop: 32, display: 'flex', flexDirection: 'column', gap: 8 }}> <div
<button className="btn btn-primary" onClick={onSubmit}>Submit exam</button> style={{
<button className="btn btn-ghost btn-sm" onClick={onExit}>Exit to home</button> marginTop: 32,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
</button>
<button className="btn btn-ghost btn-sm" onClick={onExit}>
Exit to home
</button>
</div> </div>
</aside> </aside>
<main className="main"> <main className="main">
<header className="question-header"> <header className="question-header">
<span className="q-index">Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}</span> <span className="q-index">
Question {String(currentIdx + 1).padStart(2, "0")} /{" "}
{String(total).padStart(2, "0")}
</span>
{q.category && <span className="q-category">{q.category}</span>} {q.category && <span className="q-category">{q.category}</span>}
<span className="q-points">{q.points ?? 1} {(q.points ?? 1) === 1 ? 'pt' : 'pts'}</span> <span className="q-points">
{q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"}
</span>
</header> </header>
<Tex tag="div" className="q-prompt">{q.prompt}</Tex> <Tex tag="div" className="q-prompt">
{q.prompt}
</Tex>
<QuestionMedia q={q} /> <QuestionMedia q={q} />
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={setAnswer} locked={graded && q.type !== 'essay'} graded={graded} /> <QuestionRenderer
key={q.id}
q={q}
value={answers[q.id]}
onChange={setAnswer}
locked={graded && q.type !== "essay"}
graded={graded}
/>
{graded && result && q.type !== 'essay' && ( {graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? 'correct' : 'wrong'}`}> <div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label">{result.correct ? 'Correct' : 'Not quite'}</div> <div className="feedback-label">
{result.correct ? "Correct" : "Not quite"}
</div>
<div className="feedback-body"> <div className="feedback-body">
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && ( {!result.correct &&
q.answer &&
q.type !== "match" &&
q.type !== "order" && (
<p>
<strong>Answer:</strong> <Tex>{answerText}</Tex>
</p>
)}
{q.explanation && (
<p> <p>
<strong>Answer:</strong>{' '} <Tex>{q.explanation}</Tex>
<Tex>{answerText}</Tex>
</p> </p>
)} )}
{q.explanation && <p><Tex>{q.explanation}</Tex></p>}
</div> </div>
</div> </div>
)} )}
<footer className="exam-footer"> <footer className="exam-footer">
<button className="btn" onClick={() => goTo(currentIdx - 1)} disabled={currentIdx === 0}>← Previous</button> <button
className="btn"
onClick={() => goTo(currentIdx - 1)}
disabled={currentIdx === 0}
>
← Previous
</button>
<button className="btn btn-ghost" onClick={toggleFlag}> <button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'} {flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"}
</button> </button>
<span className="spacer" /> <span className="spacer" />
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && ( {feedbackMode === "immediate" &&
<button className="btn btn-primary" onClick={checkAnswer}>Check answer</button> !revealed &&
q.type !== "essay" &&
answered && (
<button className="btn btn-primary" onClick={checkAnswer}>
Check answer
</button>
)}
{currentIdx < total - 1 ? (
<button
className="btn btn-primary"
onClick={() => goTo(currentIdx + 1)}
>
Next →
</button>
) : (
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
</button>
)} )}
{currentIdx < total - 1
? <button className="btn btn-primary" onClick={() => goTo(currentIdx + 1)}>Next →</button>
: <button className="btn btn-primary" onClick={onSubmit}>Submit exam</button>}
</footer> </footer>
<div style={{ marginTop: 48, fontSize: 11.5, color: 'var(--ink-4)', display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div
<span><span className="kbd">←</span> <span className="kbd">→</span> navigate</span> style={{
<span><span className="kbd">F</span> flag</span> marginTop: 48,
{q.type === 'multiple-choice' && q.choices && ( fontSize: 11.5,
<span><span className="kbd">1</span>–<span className="kbd">{q.choices.length}</span> select</span> color: "var(--ink-4)",
display: "flex",
gap: 16,
flexWrap: "wrap",
}}
>
<span>
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
navigate
</span>
<span>
<span className="kbd">F</span> flag
</span>
{q.type === "multiple-choice" && q.choices && (
<span>
<span className="kbd">1</span>–
<span className="kbd">{q.choices.length}</span> select
</span>
)} )}
</div> </div>
</main> </main>
@@ -197,7 +310,11 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{remainingMs != null && ( {remainingMs != null && (
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Remaining</div> <div className="meta-label">Remaining</div>
<div className={`meta-value mono${timeUrgent ? ' timer-urgent' : ''}`}>{formatTime(Math.max(0, remainingMs))}</div> <div
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
>
{formatTime(Math.max(0, remainingMs))}
</div>
</div> </div>
)} )}
<div className="meta-block"> <div className="meta-block">
@@ -207,7 +324,9 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Mode</div> <div className="meta-label">Mode</div>
<div className="meta-value mono" style={{ fontSize: 13 }}> <div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'} {feedbackMode === "immediate"
? "Immediate feedback"
: "Grade at end"}
</div> </div>
</div> </div>
<div className="meta-block notes-block"> <div className="meta-block notes-block">
@@ -230,5 +349,5 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
)} )}
</aside> </aside>
</div> </div>
) );
} }
+11 -8
View File
@@ -1,11 +1,14 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import 'katex/dist/katex.min.css' import { BrowserRouter } from "react-router-dom";
import './index.css' import "katex/dist/katex.min.css";
import App from './App.tsx' import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>, </StrictMode>,
) );
+82 -83
View File
@@ -1,125 +1,124 @@
// src/types.ts // src/types.ts
export type QuestionType = export type QuestionType =
| 'multiple-choice' | "multiple-choice"
| 'multiple-select' | "multiple-select"
| 'short-answer' | "short-answer"
| 'translation' | "translation"
| 'math-input' | "math-input"
| 'essay' | "essay"
| 'match' | "match"
| 'order' | "order";
export interface Choice { export interface Choice {
id: string id: string;
text: string text: string;
} }
export interface MatchItem { export interface MatchItem {
id: string id: string;
text: string text: string;
} }
export interface QuestionImage { export interface QuestionImage {
src: string src: string;
alt?: string alt?: string;
caption?: string caption?: string;
} }
export interface QuestionAudio { export interface QuestionAudio {
src: string src: string;
} }
export interface QuestionCode { export interface QuestionCode {
language: string language: string;
content: string content: string;
} }
export interface QuestionTable { export interface QuestionTable {
headers: string[] headers: string[];
rows: string[][] rows: string[][];
} }
export interface Question { export interface Question {
id: string id: string;
type: QuestionType type: QuestionType;
category?: string category?: string;
points?: number points?: number;
prompt: string prompt: string;
explanation?: string explanation?: string;
image?: QuestionImage image?: QuestionImage;
audio?: QuestionAudio audio?: QuestionAudio;
code?: QuestionCode code?: QuestionCode;
table?: QuestionTable table?: QuestionTable;
svg?: string svg?: string;
answer?: string | string[] | Record<string, string> answer?: string | string[] | Record<string, string>;
acceptedForms?: string[] acceptedForms?: string[];
caseSensitive?: boolean caseSensitive?: boolean;
ignorePunctuation?: boolean ignorePunctuation?: boolean;
ignoreAccents?: boolean ignoreAccents?: boolean;
sourceLanguage?: string sourceLanguage?: string;
targetLanguage?: string targetLanguage?: string;
choices?: Choice[] choices?: Choice[];
minWords?: number minWords?: number;
rubric?: string[] rubric?: string[];
left?: MatchItem[] left?: MatchItem[];
right?: MatchItem[] right?: MatchItem[];
items?: MatchItem[] items?: MatchItem[];
} }
export interface Exam { export interface Exam {
title: string title: string;
subtitle?: string subtitle?: string;
author?: string author?: string;
timeLimitMinutes?: number | null timeLimitMinutes?: number | null;
feedbackMode?: 'end' | 'immediate' feedbackMode?: "end" | "immediate";
passingScore?: number passingScore?: number;
categories?: string[] categories?: string[];
questions: Question[] questions: Question[];
} }
export type FeedbackMode = 'immediate' | 'end' export type FeedbackMode = "immediate" | "end";
export interface ExamState { export interface ExamState {
currentIdx: number currentIdx: number;
answers: Record<string, unknown> answers: Record<string, unknown>;
flags: Record<string, boolean> flags: Record<string, boolean>;
notes: string notes: string;
revealed?: Record<string, boolean> revealed?: Record<string, boolean>;
startedAt: number startedAt: number;
completedAt?: number completedAt?: number;
feedbackMode: FeedbackMode feedbackMode: FeedbackMode;
} }
export interface HistoryEntry { export interface HistoryEntry {
id: string id: string;
title: string title: string;
completedAt: number completedAt: number;
elapsedMs: number elapsedMs: number;
gradedCount: number gradedCount: number;
correctCount: number correctCount: number;
totalPoints: number totalPoints: number;
scorePoints: number scorePoints: number;
passingScore: number passingScore: number;
exam: Exam exam: Exam;
state: ExamState state: ExamState;
} }
export type GradeStatus = 'correct' | 'wrong' | 'ungraded' | 'unanswered' export type GradeStatus = "correct" | "wrong" | "ungraded" | "unanswered";
export interface GradeResult { export interface GradeResult {
status: GradeStatus status: GradeStatus;
correct: boolean correct: boolean;
points: number points: number;
} }
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin' export type ThemeMode = "light" | "dark";
export type ThemeMode = 'light' | 'dark' export type FontSize = "small" | "medium" | "large";
export type FontSize = 'small' | 'medium' | 'large' export type FeedbackTweak = "auto" | "immediate" | "end";
export type FeedbackTweak = 'auto' | 'immediate' | 'end'
export interface Tweaks { export interface Tweaks {
theme: ThemeMode theme: ThemeMode;
fontSize: FontSize fontSize: FontSize;
feedbackMode: FeedbackTweak feedbackMode: FeedbackTweak;
} }
+26
View File
@@ -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('')
})
})
+7
View File
@@ -0,0 +1,7 @@
export function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
+8 -12
View File
@@ -1,23 +1,19 @@
import { defineConfig } from 'vitest/config' import { defineConfig } from "vitest/config";
import react, { reactCompilerPreset } from '@vitejs/plugin-react' import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from '@rolldown/plugin-babel' import babel from "@rolldown/plugin-babel";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
react(),
babel({ presets: [reactCompilerPreset()] })
],
server: { server: {
proxy: { proxy: {
'/api': { "/api": {
target: 'http://localhost:3001', target: "http://localhost:3001",
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
test: { test: {
environment: 'node', environment: "node",
globals: true, globals: true,
}, },
}) });