Compare commits

...
12 Commits
Author SHA1 Message Date
LaurensBartelds 4f33444653 fix: update exam route parameter format and improve question index calculation
Release Pipeline / build-and-release (push) Successful in 5m29s
2026-04-25 16:18:07 +02:00
LaurensBartelds 29d9264cfb feat: optimize state updates using flushSync for smoother exam transitions
Release Pipeline / build-and-release (push) Successful in 5m52s
2026-04-25 13:30:34 +02:00
LaurensBartelds 107ebf4d47 feat: implement admin dashboard with site settings, user management, and activity monitoring 2026-04-25 00:14:57 +02:00
LaurensBartelds 4eeacc38c5 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
2026-04-24 23:55:52 +02:00
LaurensBarteldsandClaude Sonnet 4.6 e97d90e316 docs: add React Router URL routing implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 23:48:23 +02:00
LaurensBarteldsandClaude Sonnet 4.6 1c13f4849a docs: add React Router URL routing design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 23:39:06 +02:00
LaurensBartelds 40fe1479f9 fix: update SSH key handling in deploy workflow to remove unnecessary transformations
Release Pipeline / build-and-release (push) Successful in 5m41s
2026-04-24 23:02:21 +02:00
LaurensBartelds 7b4288fe86 fix: update route handling for static files to improve frontend serving
Release Pipeline / build-and-release (push) Successful in 5m36s
2026-04-24 22:52:53 +02:00
LaurensBartelds 9539e3d740 feat: refactor SSH key handling in deploy workflow to use ssh-agent 2026-04-24 22:35:45 +02:00
LaurensBartelds 10ce58fdfe feat: update deploy workflow to enhance SSH key handling and add git status checks 2026-04-24 22:30:38 +02:00
LaurensBartelds aeed804460 feat: enhance permissions in settings.local.json and update deploy workflow for SSH key handling 2026-04-24 22:20:08 +02:00
LaurensBartelds e21610880b Update release workflow: quotes and asset uploads
Release Pipeline / build-and-release (push) Successful in 5m14s
2026-04-24 16:49:27 +02:00
22 changed files with 4377 additions and 1965 deletions

No files matched your search

+11 -1
View File
@@ -23,7 +23,17 @@
"Bash(xargs git *)", "Bash(xargs git *)",
"Bash(awk '{print $2}')", "Bash(awk '{print $2}')",
"Bash(git tag *)", "Bash(git tag *)",
"Bash(git push *)" "Bash(git push *)",
"Bash(xargs grep *)",
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/**)",
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/**)",
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/**)",
"Bash(bash \"C:/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh\" --project-dir \"c:/Users/laure/Documents/Gitea/brainwave\")",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" status --short)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
] ]
} }
} }
+9 -6
View File
@@ -18,16 +18,19 @@ jobs:
- name: Install Ansible - name: Install Ansible
run: pip3 install --quiet --break-system-packages "ansible>=9,<11" run: pip3 install --quiet --break-system-packages "ansible>=9,<11"
- name: Write SSH Key - name: Setup SSH Agent
run: | run: |
printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" > /tmp/ansible_key eval "$(ssh-agent -s)"
chmod 600 /tmp/ansible_key printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" | ssh-add -
ssh-add -l
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Write Inventory - name: Write Inventory
# StrictHostKeyChecking=no is intentional for a known home-network host. # StrictHostKeyChecking=no is intentional for a known home-network host.
# To harden, add the host fingerprint as a secret and use a known_hosts file. # To harden, add the host fingerprint as a secret and use a known_hosts file.
run: | run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_private_key_file=/tmp/ansible_key ansible_ssh_common_args="-o StrictHostKeyChecking=no"\n' \ printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
"${{ secrets.ANSIBLE_HOST }}" \ "${{ secrets.ANSIBLE_HOST }}" \
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini "${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
@@ -54,8 +57,8 @@ jobs:
-i /tmp/inventory.ini \ -i /tmp/inventory.ini \
-e "artifact_path=${WORKSPACE}" -e "artifact_path=${WORKSPACE}"
- name: Cleanup Secrets - name: Cleanup
if: always() if: always()
run: | run: |
rm -f /tmp/ansible_key ssh-agent -k || true
rm -f /tmp/inventory.ini rm -f /tmp/inventory.ini
+11 -11
View File
@@ -3,7 +3,7 @@ name: Release Pipeline
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
build-and-release: build-and-release:
@@ -17,7 +17,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -96,9 +96,9 @@ jobs:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\", \"name\":\"Release ${TAG}\", \"body\":\"Automated release for version ${TAG}\", \"draft\":false, \"prerelease\":false}" \ -d "{\"tag_name\":\"${TAG}\", \"name\":\"Release ${TAG}\", \"body\":\"Automated release for version ${TAG}\", \"draft\":false, \"prerelease\":false}" \
"${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases") "${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('id', data.get('message', 'ERROR')))") RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('id', data.get('message', 'ERROR')))")
if [ "$RELEASE_ID" = "ERROR" ] || [ "$RELEASE_ID" = "release already exists for this tag" ]; then if [ "$RELEASE_ID" = "ERROR" ] || [ "$RELEASE_ID" = "release already exists for this tag" ]; then
echo "Release might already exist, attempting to find it..." echo "Release might already exist, attempting to find it..."
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
@@ -110,15 +110,15 @@ jobs:
echo "Failed to create or find release: $RELEASE_RESPONSE" echo "Failed to create or find release: $RELEASE_RESPONSE"
exit 1 exit 1
fi fi
echo "Uploading assets to release ID: ${RELEASE_ID}" echo "Uploading assets to release ID: ${RELEASE_ID}"
for FILE in brainwave-amd64.tar.gz brainwave-arm64.tar.gz docker-compose.amd64.yml docker-compose.arm64.yml; do for FILE in brainwave-amd64.tar.gz brainwave-arm64.tar.gz docker-compose.amd64.yml docker-compose.arm64.yml; do
echo "Uploading ${FILE}..." echo "Uploading ${FILE}..."
curl -s -X POST \ UPLOAD_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -F "attachment=@${FILE}" \
--data-binary "@${FILE}" \ "${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILE}")
"${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILE}" \ echo "${UPLOAD_RESPONSE}" | python3 -c "import sys, json; data=json.load(sys.stdin); exit(0 if 'id' in data else 1)" \
| grep -q "id" || { echo "Failed to upload ${FILE}"; exit 1; } || { echo "Failed to upload ${FILE}"; echo "Response: ${UPLOAD_RESPONSE}"; exit 1; }
done done
echo "Release completed successfully!" echo "Release completed successfully!"
+1
View File
@@ -1,3 +1,4 @@
backend/brainwave.db-shm backend/brainwave.db-shm
backend/brainwave.db-wal backend/brainwave.db-wal
.claude/settings.local.json .claude/settings.local.json
.superpowers/
+31 -17
View File
@@ -1,4 +1,4 @@
import type Database from 'better-sqlite3' import type Database from "better-sqlite3";
export function runMigrations(db: Database.Database): void { export function runMigrations(db: Database.Database): void {
db.exec(` db.exec(`
@@ -51,35 +51,49 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id); CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id); CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
`) `);
// ── Add role column if missing (existing databases) ── // ── Add role column if missing (existing databases) ──
const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[] const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
if (!cols.some(c => c.name === 'role')) { name: string;
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`) }[];
if (!cols.some((c) => c.name === "role")) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
} }
// ── Promote first user to admin if no admins exist ── // ── Promote first user to admin if no admins exist ──
const adminCount = db.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`).get() as { cnt: number } const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
.get() as { cnt: number };
if (adminCount.cnt === 0) { if (adminCount.cnt === 0) {
db.prepare(`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`).run() db.prepare(
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
).run();
} }
// ── Seed default settings ── // ── Seed default settings ──
const defaults: Record<string, string> = { const defaults: Record<string, string> = {
site_name: 'BrainWave', site_name: "BrainWave",
max_upload_mb: '5', max_upload_mb: "5",
allow_guest_mode: 'true', allow_guest_mode: "true",
registration_enabled: 'true', registration_enabled: "true",
default_theme: 'light', default_theme: "light",
maintenance_mode: 'false', maintenance_mode: "false",
maintenance_message: 'We are currently performing maintenance. Please check back soon.', maintenance_message:
} "We are currently performing maintenance. Please check back soon.",
smtp_host: "",
smtp_port: "587",
smtp_user: "",
smtp_pass: "",
smtp_from: "BrainWave <noreply@brainwave.local>",
smtp_secure: "auto",
app_url: "http://localhost:5173",
};
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?) INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING ON CONFLICT(key) DO NOTHING
`) `);
for (const [key, value] of Object.entries(defaults)) { for (const [key, value] of Object.entries(defaults)) {
upsert.run(key, value) upsert.run(key, value);
} }
} }
+1 -1
View File
@@ -39,7 +39,7 @@ const frontendDistPath = path.join(__dirname, '../../frontend/dist')
if (fs.existsSync(frontendDistPath)) { if (fs.existsSync(frontendDistPath)) {
console.log(`Serving frontend from: ${frontendDistPath}`) console.log(`Serving frontend from: ${frontendDistPath}`)
app.use(express.static(frontendDistPath)) app.use(express.static(frontendDistPath))
app.get('*', (req, res) => { app.get('*path', (req, res) => {
if (!req.path.startsWith('/api')) { if (!req.path.startsWith('/api')) {
res.sendFile(path.join(frontendDistPath, 'index.html')) res.sendFile(path.join(frontendDistPath, 'index.html'))
} else { } else {
+177 -99
View File
@@ -1,30 +1,44 @@
import { Router } from 'express' import { Router } from "express";
import { getDb } from '../db/database.js' import { getDb } from "../db/database.js";
import { requireAuth, requireAdmin } from '../middleware/auth.js' import { requireAuth, requireAdmin } from "../middleware/auth.js";
import { sendTestEmail } from "../utils/email.js";
import type { SmtpConfig } from "../utils/email.js";
const router = Router() const router = Router();
// All admin routes require authentication + admin role // All admin routes require authentication + admin role
router.use(requireAuth, requireAdmin) router.use(requireAuth, requireAdmin);
// ─── Dashboard Stats ───────────────────────────────────── // ─── Dashboard Stats ─────────────────────────────────────
router.get('/stats', (_req, res) => { router.get("/stats", (_req, res) => {
try { try {
const db = getDb() const db = getDb();
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number } const users = db.prepare("SELECT COUNT(*) AS cnt FROM users").get() as {
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number } cnt: number;
const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number } };
const avgScore = db.prepare( const exams = db
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results' .prepare("SELECT COUNT(*) AS cnt FROM saved_exams")
).get() as { avg: number } .get() as { cnt: number };
const recentResults = db.prepare(` const results = db
.prepare("SELECT COUNT(*) AS cnt FROM exam_results")
.get() as { cnt: number };
const avgScore = db
.prepare(
"SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results",
)
.get() as { avg: number };
const recentResults = db
.prepare(
`
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at, SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
u.display_name, u.email u.display_name, u.email
FROM exam_results er FROM exam_results er
JOIN users u ON u.id = er.user_id JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC ORDER BY er.completed_at DESC
LIMIT 10 LIMIT 10
`).all() `,
)
.all();
res.json({ res.json({
totalUsers: users.cnt, totalUsers: users.cnt,
@@ -32,159 +46,223 @@ router.get('/stats', (_req, res) => {
totalResults: results.cnt, totalResults: results.cnt,
averageScore: Math.round(avgScore.avg * 10) / 10, averageScore: Math.round(avgScore.avg * 10) / 10,
recentResults, recentResults,
}) });
} catch (err) { } catch (err) {
console.error('Admin stats error:', err) console.error("Admin stats error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Get All Settings ──────────────────────────────────── // ─── Get All Settings ────────────────────────────────────
router.get('/settings', (_req, res) => { router.get("/settings", (_req, res) => {
try { try {
const db = getDb() const db = getDb();
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[] const rows = db
const settings: Record<string, string> = {} .prepare("SELECT key, value FROM site_settings ORDER BY key")
.all() as { key: string; value: string }[];
const settings: Record<string, string> = {};
for (const row of rows) { for (const row of rows) {
settings[row.key] = row.value settings[row.key] = row.value;
} }
res.json({ settings }) res.json({ settings });
} catch (err) { } catch (err) {
console.error('Admin get settings error:', err) console.error("Admin get settings error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Update Settings ───────────────────────────────────── // ─── Update Settings ─────────────────────────────────────
router.put('/settings', (req, res) => { router.put("/settings", (req, res) => {
try { try {
const { settings } = req.body as { settings: Record<string, string> } const { settings } = req.body as { settings: Record<string, string> };
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== "object") {
res.status(400).json({ error: 'Settings object is required' }) res.status(400).json({ error: "Settings object is required" });
return return;
} }
const validKeys = [ const validKeys = [
'site_name', 'max_upload_mb', 'allow_guest_mode', "site_name",
'registration_enabled', 'default_theme', 'maintenance_mode', "max_upload_mb",
'maintenance_message', "allow_guest_mode",
] "registration_enabled",
"default_theme",
"maintenance_mode",
"maintenance_message",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_pass",
"smtp_from",
"smtp_secure",
"app_url",
];
const db = getDb() const db = getDb();
const update = db.prepare(` const update = db.prepare(`
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ? UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
`) `);
const updateMany = db.transaction((entries: [string, string][]) => { const updateMany = db.transaction((entries: [string, string][]) => {
for (const [key, value] of entries) { for (const [key, value] of entries) {
if (!validKeys.includes(key)) continue if (!validKeys.includes(key)) continue;
update.run(String(value), key) update.run(String(value), key);
} }
}) });
updateMany(Object.entries(settings)) updateMany(Object.entries(settings));
res.json({ message: 'Settings updated successfully' }) res.json({ message: "Settings updated successfully" });
} catch (err) { } catch (err) {
console.error('Admin update settings error:', err) console.error("Admin update settings error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── List All Users ────────────────────────────────────── // ─── List All Users ──────────────────────────────────────
router.get('/users', (req, res) => { router.get("/users", (req, res) => {
try { try {
const limit = Math.min(Number(req.query['limit']) || 50, 200) const limit = Math.min(Number(req.query["limit"]) || 50, 200);
const offset = Number(req.query['offset']) || 0 const offset = Number(req.query["offset"]) || 0;
const search = (req.query['search'] as string) || '' const search = (req.query["search"] as string) || "";
const db = getDb() const db = getDb();
let query = ` let query = `
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at, SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at,
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count, (SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count,
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count (SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
FROM users u FROM users u
` `;
let countQuery = 'SELECT COUNT(*) AS total FROM users' let countQuery = "SELECT COUNT(*) AS total FROM users";
const params: unknown[] = [] const params: unknown[] = [];
const countParams: unknown[] = [] const countParams: unknown[] = [];
if (search) { if (search) {
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?` const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`;
query += where query += where;
countQuery += where.replace('u.', '') countQuery += where.replace("u.", "");
params.push(`%${search}%`, `%${search}%`) params.push(`%${search}%`, `%${search}%`);
countParams.push(`%${search}%`, `%${search}%`) countParams.push(`%${search}%`, `%${search}%`);
} }
query += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?' query += " ORDER BY u.created_at DESC LIMIT ? OFFSET ?";
params.push(limit, offset) params.push(limit, offset);
const users = db.prepare(query).all(...params) const users = db.prepare(query).all(...params);
const countRow = db.prepare(countQuery).get(...countParams) as { total: number } const countRow = db.prepare(countQuery).get(...countParams) as {
total: number;
};
res.json({ users, total: countRow.total }) res.json({ users, total: countRow.total });
} catch (err) { } catch (err) {
console.error('Admin list users error:', err) console.error("Admin list users error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Change User Role ──────────────────────────────────── // ─── Change User Role ────────────────────────────────────
router.patch('/users/:id/role', (req, res) => { router.patch("/users/:id/role", (req, res) => {
try { try {
const userId = Number(req.params['id']) const userId = Number(req.params["id"]);
const { role } = req.body as { role: string } const { role } = req.body as { role: string };
if (!['user', 'admin'].includes(role)) { if (!["user", "admin"].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' }) res.status(400).json({ error: 'Role must be "user" or "admin"' });
return return;
} }
// Prevent self-demotion // Prevent self-demotion
if (userId === req.user!.userId && role !== 'admin') { if (userId === req.user!.userId && role !== "admin") {
res.status(400).json({ error: 'You cannot remove your own admin role' }) res.status(400).json({ error: "You cannot remove your own admin role" });
return return;
} }
const db = getDb() const db = getDb();
const result = db.prepare('UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?').run(role, userId) const result = db
.prepare(
"UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?",
)
.run(role, userId);
if (result.changes === 0) { if (result.changes === 0) {
res.status(404).json({ error: 'User not found' }) res.status(404).json({ error: "User not found" });
return return;
} }
res.json({ message: `User role updated to ${role}` }) res.json({ message: `User role updated to ${role}` });
} catch (err) { } catch (err) {
console.error('Admin change role error:', err) console.error("Admin change role error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Delete User ───────────────────────────────────────── // ─── Delete User ─────────────────────────────────────────
router.delete('/users/:id', (req, res) => { router.delete("/users/:id", (req, res) => {
try { try {
const userId = Number(req.params['id']) const userId = Number(req.params["id"]);
// Prevent self-deletion // Prevent self-deletion
if (userId === req.user!.userId) { if (userId === req.user!.userId) {
res.status(400).json({ error: 'You cannot delete your own account' }) res.status(400).json({ error: "You cannot delete your own account" });
return return;
} }
const db = getDb() const db = getDb();
const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId) const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
if (result.changes === 0) { if (result.changes === 0) {
res.status(404).json({ error: 'User not found' }) res.status(404).json({ error: "User not found" });
return return;
} }
res.json({ message: 'User deleted' }) res.json({ message: "User deleted" });
} catch (err) { } catch (err) {
console.error('Admin delete user error:', err) console.error("Admin delete user error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
export default router // ─── Test Email ───────────────────────────────────────────
router.post("/test-email", async (req, res) => {
try {
// The body may contain optional SMTP override settings (for testing before saving)
// and a `to` field (defaults to the requesting admin's email)
const { to, smtp } = req.body as {
to?: string;
smtp?: {
host: string;
port: string;
user: string;
pass: string;
from: string;
secure: string;
};
};
const recipient = to || req.user!.email;
let overrideConfig: SmtpConfig | undefined;
if (smtp?.host) {
const port = Number(smtp.port ?? "587");
overrideConfig = {
host: smtp.host,
port,
user: smtp.user ?? "",
pass: smtp.pass ?? "",
from: smtp.from ?? "BrainWave <noreply@brainwave.local>",
secure:
smtp.secure === "true" || (smtp.secure === "auto" && port === 465),
};
}
await sendTestEmail(recipient, overrideConfig);
res.json({ message: `Test email sent to ${recipient}` });
} catch (err) {
console.error("Test email error:", err);
res
.status(500)
.json({
error: err instanceof Error ? err.message : "Failed to send test email",
});
}
});
export default router;
+141 -41
View File
@@ -1,46 +1,115 @@
import nodemailer from 'nodemailer' import nodemailer from "nodemailer";
import { getDb } from "../db/database.js";
const APP_URL = process.env['APP_URL'] ?? 'http://localhost:5173' // ─── Types ────────────────────────────────────────────────────────────────────
const SMTP_HOST = process.env['SMTP_HOST'] export interface SmtpConfig {
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587') host: string;
const SMTP_USER = process.env['SMTP_USER'] port: number;
const SMTP_PASS = process.env['SMTP_PASS'] user: string;
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>' pass: string;
from: string;
const isDev = !SMTP_HOST secure: boolean;
let transporter: nodemailer.Transporter | null = null
function getTransporter(): nodemailer.Transporter | null {
if (isDev) return null
if (!transporter) {
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: { user: SMTP_USER, pass: SMTP_PASS },
})
}
return transporter
} }
async function sendMail(to: string, subject: string, html: string): Promise<void> { // ─── App URL ──────────────────────────────────────────────────────────────────
const t = getTransporter()
if (!t) { export function getAppUrl(): string {
console.log('\n══════════════════════════════════════════════════') try {
console.log(`📧 EMAIL (dev mode) → ${to}`) const db = getDb();
console.log(` Subject: ${subject}`) const row = db
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`) .prepare("SELECT value FROM site_settings WHERE key = ?")
console.log('══════════════════════════════════════════════════\n') .get("app_url") as { value: string } | undefined;
return return (
(row?.value?.trim() || process.env["APP_URL"]) ?? "http://localhost:5173"
);
} catch {
return process.env["APP_URL"] ?? "http://localhost:5173";
} }
await t.sendMail({ from: SMTP_FROM, to, subject, html })
} }
export async function sendVerificationEmail(to: string, token: string): Promise<void> { // ─── SMTP Config ──────────────────────────────────────────────────────────────
const link = `${APP_URL}/verify?token=${token}`
await sendMail(to, 'Verify your BrainWave account', ` function getSetting(key: string): string {
const db = getDb();
const row = db
.prepare("SELECT value FROM site_settings WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? "";
}
function getSmtpConfig(): SmtpConfig | null {
const host = getSetting("smtp_host").trim();
if (!host) return null;
const portStr = getSetting("smtp_port").trim() || "587";
const port = Number(portStr);
const user = getSetting("smtp_user").trim();
const pass = getSetting("smtp_pass").trim();
const from =
getSetting("smtp_from").trim() || "BrainWave <noreply@brainwave.local>";
const secureStr = getSetting("smtp_secure").trim() || "auto";
let secure: boolean;
if (secureStr === "true") {
secure = true;
} else if (secureStr === "false") {
secure = false;
} else {
// 'auto': derive from port
secure = port === 465;
}
return { host, port, user, pass, from, secure };
}
// ─── Transporter ─────────────────────────────────────────────────────────────
function buildTransporter(cfg: SmtpConfig): nodemailer.Transporter {
return nodemailer.createTransport({
host: cfg.host,
port: cfg.port,
secure: cfg.secure,
auth: cfg.user ? { user: cfg.user, pass: cfg.pass } : undefined,
});
}
// ─── Core Send ────────────────────────────────────────────────────────────────
async function sendMail(
to: string,
subject: string,
html: string,
overrideConfig?: SmtpConfig,
): Promise<void> {
const cfg = overrideConfig ?? getSmtpConfig();
if (!cfg) {
// Dev mode — no SMTP configured, log to console
console.log("\n══════════════════════════════════════════════════");
console.log(`📧 EMAIL (dev mode) → ${to}`);
console.log(` Subject: ${subject}`);
console.log(` Body:\n${html.replace(/<[^>]*>/g, "")}`);
console.log("══════════════════════════════════════════════════\n");
return;
}
const transporter = buildTransporter(cfg);
await transporter.sendMail({ from: cfg.from, to, subject, html });
}
// ─── Exported Email Functions ─────────────────────────────────────────────────
export async function sendVerificationEmail(
to: string,
token: string,
): Promise<void> {
const appUrl = getAppUrl();
const link = `${appUrl}/verify?token=${token}`;
await sendMail(
to,
"Verify your BrainWave account",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;"> <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2> <h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;"> <p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
@@ -56,12 +125,20 @@ export async function sendVerificationEmail(to: string, token: string): Promise<
This link expires in 24 hours. If you didn't create an account, you can ignore this email. This link expires in 24 hours. If you didn't create an account, you can ignore this email.
</p> </p>
</div> </div>
`) `,
);
} }
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> { export async function sendPasswordResetEmail(
const link = `${APP_URL}/reset-password?token=${token}` to: string,
await sendMail(to, 'Reset your BrainWave password', ` token: string,
): Promise<void> {
const appUrl = getAppUrl();
const link = `${appUrl}/reset-password?token=${token}`;
await sendMail(
to,
"Reset your BrainWave password",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;"> <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2> <h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;"> <p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
@@ -77,5 +154,28 @@ export async function sendPasswordResetEmail(to: string, token: string): Promise
This link expires in 1 hour. If you didn't request a password reset, you can ignore this email. This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
</p> </p>
</div> </div>
`) `,
);
}
export async function sendTestEmail(
to: string,
overrideConfig?: SmtpConfig,
): Promise<void> {
await sendMail(
to,
"Test email from BrainWave",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">This is a test email from BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
If you're reading this, your SMTP configuration is working correctly.
</p>
<p style="font-size: 13px; color: #888; margin-top: 32px;">
Sent at: ${new Date().toUTCString()}
</p>
</div>
`,
overrideConfig,
);
} }
@@ -0,0 +1,818 @@
# React Router URL Routing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the custom screen-state machine in App.tsx with React Router v7 so the URL reflects the active exam and question number (e.g. `/exam/my-exam/q3`).
**Architecture:** Install `react-router-dom`, wrap the app in `BrowserRouter`, replace the `screen` useState with `<Routes>` / `<Route>`, and replace all `setScreen()` calls with `useNavigate()`. A new `ExamRoute` component handles deep-link restoration from localStorage and keeps `currentIdx` in sync with the `:n` URL param (enabling browser back/forward).
**Tech Stack:** React 19, react-router-dom v7, Vite, TypeScript, Vitest
---
## File Map
| Action | File |
| ------ | ---- |
| Create | `frontend/src/utils/slug.ts` |
| Create | `frontend/src/utils/slug.test.ts` |
| Create | `frontend/src/components/ExamRoute.tsx` |
| Modify | `frontend/package.json` |
| Modify | `frontend/vite.config.ts` |
| Modify | `frontend/src/main.tsx` |
| Modify | `frontend/src/components/ExamRunner.tsx` |
| Modify | `frontend/src/App.tsx` |
| Modify | `frontend/src/types.ts` |
---
## Task 1: Install react-router-dom
**Files:**
- Modify: `frontend/package.json`
- [ ] **Step 1: Install the package**
Run from the `frontend/` directory:
```bash
npm install react-router-dom
```
Expected: `react-router-dom` appears under `"dependencies"` in `package.json`. No type errors.
- [ ] **Step 2: Verify TypeScript types are available**
Run:
```bash
npm run build 2>&1 | head -5
```
Expected: build succeeds (or fails only on pre-existing errors — not on missing `react-router-dom` types).
- [ ] **Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "feat: install react-router-dom"
```
---
## Task 2: Add slugify utility (TDD)
**Files:**
- Create: `frontend/src/utils/slug.test.ts`
- Create: `frontend/src/utils/slug.ts`
- [ ] **Step 1: Write the failing tests**
Create `frontend/src/utils/slug.test.ts`:
```typescript
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('')
})
})
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
cd frontend && npm run test:run -- src/utils/slug.test.ts
```
Expected: FAIL — `Cannot find module './slug'`
- [ ] **Step 3: Implement slugify**
Create `frontend/src/utils/slug.ts`:
```typescript
export function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
```
- [ ] **Step 4: Run tests to confirm they pass**
```bash
cd frontend && npm run test:run -- src/utils/slug.test.ts
```
Expected: 7 passing tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/utils/slug.ts frontend/src/utils/slug.test.ts
git commit -m "feat: add slugify utility"
```
---
## Task 3: Update Vite config and wrap app in BrowserRouter
**Files:**
- Modify: `frontend/vite.config.ts`
- Modify: `frontend/src/main.tsx`
- [ ] **Step 1: Add historyApiFallback to Vite config**
In `frontend/vite.config.ts`, add `historyApiFallback: true` to the `server` block:
```typescript
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()] })
],
server: {
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
test: {
environment: 'node',
globals: true,
},
})
```
- [ ] **Step 2: Wrap the app in BrowserRouter**
Replace the contents of `frontend/src/main.tsx`:
```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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
```
- [ ] **Step 3: Verify build still passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds — `App` does not yet use any router hooks so wrapping it is safe.
- [ ] **Step 4: Commit**
```bash
git add frontend/vite.config.ts frontend/src/main.tsx
git commit -m "feat: wrap app in BrowserRouter and enable history fallback"
```
---
## Task 4: Update ExamRunner with onNavigate prop
**Files:**
- Modify: `frontend/src/components/ExamRunner.tsx`
The only change is adding an optional `onNavigate` prop and calling it from `goTo`. Everything else stays identical.
- [ ] **Step 1: Add the prop and update goTo**
In `frontend/src/components/ExamRunner.tsx`, update the `Props` interface and `goTo` callback:
```typescript
// Change the Props interface (lines 9-15) to:
interface Props {
exam: Exam
state: ExamState
setState: React.Dispatch<React.SetStateAction<ExamState>>
onSubmit: () => void
onExit: () => void
onNavigate?: (i: number) => void
}
// Change the function signature (line 17) to:
export default function ExamRunner({ exam, state, setState, onSubmit, onExit, onNavigate }: Readonly<Props>) {
// Replace the goTo callback (lines 29-30) with:
const goTo = useCallback((i: number) => {
const next = Math.max(0, Math.min(total - 1, i))
setState((s) => ({ ...s, currentIdx: next }))
onNavigate?.(next)
}, [total, onNavigate])
```
- [ ] **Step 2: Verify build passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds — `onNavigate` is optional so existing callers are unaffected.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/ExamRunner.tsx
git commit -m "feat: add optional onNavigate prop to ExamRunner"
```
---
## Task 5: Create ExamRoute component
**Files:**
- Create: `frontend/src/components/ExamRoute.tsx`
This component owns three responsibilities:
1. On mount, if no exam is active, attempt to restore from localStorage using the `:slug` param; redirect to `/` if no match.
2. On every render, sync the `:n` URL param to `examState.currentIdx` (enables browser back/forward).
3. Render `ExamRunner` with an `onNavigate` handler that updates the URL.
- [ ] **Step 1: Create the file**
Create `frontend/src/components/ExamRoute.tsx`:
```tsx
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}
/>
)
}
```
- [ ] **Step 2: Verify build passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds. `ExamRoute` is not yet imported by anything so no usages to break.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/ExamRoute.tsx
git commit -m "feat: add ExamRoute component with deep-link restore and back/forward sync"
```
---
## Task 6: Migrate App.tsx to React Router
**Files:**
- Modify: `frontend/src/App.tsx`
This is the main migration. Replace the `screen` state machine with `<Routes>`, replace all `setScreen()` calls with `navigate()`, add `activateExam` for deep-link activation, and use `useLocation` for the verify/reset-password special layouts.
- [ ] **Step 1: Replace App.tsx with the migrated version**
Replace the entire contents of `frontend/src/App.tsx`:
```tsx
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 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
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))
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) => {
setExam(data.exam)
setExamState(withNotes(data.state))
navigate(`/review/${slugify(entry.title)}`)
})
.catch(() => {
setExam(entry.exam)
setExamState(withNotes(entry.state))
navigate(`/review/${slugify(entry.title)}`)
})
} else {
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>
)
}
```
- [ ] **Step 2: Verify the build passes**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: build succeeds with no TypeScript errors.
- [ ] **Step 3: Run all tests**
```bash
cd frontend && npm run test:run
```
Expected: all tests pass (slugify + pre-existing utils).
- [ ] **Step 4: Commit**
```bash
git add frontend/src/App.tsx
git commit -m "feat: migrate App.tsx to React Router — replace screen state with declarative routes"
```
---
## Task 7: Remove AppScreen type from types.ts
**Files:**
- Modify: `frontend/src/types.ts`
`AppScreen` is no longer referenced anywhere after the App.tsx migration.
- [ ] **Step 1: Remove the type**
In `frontend/src/types.ts`, delete line 116:
```typescript
// Delete this line:
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
```
- [ ] **Step 2: Verify build still passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds. If there are errors referencing `AppScreen`, search for usages:
```bash
grep -r "AppScreen" frontend/src
```
Remove any remaining import of `AppScreen` from those files.
- [ ] **Step 3: Run all tests**
```bash
cd frontend && npm run test:run
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/types.ts
git commit -m "chore: remove unused AppScreen type"
```
---
## Task 8: Manual smoke test
No automated tests cover the React Router integration (the test environment is `node`, not jsdom). Verify the feature manually.
- [ ] **Step 1: Start the dev server**
```bash
cd frontend && npm run dev
```
- [ ] **Step 2: Verify landing page**
Open `http://localhost:5173`. URL should be `/`. Landing page should render.
- [ ] **Step 3: Start an exam and verify URL updates**
Load any exam. The URL should change to `/exam/<slug>/q1`.
Navigate to question 3 (sidebar or Next button). URL should change to `/exam/<slug>/q3`.
- [ ] **Step 4: Verify browser back/forward**
Press the browser Back button. URL should go to `/exam/<slug>/q2` and question 2 should display.
- [ ] **Step 5: Verify deep link**
While on question 5, copy the URL. Open a new tab and paste it. The exam should resume at question 5 (restored from localStorage).
- [ ] **Step 6: Verify submit → results URL**
Submit the exam. URL should change to `/results`.
- [ ] **Step 7: Verify review URL**
From the landing history, click a past attempt to review. URL should change to `/review/<slug>`.
- [ ] **Step 8: Verify auth flow routes**
Navigate to `http://localhost:5173/verify`. The email verify screen should render (simplified header, no nav).
Navigate to `http://localhost:5173/reset-password`. The reset password screen should render.
- [ ] **Step 9: Verify unknown route redirect**
Navigate to `http://localhost:5173/does-not-exist`. Should redirect to `/`.
@@ -0,0 +1,112 @@
# React Router URL Routing — Design Spec
**Date:** 2026-04-24
**Status:** Approved
## Overview
Add React Router v7 to the Brainwave frontend so the URL reflects the active exam and current question number. A user on question 3 of "My Exam" will see `/exam/my-exam/q3` in the address bar. Back/forward browser navigation works naturally, and deep links to a specific question are supported.
## Route Structure
| URL | Screen |
| --- | ------ |
| `/` | Landing |
| `/exam/:slug/q:n` | ExamRunner — question `n` (1-indexed) of exam identified by `slug` |
| `/results` | Results (post-submission) |
| `/review/:slug` | Results in review-only mode |
| `/verify` | Email verification |
| `/reset-password` | Password reset |
| `/admin` | Admin dashboard (admin users only) |
`:slug` is the exam title converted to lowercase kebab-case (e.g. `"My Exam 2024"` → `"my-exam-2024"`). `:n` is the 1-indexed question number.
## Architecture
### New dependency
`react-router-dom` v7 added to `frontend/package.json` dependencies.
### New utility — `frontend/src/utils/slug.ts`
Pure function `slugify(title: string): string`:
- Lowercase
- Replace non-alphanumeric characters with hyphens
- Collapse consecutive hyphens
- Trim leading/trailing hyphens
### `main.tsx`
Wrap `<App>` in `<BrowserRouter>`.
### `App.tsx`
- Remove `screen` state and `AppScreen` type usage.
- Replace the URL-on-mount `useEffect` with React Router's declarative routing.
- Use `<Routes>` / `<Route>` to render screen components.
- Replace all `setScreen()` calls with `useNavigate()`:
- `loadExam()` → `navigate('/exam/${slug}/q1')`
- `submit()` → `navigate('/results')`
- `exit()` → `navigate('/')`
- `reviewAttempt()` → `navigate('/review/${slug}')`
- Admin menu item → `navigate('/admin')`
- `handleAuthFlowDone()` → `navigate('/')`
- `exam` and `examState` remain in React state — no change to storage, grading, or submission logic.
### `ExamRunner.tsx`
- Accept an `onNavigate: (i: number) => void` prop, called from `goTo()` after updating `examState`.
- `App.tsx` passes a handler that calls `navigate('/exam/${slug}/q${i+1}')`.
- ExamRunner has no direct knowledge of React Router — it remains router-agnostic.
### Exam route handler in `App.tsx`
- Reads `:n` and `:slug` from `useParams()`.
- On mount and whenever `:n` changes (back/forward), calls `setExamState` to set `currentIdx = n - 1`.
- Handles the deep-link case: if no active exam in state, checks localStorage for a matching slug; resumes or redirects to `/`.
### Deep linking
When the app loads on `/exam/:slug/q:n`:
- Check localStorage for a saved exam state (`brainwave.exam.state`).
- If the saved exam's slugified title matches `:slug`, resume with `currentIdx = n - 1`.
- If no match, redirect to `/`.
### Vite config
Add `server.historyApiFallback: true` to `vite.config.ts` so the dev server serves `index.html` for all non-asset paths. The production backend already handles this (it serves `index.html` for non-API routes).
## Data Flow
```text
User clicks question 5 in sidebar
→ goTo(4) called in ExamRunner
→ setState updates examState.currentIdx = 4
→ onNavigate(4) called
→ navigate('/exam/my-exam/q5') in App
→ URL bar updates
→ :n param changes
→ useEffect in App exam route calls setExamState [no-op, already 4]
User presses browser Back
→ URL changes to /exam/my-exam/q4
→ :n param changes to '4'
→ useEffect in App exam route calls setExamState({ currentIdx: 3 })
→ ExamRunner re-renders at question 4
```
## What Does Not Change
- `Results.tsx`, `Landing.tsx`, all question components
- `AuthContext`, storage utils, grading utils, API utils
- Backend — no changes required
- `ExamState` shape and localStorage persistence
- `AppScreen` type removed from `types.ts`
## Out of Scope
- Animated route transitions
- Scroll restoration between questions
- Sharing/bookmarking exam links to others (exam data is client-side only)
+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",
+331 -155
View File
@@ -1,57 +1,73 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types' import { flushSync } from "react-dom";
import { gradeQuestion } from './utils/grading' import {
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage' useNavigate,
import { apiFetch, isAuthenticated } from './utils/api' useLocation,
import { AuthProvider, useAuth } from './contexts/AuthContext' Routes,
import Landing from './components/Landing' Route,
import ExamRunner from './components/ExamRunner' Navigate,
import Results from './components/Results' } from "react-router-dom";
import TweaksPanel from './components/TweaksPanel' import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
import AuthModal from './components/AuthModal' import { gradeQuestion } from "./utils/grading";
import EmailVerify from './components/EmailVerify' import {
import ResetPassword from './components/ResetPassword' loadState,
import AdminDashboard from './components/AdminDashboard' 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 => ({ 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 +80,115 @@ 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;
} flushSync(() => {
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) {
} flushSync(() => {
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 +202,79 @@ 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) flushSync(() => {
setExamState(withNotes(data.state)) setExam(data.exam);
setScreen('review') setExamState(withNotes(data.state));
});
navigate(`/review/${slugify(entry.title)}`);
}) })
.catch(() => { .catch(() => {
// Fallback to local data flushSync(() => {
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') setExam(entry.exam);
}) setExamState(withNotes(entry.state));
});
navigate(`/review/${slugify(entry.title)}`);
});
} else { } else {
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') flushSync(() => {
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 +285,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 +299,7 @@ function AppInner() {
</header> </header>
<ResetPassword onDone={handleAuthFlowDone} /> <ResetPassword onDone={handleAuthFlowDone} />
</div> </div>
) );
} }
return ( return (
@@ -235,24 +311,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 +365,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/: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 +491,5 @@ export default function App() {
<AuthProvider> <AuthProvider>
<AppInner /> <AppInner />
</AuthProvider> </AuthProvider>
) );
} }
+492 -155
View File
@@ -1,155 +1,208 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { apiFetch } from '../utils/api' import { apiFetch } from "../utils/api";
interface SiteSettings { interface SiteSettings {
site_name: string site_name: string;
max_upload_mb: string max_upload_mb: string;
allow_guest_mode: string allow_guest_mode: string;
registration_enabled: string registration_enabled: string;
default_theme: string default_theme: string;
maintenance_mode: string maintenance_mode: string;
maintenance_message: string maintenance_message: string;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: string;
app_url: string;
} }
interface DashboardStats { interface DashboardStats {
totalUsers: number totalUsers: number;
totalExams: number totalExams: number;
totalResults: number totalResults: number;
averageScore: number averageScore: number;
recentResults: { recentResults: {
id: number id: number;
title: string title: string;
score_points: number score_points: number;
total_points: number total_points: number;
completed_at: number completed_at: number;
display_name: string display_name: string;
email: string email: string;
}[] }[];
} }
interface AdminUser { interface AdminUser {
id: number id: number;
email: string email: string;
display_name: string display_name: string;
role: 'user' | 'admin' role: "user" | "admin";
email_verified: number email_verified: number;
created_at: number created_at: number;
results_count: number results_count: number;
exams_count: number exams_count: number;
} }
type AdminTab = 'overview' | 'settings' | 'users' type AdminTab = "overview" | "settings" | "users";
interface Props { interface Props {
onExit: () => void onExit: () => void;
} }
export default function AdminDashboard({ onExit }: Readonly<Props>) { export default function AdminDashboard({ onExit }: Readonly<Props>) {
const [tab, setTab] = useState<AdminTab>('overview') const [tab, setTab] = useState<AdminTab>("overview");
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null);
const [settings, setSettings] = useState<SiteSettings | null>(null) const [settings, setSettings] = useState<SiteSettings | null>(null);
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null) const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([]);
const [usersTotal, setUsersTotal] = useState(0) const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('') const [userSearch, setUserSearch] = useState("");
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null) const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null) const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailMsg, setTestEmailMsg] = useState<{
ok: boolean;
text: string;
} | null>(null);
const [testEmailSending, setTestEmailSending] = useState(false);
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
try { try {
const data = await apiFetch<DashboardStats>('/api/admin/stats') const data = await apiFetch<DashboardStats>("/api/admin/stats");
setStats(data) setStats(data);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
try { try {
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings') const data = await apiFetch<{ settings: SiteSettings }>(
setSettings(data.settings) "/api/admin/settings",
setSettingsDraft(data.settings) );
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
const loadUsers = useCallback(async (search = '') => { const loadUsers = useCallback(async (search = "") => {
try { try {
const q = search ? `?search=${encodeURIComponent(search)}` : '' const q = search ? `?search=${encodeURIComponent(search)}` : "";
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`) const data = await apiFetch<{ users: AdminUser[]; total: number }>(
setUsers(data.users) `/api/admin/users${q}`,
setUsersTotal(data.total) );
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
void loadStats() void loadStats();
void loadSettings() void loadSettings();
void loadUsers() void loadUsers();
}, [loadStats, loadSettings, loadUsers]) }, [loadStats, loadSettings, loadUsers]);
const saveSettings = async () => { const saveSettings = async () => {
if (!settingsDraft) return if (!settingsDraft) return;
setSaving(true) setSaving(true);
setSaveMsg(null) setSaveMsg(null);
try { try {
await apiFetch('/api/admin/settings', { await apiFetch("/api/admin/settings", {
method: 'PUT', method: "PUT",
body: JSON.stringify({ settings: settingsDraft }), body: JSON.stringify({ settings: settingsDraft }),
}) });
setSettings(settingsDraft) setSettings(settingsDraft);
setSaveMsg('Settings saved successfully') setSaveMsg("Settings saved successfully");
setTimeout(() => setSaveMsg(null), 3000) setTimeout(() => setSaveMsg(null), 3000);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} finally { } finally {
setSaving(false) setSaving(false);
} }
} };
const changeRole = async (userId: number, newRole: 'user' | 'admin') => { const changeRole = async (userId: number, newRole: "user" | "admin") => {
try { try {
await apiFetch(`/api/admin/users/${userId}/role`, { await apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH', method: "PATCH",
body: JSON.stringify({ role: newRole }), body: JSON.stringify({ role: newRole }),
}) });
await loadUsers(userSearch) await loadUsers(userSearch);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
} };
const deleteUser = async (userId: number) => { const deleteUser = async (userId: number) => {
try { try {
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' }) await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
setDeleteConfirm(null) setDeleteConfirm(null);
await loadUsers(userSearch) await loadUsers(userSearch);
await loadStats() await loadStats();
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
} };
const sendTestEmail = async () => {
if (!settingsDraft) return;
setTestEmailSending(true);
setTestEmailMsg(null);
try {
const smtp = {
host: settingsDraft.smtp_host,
port: settingsDraft.smtp_port,
user: settingsDraft.smtp_user,
pass: settingsDraft.smtp_pass,
from: settingsDraft.smtp_from,
secure: settingsDraft.smtp_secure,
};
await apiFetch<{ message: string }>("/api/admin/test-email", {
method: "POST",
body: JSON.stringify({ to: testEmailTo || undefined, smtp }),
});
setTestEmailMsg({
ok: true,
text: "Test email sent successfully! Check your inbox.",
});
} catch (e) {
setTestEmailMsg({ ok: false, text: (e as Error).message });
} finally {
setTestEmailSending(false);
setTimeout(() => setTestEmailMsg(null), 6000);
}
};
const handleUserSearch = (e: React.FormEvent) => { const handleUserSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
void loadUsers(userSearch) void loadUsers(userSearch);
} };
const updateDraft = (key: keyof SiteSettings, value: string) => { const updateDraft = (key: keyof SiteSettings, value: string) => {
setSettingsDraft((d) => d ? { ...d, [key]: value } : d) setSettingsDraft((d) => (d ? { ...d, [key]: value } : d));
} };
const hasChanges = settings && settingsDraft && JSON.stringify(settings) !== JSON.stringify(settingsDraft) const hasChanges =
settings &&
settingsDraft &&
JSON.stringify(settings) !== JSON.stringify(settingsDraft);
const formatDate = (ts: number) => { const formatDate = (ts: number) => {
const d = new Date(ts * 1000) const d = new Date(ts * 1000);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return d.toLocaleDateString("en-US", {
} month: "short",
day: "numeric",
year: "numeric",
});
};
return ( return (
<div className="admin"> <div className="admin">
@@ -166,19 +219,21 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{error && ( {error && (
<div className="admin-alert admin-alert-error"> <div className="admin-alert admin-alert-error">
<span>{error}</span> <span>{error}</span>
<button onClick={() => setError(null)} className="admin-alert-close">×</button> <button onClick={() => setError(null)} className="admin-alert-close">
×
</button>
</div> </div>
)} )}
<div className="admin-tabs"> <div className="admin-tabs">
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => ( {(["overview", "settings", "users"] as AdminTab[]).map((t) => (
<button <button
key={t} key={t}
className={`admin-tab${tab === t ? ' active' : ''}`} className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)} onClick={() => setTab(t)}
> >
<span className="admin-tab-icon"> <span className="admin-tab-icon">
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'} {t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
</span> </span>
{t.charAt(0).toUpperCase() + t.slice(1)} {t.charAt(0).toUpperCase() + t.slice(1)}
</button> </button>
@@ -186,27 +241,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
{/* ─── Overview Tab ─── */} {/* ─── Overview Tab ─── */}
{tab === 'overview' && ( {tab === "overview" && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon users-icon">⊕</div> <div className="admin-stat-icon users-icon">⊕</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value">{stats?.totalUsers ?? '—'}</div> <div className="admin-stat-value">
{stats?.totalUsers ?? "—"}
</div>
<div className="admin-stat-label">Total Users</div> <div className="admin-stat-label">Total Users</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon exams-icon">◇</div> <div className="admin-stat-icon exams-icon">◇</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value">{stats?.totalExams ?? '—'}</div> <div className="admin-stat-value">
{stats?.totalExams ?? "—"}
</div>
<div className="admin-stat-label">Saved Exams</div> <div className="admin-stat-label">Saved Exams</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon results-icon">✦</div> <div className="admin-stat-icon results-icon">✦</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value">{stats?.totalResults ?? '—'}</div> <div className="admin-stat-value">
{stats?.totalResults ?? "—"}
</div>
<div className="admin-stat-label">Exams Taken</div> <div className="admin-stat-label">Exams Taken</div>
</div> </div>
</div> </div>
@@ -214,7 +275,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-icon avg-icon">%</div> <div className="admin-stat-icon avg-icon">%</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value"> <div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : '—'} {stats ? `${stats.averageScore}%` : "—"}
</div> </div>
<div className="admin-stat-label">Avg. Score</div> <div className="admin-stat-label">Avg. Score</div>
</div> </div>
@@ -230,7 +291,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-empty">No recent activity yet</div> <div className="admin-empty">No recent activity yet</div>
)} )}
{stats?.recentResults?.map((r) => { {stats?.recentResults?.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0 const pct = r.total_points
? Math.round((r.score_points / r.total_points) * 100)
: 0;
return ( return (
<div key={r.id} className="admin-activity-row"> <div key={r.id} className="admin-activity-row">
<div className="admin-activity-user"> <div className="admin-activity-user">
@@ -238,15 +301,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{r.display_name.charAt(0).toUpperCase()} {r.display_name.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<div className="admin-activity-name">{r.display_name}</div> <div className="admin-activity-name">
{r.display_name}
</div>
<div className="admin-activity-email">{r.email}</div> <div className="admin-activity-email">{r.email}</div>
</div> </div>
</div> </div>
<div className="admin-activity-exam">{r.title}</div> <div className="admin-activity-exam">{r.title}</div>
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div> <div
<div className="admin-activity-date">{formatDate(r.completed_at)}</div> className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
>
{pct}%
</div>
<div className="admin-activity-date">
{formatDate(r.completed_at)}
</div>
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
@@ -254,7 +325,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)} )}
{/* ─── Settings Tab ─── */} {/* ─── Settings Tab ─── */}
{tab === 'settings' && settingsDraft && ( {tab === "settings" && settingsDraft && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
{saveMsg && ( {saveMsg && (
<div className="admin-alert admin-alert-success">{saveMsg}</div> <div className="admin-alert admin-alert-success">{saveMsg}</div>
@@ -266,21 +337,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
<div className="admin-panel-body admin-settings-form"> <div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-site-name"> <label
className="admin-setting-label"
htmlFor="setting-site-name"
>
<span className="admin-setting-title">Site Name</span> <span className="admin-setting-title">Site Name</span>
<span className="admin-setting-desc">The name displayed throughout the application</span> <span className="admin-setting-desc">
The name displayed throughout the application
</span>
</label> </label>
<input <input
id="setting-site-name" id="setting-site-name"
className="input admin-setting-input" className="input admin-setting-input"
value={settingsDraft.site_name} value={settingsDraft.site_name}
onChange={(e) => updateDraft('site_name', e.target.value)} onChange={(e) => updateDraft("site_name", e.target.value)}
/> />
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-max-upload"> <label
<span className="admin-setting-title">Max Upload Size (MB)</span> className="admin-setting-label"
<span className="admin-setting-desc">Maximum file size for exam JSON uploads</span> htmlFor="setting-max-upload"
>
<span className="admin-setting-title">
Max Upload Size (MB)
</span>
<span className="admin-setting-desc">
Maximum file size for exam JSON uploads
</span>
</label> </label>
<input <input
id="setting-max-upload" id="setting-max-upload"
@@ -289,23 +372,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
min="1" min="1"
max="50" max="50"
value={settingsDraft.max_upload_mb} value={settingsDraft.max_upload_mb}
onChange={(e) => updateDraft('max_upload_mb', e.target.value)} onChange={(e) => updateDraft("max_upload_mb", e.target.value)}
/> />
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-default-theme"> <label
className="admin-setting-label"
htmlFor="setting-default-theme"
>
<span className="admin-setting-title">Default Theme</span> <span className="admin-setting-title">Default Theme</span>
<span className="admin-setting-desc">Default theme for new visitors</span> <span className="admin-setting-desc">
Default theme for new visitors
</span>
</label> </label>
<div className="admin-setting-toggle-group"> <div className="admin-setting-toggle-group">
<button <button
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`} className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
onClick={() => updateDraft('default_theme', 'light')} onClick={() => updateDraft("default_theme", "light")}
>☀ Light</button> >
☀ Light
</button>
<button <button
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`} className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft('default_theme', 'dark')} onClick={() => updateDraft("default_theme", "dark")}
>☾ Dark</button> >
☾ Dark
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -319,27 +411,47 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Allow Guest Mode</span> <span className="admin-setting-title">Allow Guest Mode</span>
<span className="admin-setting-desc">Let unauthenticated users take exams without signing in</span> <span className="admin-setting-desc">
Let unauthenticated users take exams without signing in
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.allow_guest_mode === "true" ? " on" : ""}`}
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"allow_guest_mode",
settingsDraft.allow_guest_mode === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.allow_guest_mode === 'true'} aria-checked={settingsDraft.allow_guest_mode === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Registration Enabled</span> <span className="admin-setting-title">
<span className="admin-setting-desc">Allow new users to create accounts</span> Registration Enabled
</span>
<span className="admin-setting-desc">
Allow new users to create accounts
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.registration_enabled === "true" ? " on" : ""}`}
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"registration_enabled",
settingsDraft.registration_enabled === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.registration_enabled === 'true'} aria-checked={settingsDraft.registration_enabled === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
@@ -347,6 +459,186 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
</div> </div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Email / SMTP</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-app-url"
>
<span className="admin-setting-title">App URL</span>
<span className="admin-setting-desc">
Base URL used in email links (e.g.
https://brainwave.example.com)
</span>
</label>
<input
id="setting-app-url"
className="input admin-setting-input"
value={settingsDraft.app_url}
onChange={(e) => updateDraft("app_url", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-host"
>
<span className="admin-setting-title">SMTP Host</span>
<span className="admin-setting-desc">
Hostname of your SMTP server
</span>
</label>
<input
id="setting-smtp-host"
className="input admin-setting-input"
placeholder="smtp.example.com"
value={settingsDraft.smtp_host}
onChange={(e) => updateDraft("smtp_host", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-port"
>
<span className="admin-setting-title">SMTP Port</span>
<span className="admin-setting-desc">
Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)
</span>
</label>
<input
id="setting-smtp-port"
className="input admin-setting-input"
type="number"
min="1"
max="65535"
value={settingsDraft.smtp_port}
onChange={(e) => updateDraft("smtp_port", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-user"
>
<span className="admin-setting-title">Username</span>
<span className="admin-setting-desc">
SMTP authentication username
</span>
</label>
<input
id="setting-smtp-user"
className="input admin-setting-input"
autoComplete="off"
value={settingsDraft.smtp_user}
onChange={(e) => updateDraft("smtp_user", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-pass"
>
<span className="admin-setting-title">Password</span>
<span className="admin-setting-desc">
SMTP authentication password
</span>
</label>
<input
id="setting-smtp-pass"
className="input admin-setting-input"
type="password"
autoComplete="new-password"
value={settingsDraft.smtp_pass}
onChange={(e) => updateDraft("smtp_pass", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-from"
>
<span className="admin-setting-title">From Address</span>
<span className="admin-setting-desc">
The sender name and address shown in emails
</span>
</label>
<input
id="setting-smtp-from"
className="input admin-setting-input"
placeholder="BrainWave <noreply@example.com>"
value={settingsDraft.smtp_from}
onChange={(e) => updateDraft("smtp_from", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">TLS Mode</span>
<span className="admin-setting-desc">
Auto detects from port (465→SSL). Use explicit override if
needed.
</span>
</label>
<div className="admin-setting-toggle-group">
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "auto")}
>
Auto
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "true")}
>
Force TLS
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "false")}
>
Off
</button>
</div>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">Send Test Email</span>
<span className="admin-setting-desc">
Sends a test email using the settings above (uses draft
values, no need to save first)
</span>
</div>
<div className="admin-test-email-row">
<input
className="input admin-setting-input"
placeholder="Recipient address (defaults to your account email)"
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
type="email"
/>
<button
className="btn btn-sm"
onClick={() => void sendTestEmail()}
disabled={testEmailSending || !settingsDraft?.smtp_host}
>
{testEmailSending ? "Sending…" : "Send Test"}
</button>
</div>
{testEmailMsg && (
<div
className={`admin-alert ${testEmailMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
>
{testEmailMsg.text}
</div>
)}
</div>
</div>
</div>
<div className="admin-panel"> <div className="admin-panel">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>Maintenance</h3> <h3>Maintenance</h3>
@@ -355,28 +647,46 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Maintenance Mode</span> <span className="admin-setting-title">Maintenance Mode</span>
<span className="admin-setting-desc">Take the site offline for non-admin users</span> <span className="admin-setting-desc">
Take the site offline for non-admin users
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.maintenance_mode === "true" ? " on" : ""}`}
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"maintenance_mode",
settingsDraft.maintenance_mode === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.maintenance_mode === 'true'} aria-checked={settingsDraft.maintenance_mode === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
</div> </div>
{settingsDraft.maintenance_mode === 'true' && ( {settingsDraft.maintenance_mode === "true" && (
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-maint-msg"> <label
<span className="admin-setting-title">Maintenance Message</span> className="admin-setting-label"
<span className="admin-setting-desc">Message displayed to visitors during maintenance</span> htmlFor="setting-maint-msg"
>
<span className="admin-setting-title">
Maintenance Message
</span>
<span className="admin-setting-desc">
Message displayed to visitors during maintenance
</span>
</label> </label>
<textarea <textarea
id="setting-maint-msg" id="setting-maint-msg"
className="textarea admin-setting-textarea" className="textarea admin-setting-textarea"
value={settingsDraft.maintenance_message} value={settingsDraft.maintenance_message}
onChange={(e) => updateDraft('maintenance_message', e.target.value)} onChange={(e) =>
updateDraft("maintenance_message", e.target.value)
}
rows={3} rows={3}
/> />
</div> </div>
@@ -397,14 +707,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void saveSettings()} onClick={() => void saveSettings()}
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
> >
{saving ? 'Saving…' : 'Save Settings'} {saving ? "Saving…" : "Save Settings"}
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ─── Users Tab ─── */} {/* ─── Users Tab ─── */}
{tab === 'users' && ( {tab === "users" && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
<div className="admin-users-toolbar"> <div className="admin-users-toolbar">
<form onSubmit={handleUserSearch} className="admin-search-form"> <form onSubmit={handleUserSearch} className="admin-search-form">
@@ -414,9 +724,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
value={userSearch} value={userSearch}
onChange={(e) => setUserSearch(e.target.value)} onChange={(e) => setUserSearch(e.target.value)}
/> />
<button className="btn btn-sm" type="submit">Search</button> <button className="btn btn-sm" type="submit">
Search
</button>
</form> </form>
<div className="admin-users-count mono">{usersTotal} user{usersTotal !== 1 ? 's' : ''}</div> <div className="admin-users-count mono">
{usersTotal} user{usersTotal !== 1 ? "s" : ""}
</div>
</div> </div>
<div className="admin-panel"> <div className="admin-panel">
@@ -442,7 +756,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{u.display_name.charAt(0).toUpperCase()} {u.display_name.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<div className="admin-user-name">{u.display_name}</div> <div className="admin-user-name">
{u.display_name}
</div>
<div className="admin-user-email">{u.email}</div> <div className="admin-user-email">{u.email}</div>
</div> </div>
</div> </div>
@@ -453,25 +769,44 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</span> </span>
</td> </td>
<td> <td>
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} /> <span
className={`admin-verified-dot ${u.email_verified ? "yes" : "no"}`}
/>
</td> </td>
<td className="mono">{u.exams_count}</td> <td className="mono">{u.exams_count}</td>
<td className="mono">{u.results_count}</td> <td className="mono">{u.results_count}</td>
<td className="admin-date-cell">{formatDate(u.created_at)}</td> <td className="admin-date-cell">
{formatDate(u.created_at)}
</td>
<td> <td>
<div className="admin-actions"> <div className="admin-actions">
<select <select
className="admin-role-select" className="admin-role-select"
value={u.role} value={u.role}
onChange={(e) => void changeRole(u.id, e.target.value as 'user' | 'admin')} onChange={(e) =>
void changeRole(
u.id,
e.target.value as "user" | "admin",
)
}
> >
<option value="user">User</option> <option value="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
{deleteConfirm === u.id ? ( {deleteConfirm === u.id ? (
<div className="admin-delete-confirm"> <div className="admin-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteUser(u.id)}>Confirm</button> <button
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>Cancel</button> className="btn btn-sm admin-btn-danger"
onClick={() => void deleteUser(u.id)}
>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</button>
</div> </div>
) : ( ) : (
<button <button
@@ -488,7 +823,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
))} ))}
{users.length === 0 && ( {users.length === 0 && (
<tr> <tr>
<td colSpan={7} className="admin-empty">No users found</td> <td colSpan={7} className="admin-empty">
No users found
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -498,5 +835,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
)} )}
</div> </div>
) );
} }
+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 ?? '').replace(/^q/, '')) || 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>
) );
} }
+1766 -1267
View File
File diff suppressed because it is too large. Load diff
+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,
}, },
}) });