Compare commits

...
14 Commits
Author SHA1 Message Date
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
LaurensBartelds 13bebba16e feat: add Gitea workflow for automated release building and asset publishing
Release Pipeline / build-and-release (push) Failing after 5m13s
2026-04-24 16:05:53 +02:00
LaurensBartelds 98b9d51d83 feat: add Gitea Actions workflow for automated multi-arch Docker image releases
Release Pipeline / build-and-release (push) Failing after 4s
2026-04-24 16:01:32 +02:00
LaurensBartelds 5b7d31cb34 feat: add Gitea Actions release pipeline to build and publish Docker images
Release Pipeline / build-and-release (push) Failing after 5m56s
2026-04-24 15:52:03 +02:00
22 changed files with 4408 additions and 1964 deletions

No files matched your search

+11 -1
View File
@@ -23,7 +23,17 @@
"Bash(xargs git *)",
"Bash(awk '{print $2}')",
"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
run: pip3 install --quiet --break-system-packages "ansible>=9,<11"
- name: Write SSH Key
- name: Setup SSH Agent
run: |
printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" > /tmp/ansible_key
chmod 600 /tmp/ansible_key
eval "$(ssh-agent -s)"
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
# 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.
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_USER }}" > /tmp/inventory.ini
@@ -54,8 +57,8 @@ jobs:
-i /tmp/inventory.ini \
-e "artifact_path=${WORKSPACE}"
- name: Cleanup Secrets
- name: Cleanup
if: always()
run: |
rm -f /tmp/ansible_key
ssh-agent -k || true
rm -f /tmp/inventory.ini
+42 -10
View File
@@ -3,11 +3,13 @@ name: Release Pipeline
on:
push:
tags:
- 'v*'
- "v*"
jobs:
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
@@ -15,7 +17,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: "20"
- name: Install Frontend Dependencies
working-directory: ./frontend
@@ -81,12 +83,42 @@ jobs:
EOF
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
brainwave-amd64.tar.gz
brainwave-arm64.tar.gz
docker-compose.amd64.yml
docker-compose.arm64.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# 1. Create the release
echo "Creating release for tag: ${TAG}"
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\", \"name\":\"Release ${TAG}\", \"body\":\"Automated release for version ${TAG}\", \"draft\":false, \"prerelease\":false}" \
"${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')))")
if [ "$RELEASE_ID" = "ERROR" ] || [ "$RELEASE_ID" = "release already exists for this tag" ]; then
echo "Release might already exist, attempting to find it..."
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases" \
| python3 -c "import sys, json; releases=json.load(sys.stdin); print(next((r['id'] for r in releases if r['tag_name'] == '${TAG}'), 'ERROR'))")
fi
if [ "$RELEASE_ID" = "ERROR" ]; then
echo "Failed to create or find release: $RELEASE_RESPONSE"
exit 1
fi
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
echo "Uploading ${FILE}..."
UPLOAD_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${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)" \
|| { echo "Failed to upload ${FILE}"; echo "Response: ${UPLOAD_RESPONSE}"; exit 1; }
done
echo "Release completed successfully!"
+1
View File
@@ -1,3 +1,4 @@
backend/brainwave.db-shm
backend/brainwave.db-wal
.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 {
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_exam_results_user ON exam_results(user_id);
`)
`);
// ── Add role column if missing (existing databases) ──
const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
if (!cols.some(c => c.name === 'role')) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
name: string;
}[];
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 ──
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) {
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 ──
const defaults: Record<string, string> = {
site_name: 'BrainWave',
max_upload_mb: '5',
allow_guest_mode: 'true',
registration_enabled: 'true',
default_theme: 'light',
maintenance_mode: 'false',
maintenance_message: 'We are currently performing maintenance. Please check back soon.',
}
site_name: "BrainWave",
max_upload_mb: "5",
allow_guest_mode: "true",
registration_enabled: "true",
default_theme: "light",
maintenance_mode: "false",
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(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING
`)
`);
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)) {
console.log(`Serving frontend from: ${frontendDistPath}`)
app.use(express.static(frontendDistPath))
app.get('*', (req, res) => {
app.get('*path', (req, res) => {
if (!req.path.startsWith('/api')) {
res.sendFile(path.join(frontendDistPath, 'index.html'))
} else {
+177 -99
View File
@@ -1,30 +1,44 @@
import { Router } from 'express'
import { getDb } from '../db/database.js'
import { requireAuth, requireAdmin } from '../middleware/auth.js'
import { Router } from "express";
import { getDb } from "../db/database.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
router.use(requireAuth, requireAdmin)
router.use(requireAuth, requireAdmin);
// ─── Dashboard Stats ─────────────────────────────────────
router.get('/stats', (_req, res) => {
router.get("/stats", (_req, res) => {
try {
const db = getDb()
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
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(`
const db = getDb();
const users = db.prepare("SELECT COUNT(*) AS cnt FROM users").get() as {
cnt: number;
};
const exams = db
.prepare("SELECT COUNT(*) AS cnt FROM saved_exams")
.get() as { cnt: number };
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,
u.display_name, u.email
FROM exam_results er
JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC
LIMIT 10
`).all()
`,
)
.all();
res.json({
totalUsers: users.cnt,
@@ -32,159 +46,223 @@ router.get('/stats', (_req, res) => {
totalResults: results.cnt,
averageScore: Math.round(avgScore.avg * 10) / 10,
recentResults,
})
});
} catch (err) {
console.error('Admin stats error:', err)
res.status(500).json({ error: 'Internal server error' })
console.error("Admin stats error:", err);
res.status(500).json({ error: "Internal server error" });
}
})
});
// ─── Get All Settings ────────────────────────────────────
router.get('/settings', (_req, res) => {
router.get("/settings", (_req, res) => {
try {
const db = getDb()
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[]
const settings: Record<string, string> = {}
const db = getDb();
const rows = db
.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) {
settings[row.key] = row.value
settings[row.key] = row.value;
}
res.json({ settings })
res.json({ settings });
} catch (err) {
console.error('Admin get settings error:', err)
res.status(500).json({ error: 'Internal server error' })
console.error("Admin get settings error:", err);
res.status(500).json({ error: "Internal server error" });
}
})
});
// ─── Update Settings ─────────────────────────────────────
router.put('/settings', (req, res) => {
router.put("/settings", (req, res) => {
try {
const { settings } = req.body as { settings: Record<string, string> }
if (!settings || typeof settings !== 'object') {
res.status(400).json({ error: 'Settings object is required' })
return
const { settings } = req.body as { settings: Record<string, string> };
if (!settings || typeof settings !== "object") {
res.status(400).json({ error: "Settings object is required" });
return;
}
const validKeys = [
'site_name', 'max_upload_mb', 'allow_guest_mode',
'registration_enabled', 'default_theme', 'maintenance_mode',
'maintenance_message',
]
"site_name",
"max_upload_mb",
"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(`
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
`)
`);
const updateMany = db.transaction((entries: [string, string][]) => {
for (const [key, value] of entries) {
if (!validKeys.includes(key)) continue
update.run(String(value), key)
if (!validKeys.includes(key)) continue;
update.run(String(value), key);
}
})
});
updateMany(Object.entries(settings))
res.json({ message: 'Settings updated successfully' })
updateMany(Object.entries(settings));
res.json({ message: "Settings updated successfully" });
} catch (err) {
console.error('Admin update settings error:', err)
res.status(500).json({ error: 'Internal server error' })
console.error("Admin update settings error:", err);
res.status(500).json({ error: "Internal server error" });
}
})
});
// ─── List All Users ──────────────────────────────────────
router.get('/users', (req, res) => {
router.get("/users", (req, res) => {
try {
const limit = Math.min(Number(req.query['limit']) || 50, 200)
const offset = Number(req.query['offset']) || 0
const search = (req.query['search'] as string) || ''
const limit = Math.min(Number(req.query["limit"]) || 50, 200);
const offset = Number(req.query["offset"]) || 0;
const search = (req.query["search"] as string) || "";
const db = getDb()
const db = getDb();
let query = `
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 saved_exams WHERE user_id = u.id) AS exams_count
FROM users u
`
let countQuery = 'SELECT COUNT(*) AS total FROM users'
const params: unknown[] = []
const countParams: unknown[] = []
`;
let countQuery = "SELECT COUNT(*) AS total FROM users";
const params: unknown[] = [];
const countParams: unknown[] = [];
if (search) {
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`
query += where
countQuery += where.replace('u.', '')
params.push(`%${search}%`, `%${search}%`)
countParams.push(`%${search}%`, `%${search}%`)
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`;
query += where;
countQuery += where.replace("u.", "");
params.push(`%${search}%`, `%${search}%`);
countParams.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
query += " ORDER BY u.created_at DESC LIMIT ? OFFSET ?";
params.push(limit, offset);
const users = db.prepare(query).all(...params)
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
const users = db.prepare(query).all(...params);
const countRow = db.prepare(countQuery).get(...countParams) as {
total: number;
};
res.json({ users, total: countRow.total })
res.json({ users, total: countRow.total });
} catch (err) {
console.error('Admin list users error:', err)
res.status(500).json({ error: 'Internal server error' })
console.error("Admin list users error:", err);
res.status(500).json({ error: "Internal server error" });
}
})
});
// ─── Change User Role ────────────────────────────────────
router.patch('/users/:id/role', (req, res) => {
router.patch("/users/:id/role", (req, res) => {
try {
const userId = Number(req.params['id'])
const { role } = req.body as { role: string }
const userId = Number(req.params["id"]);
const { role } = req.body as { role: string };
if (!['user', 'admin'].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' })
return
if (!["user", "admin"].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' });
return;
}
// Prevent self-demotion
if (userId === req.user!.userId && role !== 'admin') {
res.status(400).json({ error: 'You cannot remove your own admin role' })
return
if (userId === req.user!.userId && role !== "admin") {
res.status(400).json({ error: "You cannot remove your own admin role" });
return;
}
const db = getDb()
const result = db.prepare('UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?').run(role, userId)
const db = getDb();
const result = db
.prepare(
"UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?",
)
.run(role, userId);
if (result.changes === 0) {
res.status(404).json({ error: 'User not found' })
return
res.status(404).json({ error: "User not found" });
return;
}
res.json({ message: `User role updated to ${role}` })
res.json({ message: `User role updated to ${role}` });
} catch (err) {
console.error('Admin change role error:', err)
res.status(500).json({ error: 'Internal server error' })
console.error("Admin change role error:", err);
res.status(500).json({ error: "Internal server error" });
}
})
});
// ─── Delete User ─────────────────────────────────────────
router.delete('/users/:id', (req, res) => {
router.delete("/users/:id", (req, res) => {
try {
const userId = Number(req.params['id'])
const userId = Number(req.params["id"]);
// Prevent self-deletion
if (userId === req.user!.userId) {
res.status(400).json({ error: 'You cannot delete your own account' })
return
res.status(400).json({ error: "You cannot delete your own account" });
return;
}
const db = getDb()
const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId)
const db = getDb();
const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
if (result.changes === 0) {
res.status(404).json({ error: 'User not found' })
return
res.status(404).json({ error: "User not found" });
return;
}
res.json({ message: 'User deleted' })
res.json({ message: "User deleted" });
} catch (err) {
console.error('Admin delete user error:', err)
res.status(500).json({ error: 'Internal server error' })
console.error("Admin delete user error:", err);
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']
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587')
const SMTP_USER = process.env['SMTP_USER']
const SMTP_PASS = process.env['SMTP_PASS']
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>'
const isDev = !SMTP_HOST
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
export interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
}
async function sendMail(to: string, subject: string, html: string): Promise<void> {
const t = getTransporter()
if (!t) {
console.log('\n══════════════════════════════════════════════════')
console.log(`📧 EMAIL (dev mode) → ${to}`)
console.log(` Subject: ${subject}`)
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`)
console.log('══════════════════════════════════════════════════\n')
return
// ─── App URL ──────────────────────────────────────────────────────────────────
export function getAppUrl(): string {
try {
const db = getDb();
const row = db
.prepare("SELECT value FROM site_settings WHERE key = ?")
.get("app_url") as { value: string } | undefined;
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> {
const link = `${APP_URL}/verify?token=${token}`
await sendMail(to, 'Verify your BrainWave account', `
// ─── SMTP Config ──────────────────────────────────────────────────────────────
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;">
<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;">
@@ -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.
</p>
</div>
`)
`,
);
}
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> {
const link = `${APP_URL}/reset-password?token=${token}`
await sendMail(to, 'Reset your BrainWave password', `
export async function sendPasswordResetEmail(
to: string,
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;">
<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;">
@@ -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.
</p>
</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",
"katex": "^0.16.45",
"react": "^19.2.5",
"react-dom": "^19.2.5"
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
"@babel/core": "^7.29.0",
@@ -1791,6 +1792,19 @@
"dev": true,
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3075,6 +3089,44 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3142,6 +3194,12 @@
"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": {
"version": "2.0.0",
"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",
"katex": "^0.16.45",
"react": "^19.2.5",
"react-dom": "^19.2.5"
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
"@babel/core": "^7.29.0",
+331 -155
View File
@@ -1,57 +1,73 @@
import { useState, useEffect, useCallback } from 'react'
import type { AppScreen, 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 { AuthProvider, useAuth } from './contexts/AuthContext'
import Landing from './components/Landing'
import ExamRunner from './components/ExamRunner'
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'
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import {
useNavigate,
useLocation,
Routes,
Route,
Navigate,
} from "react-router-dom";
import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
import { gradeQuestion } from "./utils/grading";
import {
loadState,
saveState,
clearState,
loadHistory,
appendHistory,
clearHistory,
} from "./utils/storage";
import { apiFetch, isAuthenticated } from "./utils/api";
import { slugify } from "./utils/slug";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import Landing from "./components/Landing";
import ExamRoute from "./components/ExamRoute";
import Results from "./components/Results";
import TweaksPanel from "./components/TweaksPanel";
import AuthModal from "./components/AuthModal";
import EmailVerify from "./components/EmailVerify";
import ResetPassword from "./components/ResetPassword";
import AdminDashboard from "./components/AdminDashboard";
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
const DEFAULT_TWEAKS: Tweaks = {
theme: "light",
fontSize: "medium",
feedbackMode: "auto",
};
const withNotes = (state: ExamState): ExamState => ({
...state,
notes: state.notes ?? '',
})
notes: state.notes ?? "",
});
function AppInner() {
const { user, loading: authLoading, logout } = useAuth()
const [screen, setScreen] = useState<AppScreen>('landing')
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)
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);
// 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(() => {
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')
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),
@@ -64,75 +80,115 @@ function AppInner() {
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,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: "",
startedAt: 0,
feedbackMode: "end" as const,
} as ExamState,
_remoteId: r.id,
}))
setHistory(apiHistory)
}));
setHistory(apiHistory);
})
.catch(() => {
// Fall back to localStorage
setHistory(loadHistory())
})
setHistory(loadHistory());
});
}
}, [user, authLoading])
}, [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])
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 (screen === 'exam' && exam && examState) saveState({ exam, state: examState })
}, [exam, examState, screen])
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 }))
setTweaks((t) => ({ ...t, [key]: value }));
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => {
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (
nextState,
) => {
setExamState((prev) => {
if (!prev) return prev
return typeof nextState === 'function'
if (!prev) return prev;
return typeof nextState === "function"
? (nextState as (prevState: ExamState) => ExamState)(prev)
: nextState
})
}
: 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 })
setScreen('exam')
}
const fm =
tweaks.feedbackMode === "auto"
? (data.feedbackMode ?? "end")
: tweaks.feedbackMode;
flushSync(() => {
setExam(data);
setExamState({
currentIdx: 0,
answers: {},
flags: {},
notes: "",
revealed: {},
startedAt: Date.now(),
feedbackMode: fm,
});
});
navigate(`/exam/${slugify(data.title)}/q1`);
};
const resume = () => {
const saved = loadState()
if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(withNotes(saved.state)); setScreen('exam') }
}
const saved = loadState();
if (saved?.exam && saved?.state) {
flushSync(() => {
setExam(saved.exam);
setExamState(withNotes(saved.state));
});
navigate(
`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`,
);
}
};
const submit = useCallback(() => {
if (!exam || !examState) return
const completedAt = Date.now()
const elapsedMs = completedAt - examState.startedAt
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id]))
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,
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 },
}
};
// Save to API if authenticated
if (user && isAuthenticated()) {
void apiFetch('/api/results', {
method: 'POST',
void apiFetch("/api/results", {
method: "POST",
body: JSON.stringify({
title: entry.title,
scorePoints: entry.scorePoints,
@@ -146,59 +202,79 @@ function AppInner() {
exam: entry.exam,
}),
}).catch(() => {
// Fallback: save locally
appendHistory(entry)
})
appendHistory(entry);
});
} else {
appendHistory(entry)
appendHistory(entry);
}
// Always update local state for immediate display
setHistory((prev) => [entry, ...prev])
clearState()
setExamState((s) => s ? { ...s, completedAt } : s)
setScreen('results')
}, [exam, examState, user])
setHistory((prev) => [entry, ...prev]);
clearState();
setExamState((s) => (s ? { ...s, completedAt } : s));
navigate("/results");
}, [exam, examState, user, navigate]);
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) }
const restart = () => { if (exam) loadExam(exam) }
const exit = () => {
setExam(null);
setExamState(null);
navigate("/");
};
const restart = () => {
if (exam) loadExam(exam);
};
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()) {
void apiFetch<{
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number;
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number;
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))
setScreen('review')
flushSync(() => {
setExam(data.exam);
setExamState(withNotes(data.state));
});
navigate(`/review/${slugify(entry.title)}`);
})
.catch(() => {
// Fallback to local data
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review')
})
flushSync(() => {
setExam(entry.exam);
setExamState(withNotes(entry.state));
});
navigate(`/review/${slugify(entry.title)}`);
});
} 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 = () => {
if (confirm('Clear all past attempts?')) {
clearHistory()
setHistory([])
if (confirm("Clear all past attempts?")) {
clearHistory();
setHistory([]);
}
}
};
const handleAuthFlowDone = () => {
globalThis.history.replaceState({}, '', '/')
setScreen('landing')
setAuthModalOpen(true)
}
navigate("/");
setAuthModalOpen(true);
};
if (screen === 'verify') {
if (pathname === "/verify") {
return (
<div className="shell">
<header className="topbar">
@@ -209,10 +285,10 @@ function AppInner() {
</header>
<EmailVerify onDone={handleAuthFlowDone} />
</div>
)
);
}
if (screen === 'reset-password') {
if (pathname === "/reset-password") {
return (
<div className="shell">
<header className="topbar">
@@ -223,7 +299,7 @@ function AppInner() {
</header>
<ResetPassword onDone={handleAuthFlowDone} />
</div>
)
);
}
return (
@@ -235,24 +311,47 @@ function AppInner() {
className="brand-name"
onClick={exit}
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
</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 className="topbar-meta">
{screen === 'exam' && exam && (
{pathname.startsWith("/exam/") && exam && (
<>
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
<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>
<button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
>
⚙
</button>
{/* Auth buttons */}
{!authLoading && !user && (
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
<button
className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)}
>
Sign In
</button>
)}
@@ -266,48 +365,125 @@ function AppInner() {
{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); setScreen('admin'); globalThis.history.pushState({}, '', '/admin') }}>
Admin Dashboard
</button>
)}
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
Sign Out
</button>
<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>
{screen === 'landing' && (
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
<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)}
/>
)}
{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 && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => setScreen('reset-password')}
onForgotPassword={() => navigate("/reset-password")}
/>
)}
</div>
)
);
}
export default function App() {
@@ -315,5 +491,5 @@ export default function App() {
<AuthProvider>
<AppInner />
</AuthProvider>
)
);
}
+492 -155
View File
@@ -1,155 +1,208 @@
import { useState, useEffect, useCallback } from 'react'
import { apiFetch } from '../utils/api'
import { useState, useEffect, useCallback } from "react";
import { apiFetch } from "../utils/api";
interface SiteSettings {
site_name: string
max_upload_mb: string
allow_guest_mode: string
registration_enabled: string
default_theme: string
maintenance_mode: string
maintenance_message: string
site_name: string;
max_upload_mb: string;
allow_guest_mode: string;
registration_enabled: string;
default_theme: string;
maintenance_mode: 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 {
totalUsers: number
totalExams: number
totalResults: number
averageScore: number
totalUsers: number;
totalExams: number;
totalResults: number;
averageScore: number;
recentResults: {
id: number
title: string
score_points: number
total_points: number
completed_at: number
display_name: string
email: string
}[]
id: number;
title: string;
score_points: number;
total_points: number;
completed_at: number;
display_name: string;
email: string;
}[];
}
interface AdminUser {
id: number
email: string
display_name: string
role: 'user' | 'admin'
email_verified: number
created_at: number
results_count: number
exams_count: number
id: number;
email: string;
display_name: string;
role: "user" | "admin";
email_verified: number;
created_at: number;
results_count: number;
exams_count: number;
}
type AdminTab = 'overview' | 'settings' | 'users'
type AdminTab = "overview" | "settings" | "users";
interface Props {
onExit: () => void
onExit: () => void;
}
export default function AdminDashboard({ onExit }: Readonly<Props>) {
const [tab, setTab] = useState<AdminTab>('overview')
const [stats, setStats] = useState<DashboardStats | null>(null)
const [settings, setSettings] = useState<SiteSettings | null>(null)
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null)
const [users, setUsers] = useState<AdminUser[]>([])
const [usersTotal, setUsersTotal] = useState(0)
const [userSearch, setUserSearch] = useState('')
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [tab, setTab] = useState<AdminTab>("overview");
const [stats, setStats] = useState<DashboardStats | null>(null);
const [settings, setSettings] = useState<SiteSettings | null>(null);
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState("");
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [error, setError] = useState<string | 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 () => {
try {
const data = await apiFetch<DashboardStats>('/api/admin/stats')
setStats(data)
const data = await apiFetch<DashboardStats>("/api/admin/stats");
setStats(data);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
}
}, [])
}, []);
const loadSettings = useCallback(async () => {
try {
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings')
setSettings(data.settings)
setSettingsDraft(data.settings)
const data = await apiFetch<{ settings: SiteSettings }>(
"/api/admin/settings",
);
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
}
}, [])
}, []);
const loadUsers = useCallback(async (search = '') => {
const loadUsers = useCallback(async (search = "") => {
try {
const q = search ? `?search=${encodeURIComponent(search)}` : ''
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`)
setUsers(data.users)
setUsersTotal(data.total)
const q = search ? `?search=${encodeURIComponent(search)}` : "";
const data = await apiFetch<{ users: AdminUser[]; total: number }>(
`/api/admin/users${q}`,
);
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
}
}, [])
}, []);
useEffect(() => {
void loadStats()
void loadSettings()
void loadUsers()
}, [loadStats, loadSettings, loadUsers])
void loadStats();
void loadSettings();
void loadUsers();
}, [loadStats, loadSettings, loadUsers]);
const saveSettings = async () => {
if (!settingsDraft) return
setSaving(true)
setSaveMsg(null)
if (!settingsDraft) return;
setSaving(true);
setSaveMsg(null);
try {
await apiFetch('/api/admin/settings', {
method: 'PUT',
await apiFetch("/api/admin/settings", {
method: "PUT",
body: JSON.stringify({ settings: settingsDraft }),
})
setSettings(settingsDraft)
setSaveMsg('Settings saved successfully')
setTimeout(() => setSaveMsg(null), 3000)
});
setSettings(settingsDraft);
setSaveMsg("Settings saved successfully");
setTimeout(() => setSaveMsg(null), 3000);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
} finally {
setSaving(false)
setSaving(false);
}
}
};
const changeRole = async (userId: number, newRole: 'user' | 'admin') => {
const changeRole = async (userId: number, newRole: "user" | "admin") => {
try {
await apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH',
method: "PATCH",
body: JSON.stringify({ role: newRole }),
})
await loadUsers(userSearch)
});
await loadUsers(userSearch);
} catch (e) {
setError((e as Error).message)
setError((e as Error).message);
}
}
};
const deleteUser = async (userId: number) => {
try {
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
setDeleteConfirm(null)
await loadUsers(userSearch)
await loadStats()
await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
setDeleteConfirm(null);
await loadUsers(userSearch);
await loadStats();
} 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) => {
e.preventDefault()
void loadUsers(userSearch)
}
e.preventDefault();
void loadUsers(userSearch);
};
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 d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const d = new Date(ts * 1000);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div className="admin">
@@ -166,19 +219,21 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{error && (
<div className="admin-alert admin-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="admin-alert-close">×</button>
<button onClick={() => setError(null)} className="admin-alert-close">
×
</button>
</div>
)}
<div className="admin-tabs">
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => (
{(["overview", "settings", "users"] as AdminTab[]).map((t) => (
<button
key={t}
className={`admin-tab${tab === t ? ' active' : ''}`}
className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
>
<span className="admin-tab-icon">
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'}
{t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
</span>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
@@ -186,27 +241,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
{/* ─── Overview Tab ─── */}
{tab === 'overview' && (
{tab === "overview" && (
<div className="admin-section fade-in">
<div className="admin-stats-grid">
<div className="admin-stat-card">
<div className="admin-stat-icon users-icon">⊕</div>
<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>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon exams-icon">◇</div>
<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>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon results-icon">✦</div>
<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>
</div>
@@ -214,7 +275,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-icon avg-icon">%</div>
<div className="admin-stat-info">
<div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : '—'}
{stats ? `${stats.averageScore}%` : "—"}
</div>
<div className="admin-stat-label">Avg. Score</div>
</div>
@@ -230,7 +291,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-empty">No recent activity yet</div>
)}
{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 (
<div key={r.id} className="admin-activity-row">
<div className="admin-activity-user">
@@ -238,15 +301,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{r.display_name.charAt(0).toUpperCase()}
</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>
</div>
<div className="admin-activity-exam">{r.title}</div>
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div>
<div className="admin-activity-date">{formatDate(r.completed_at)}</div>
<div
className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
>
{pct}%
</div>
<div className="admin-activity-date">
{formatDate(r.completed_at)}
</div>
</div>
)
);
})}
</div>
</div>
@@ -254,7 +325,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)}
{/* ─── Settings Tab ─── */}
{tab === 'settings' && settingsDraft && (
{tab === "settings" && settingsDraft && (
<div className="admin-section fade-in">
{saveMsg && (
<div className="admin-alert admin-alert-success">{saveMsg}</div>
@@ -266,21 +337,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
<div className="admin-panel-body admin-settings-form">
<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-desc">The name displayed throughout the application</span>
<span className="admin-setting-desc">
The name displayed throughout the application
</span>
</label>
<input
id="setting-site-name"
className="input admin-setting-input"
value={settingsDraft.site_name}
onChange={(e) => updateDraft('site_name', e.target.value)}
onChange={(e) => updateDraft("site_name", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label" 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
className="admin-setting-label"
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>
<input
id="setting-max-upload"
@@ -289,23 +372,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
min="1"
max="50"
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 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-desc">Default theme for new visitors</span>
<span className="admin-setting-desc">
Default theme for new visitors
</span>
</label>
<div className="admin-setting-toggle-group">
<button
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`}
onClick={() => updateDraft('default_theme', 'light')}
>☀ Light</button>
className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "light")}
>
☀ Light
</button>
<button
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`}
onClick={() => updateDraft('default_theme', 'dark')}
>☾ Dark</button>
className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "dark")}
>
☾ Dark
</button>
</div>
</div>
</div>
@@ -319,27 +411,47 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row">
<label className="admin-setting-label">
<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>
<button
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`}
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')}
className={`admin-switch${settingsDraft.allow_guest_mode === "true" ? " on" : ""}`}
onClick={() =>
updateDraft(
"allow_guest_mode",
settingsDraft.allow_guest_mode === "true"
? "false"
: "true",
)
}
role="switch"
aria-checked={settingsDraft.allow_guest_mode === 'true'}
aria-checked={settingsDraft.allow_guest_mode === "true"}
>
<span className="admin-switch-thumb" />
</button>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">Registration Enabled</span>
<span className="admin-setting-desc">Allow new users to create accounts</span>
<span className="admin-setting-title">
Registration Enabled
</span>
<span className="admin-setting-desc">
Allow new users to create accounts
</span>
</label>
<button
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`}
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')}
className={`admin-switch${settingsDraft.registration_enabled === "true" ? " on" : ""}`}
onClick={() =>
updateDraft(
"registration_enabled",
settingsDraft.registration_enabled === "true"
? "false"
: "true",
)
}
role="switch"
aria-checked={settingsDraft.registration_enabled === 'true'}
aria-checked={settingsDraft.registration_enabled === "true"}
>
<span className="admin-switch-thumb" />
</button>
@@ -347,6 +459,186 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</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-header">
<h3>Maintenance</h3>
@@ -355,28 +647,46 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row">
<label className="admin-setting-label">
<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>
<button
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`}
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')}
className={`admin-switch${settingsDraft.maintenance_mode === "true" ? " on" : ""}`}
onClick={() =>
updateDraft(
"maintenance_mode",
settingsDraft.maintenance_mode === "true"
? "false"
: "true",
)
}
role="switch"
aria-checked={settingsDraft.maintenance_mode === 'true'}
aria-checked={settingsDraft.maintenance_mode === "true"}
>
<span className="admin-switch-thumb" />
</button>
</div>
{settingsDraft.maintenance_mode === 'true' && (
{settingsDraft.maintenance_mode === "true" && (
<div className="admin-setting-row">
<label className="admin-setting-label" 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
className="admin-setting-label"
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>
<textarea
id="setting-maint-msg"
className="textarea admin-setting-textarea"
value={settingsDraft.maintenance_message}
onChange={(e) => updateDraft('maintenance_message', e.target.value)}
onChange={(e) =>
updateDraft("maintenance_message", e.target.value)
}
rows={3}
/>
</div>
@@ -397,14 +707,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void saveSettings()}
disabled={saving || !hasChanges}
>
{saving ? 'Saving…' : 'Save Settings'}
{saving ? "Saving…" : "Save Settings"}
</button>
</div>
</div>
)}
{/* ─── Users Tab ─── */}
{tab === 'users' && (
{tab === "users" && (
<div className="admin-section fade-in">
<div className="admin-users-toolbar">
<form onSubmit={handleUserSearch} className="admin-search-form">
@@ -414,9 +724,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
value={userSearch}
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>
<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 className="admin-panel">
@@ -442,7 +756,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{u.display_name.charAt(0).toUpperCase()}
</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>
</div>
@@ -453,25 +769,44 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</span>
</td>
<td>
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} />
<span
className={`admin-verified-dot ${u.email_verified ? "yes" : "no"}`}
/>
</td>
<td className="mono">{u.exams_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>
<div className="admin-actions">
<select
className="admin-role-select"
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="admin">Admin</option>
</select>
{deleteConfirm === u.id ? (
<div className="admin-delete-confirm">
<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>
<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>
) : (
<button
@@ -488,7 +823,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="admin-empty">No users found</td>
<td colSpan={7} className="admin-empty">
No users found
</td>
</tr>
)}
</tbody>
@@ -498,5 +835,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</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) || 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 type { Exam, ExamState, Question } from '../types'
import { gradeQuestion } from '../utils/grading'
import { formatTime } from '../utils/formatting'
import Tex from './Tex'
import QuestionMedia from './QuestionMedia'
import QuestionRenderer from './questions/QuestionRenderer'
import { useState, useEffect, useCallback } from "react";
import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from "../utils/grading";
import { formatTime } from "../utils/formatting";
import Tex from "./Tex";
import QuestionMedia from "./QuestionMedia";
import QuestionRenderer from "./questions/QuestionRenderer";
interface Props {
exam: Exam
state: ExamState
setState: React.Dispatch<React.SetStateAction<ExamState>>
onSubmit: () => void
onExit: () => void
exam: Exam;
state: ExamState;
setState: React.Dispatch<React.SetStateAction<ExamState>>;
onSubmit: () => void;
onExit: () => void;
onNavigate?: (i: number) => void;
}
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly<Props>) {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state
const q = exam.questions[currentIdx]
const total = exam.questions.length
const notes = state.notes ?? ''
export default function ExamRunner({
exam,
state,
setState,
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])
useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed])
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
useEffect(() => {
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
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) =>
setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total])
const goTo = useCallback(
(i: number) => {
const next = Math.max(0, Math.min(total - 1, i));
setState((s) => ({ ...s, currentIdx: next }));
onNavigate?.(next);
},
[total, onNavigate],
);
const toggleFlag = useCallback(() =>
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id])
const toggleFlag = useCallback(
() =>
setState((s) => ({
...s,
flags: { ...s.flags, [q.id]: !s.flags[q.id] },
})),
[q.id],
);
const updateNotes = (value: string) =>
setState((s) => ({ ...s, notes: value.slice(0, 1000) }))
setState((s) => ({ ...s, notes: value.slice(0, 1000) }));
const checkAnswer = () => {
setState((s) => ({
...s,
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(() => {
const t = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(t)
}, [])
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const elapsedMs = now - startedAt
const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null
const timeUrgent = remainingMs != null && remainingMs < 60_000
const elapsedMs = now - startedAt;
const remainingMs = exam.timeLimitMinutes
? exam.timeLimitMinutes * 60_000 - elapsedMs
: null;
const timeUrgent = remainingMs != null && remainingMs < 60_000;
useEffect(() => {
if (remainingMs != null && remainingMs <= 0) onSubmit()
}, [remainingMs, onSubmit])
if (remainingMs != null && remainingMs <= 0) onSubmit();
}, [remainingMs, onSubmit]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1)
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1)
if (e.key === 'f') toggleFlag()
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) {
const ci = Number.parseInt(e.key, 10) - 1
const choices = q.choices
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } }))
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.key === "ArrowRight" || e.key === "j") goTo(currentIdx + 1);
if (e.key === "ArrowLeft" || e.key === "k") goTo(currentIdx - 1);
if (e.key === "f") toggleFlag();
if (/^[1-9]$/.test(e.key) && q.type === "multiple-choice" && q.choices) {
const ci = Number.parseInt(e.key, 10) - 1;
const choices = q.choices;
if (choices[ci])
setState((s) => ({
...s,
answers: { ...s.answers, [q.id]: choices[ci].id },
}));
}
}
globalThis.addEventListener('keydown', handler)
return () => globalThis.removeEventListener('keydown', handler)
}, [currentIdx, q, goTo, toggleFlag, setState])
};
globalThis.addEventListener("keydown", handler);
return () => globalThis.removeEventListener("keydown", handler);
}, [currentIdx, q, goTo, toggleFlag, setState]);
const graded = feedbackMode === 'immediate' && revealed
const answered = 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 graded = feedbackMode === "immediate" && revealed;
const answered =
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 => {
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) {
const r = gradeQuestion(qq, answers[qq.id])
if (r.status === 'correct') return 'correct'
if (r.status === 'wrong') return 'wrong'
if (feedbackMode === "immediate" && state.revealed?.[qq.id]) {
const r = gradeQuestion(qq, answers[qq.id]);
if (r.status === "correct") return "correct";
if (r.status === "wrong") return "wrong";
// fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay)
}
if (flags[qq.id]) return 'flagged'
const a = answers[qq.id]
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered'
return ''
}
if (flags[qq.id]) return "flagged";
const a = answers[qq.id];
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0))
return "answered";
return "";
};
const answeredCount = exam.questions.filter((qq) => {
const st = navStatus(qq)
return st === 'answered' || st === 'correct' || st === 'wrong'
}).length
const st = navStatus(qq);
return st === "answered" || st === "correct" || st === "wrong";
}).length;
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0)
let answerText = ''
if (!Array.isArray(q.answer) && typeof q.answer === 'string') {
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer
const totalPossible = exam.questions.reduce(
(acc, qq) => acc + (qq.points ?? 1),
0,
);
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)) {
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 (
@@ -113,78 +149,155 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
<div className="sidebar-section-label">Questions</div>
<div className="nav-list">
{exam.questions.map((qq, i) => {
const st = navStatus(qq)
const st = navStatus(qq);
return (
<button key={qq.id} className={`nav-item${i === currentIdx ? ' active' : ''}`} onClick={() => goTo(i)}>
<span className="nav-item-num">{String(i + 1).padStart(2, '0')}</span>
<span className="nav-item-title">
{qq.category ? `${qq.category} · ` : ''}{qq.type.replace('-', ' ')}
<button
key={qq.id}
className={`nav-item${i === currentIdx ? " active" : ""}`}
onClick={() => goTo(i)}
>
<span className="nav-item-num">
{String(i + 1).padStart(2, "0")}
</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>
)
);
})}
</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
</div>
<div className="progress-bar">
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
</div>
<div style={{ 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
style={{
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>
</aside>
<main className="main">
<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>}
<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>
<Tex tag="div" className="q-prompt">{q.prompt}</Tex>
<Tex tag="div" className="q-prompt">
{q.prompt}
</Tex>
<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' && (
<div className={`feedback ${result.correct ? 'correct' : 'wrong'}`}>
<div className="feedback-label">{result.correct ? 'Correct' : 'Not quite'}</div>
{graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label">
{result.correct ? "Correct" : "Not quite"}
</div>
<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>
<strong>Answer:</strong>{' '}
<Tex>{answerText}</Tex>
<Tex>{q.explanation}</Tex>
</p>
)}
{q.explanation && <p><Tex>{q.explanation}</Tex></p>}
</div>
</div>
)}
<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}>
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'}
{flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"}
</button>
<span className="spacer" />
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && (
<button className="btn btn-primary" onClick={checkAnswer}>Check answer</button>
{feedbackMode === "immediate" &&
!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>
<div style={{ marginTop: 48, fontSize: 11.5, 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
style={{
marginTop: 48,
fontSize: 11.5,
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>
</main>
@@ -197,7 +310,11 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{remainingMs != null && (
<div className="meta-block">
<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 className="meta-block">
@@ -207,7 +324,9 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
<div className="meta-block">
<div className="meta-label">Mode</div>
<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 className="meta-block notes-block">
@@ -230,5 +349,5 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
)}
</aside>
</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 { createRoot } from 'react-dom/client'
import 'katex/dist/katex.min.css'
import './index.css'
import App from './App.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(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
);
+82 -83
View File
@@ -1,125 +1,124 @@
// src/types.ts
export type QuestionType =
| 'multiple-choice'
| 'multiple-select'
| 'short-answer'
| 'translation'
| 'math-input'
| 'essay'
| 'match'
| 'order'
| "multiple-choice"
| "multiple-select"
| "short-answer"
| "translation"
| "math-input"
| "essay"
| "match"
| "order";
export interface Choice {
id: string
text: string
id: string;
text: string;
}
export interface MatchItem {
id: string
text: string
id: string;
text: string;
}
export interface QuestionImage {
src: string
alt?: string
caption?: string
src: string;
alt?: string;
caption?: string;
}
export interface QuestionAudio {
src: string
src: string;
}
export interface QuestionCode {
language: string
content: string
language: string;
content: string;
}
export interface QuestionTable {
headers: string[]
rows: string[][]
headers: string[];
rows: string[][];
}
export interface Question {
id: string
type: QuestionType
category?: string
points?: number
prompt: string
explanation?: string
image?: QuestionImage
audio?: QuestionAudio
code?: QuestionCode
table?: QuestionTable
svg?: string
answer?: string | string[] | Record<string, string>
acceptedForms?: string[]
caseSensitive?: boolean
ignorePunctuation?: boolean
ignoreAccents?: boolean
sourceLanguage?: string
targetLanguage?: string
choices?: Choice[]
minWords?: number
rubric?: string[]
left?: MatchItem[]
right?: MatchItem[]
items?: MatchItem[]
id: string;
type: QuestionType;
category?: string;
points?: number;
prompt: string;
explanation?: string;
image?: QuestionImage;
audio?: QuestionAudio;
code?: QuestionCode;
table?: QuestionTable;
svg?: string;
answer?: string | string[] | Record<string, string>;
acceptedForms?: string[];
caseSensitive?: boolean;
ignorePunctuation?: boolean;
ignoreAccents?: boolean;
sourceLanguage?: string;
targetLanguage?: string;
choices?: Choice[];
minWords?: number;
rubric?: string[];
left?: MatchItem[];
right?: MatchItem[];
items?: MatchItem[];
}
export interface Exam {
title: string
subtitle?: string
author?: string
timeLimitMinutes?: number | null
feedbackMode?: 'end' | 'immediate'
passingScore?: number
categories?: string[]
questions: Question[]
title: string;
subtitle?: string;
author?: string;
timeLimitMinutes?: number | null;
feedbackMode?: "end" | "immediate";
passingScore?: number;
categories?: string[];
questions: Question[];
}
export type FeedbackMode = 'immediate' | 'end'
export type FeedbackMode = "immediate" | "end";
export interface ExamState {
currentIdx: number
answers: Record<string, unknown>
flags: Record<string, boolean>
notes: string
revealed?: Record<string, boolean>
startedAt: number
completedAt?: number
feedbackMode: FeedbackMode
currentIdx: number;
answers: Record<string, unknown>;
flags: Record<string, boolean>;
notes: string;
revealed?: Record<string, boolean>;
startedAt: number;
completedAt?: number;
feedbackMode: FeedbackMode;
}
export interface HistoryEntry {
id: string
title: string
completedAt: number
elapsedMs: number
gradedCount: number
correctCount: number
totalPoints: number
scorePoints: number
passingScore: number
exam: Exam
state: ExamState
id: string;
title: string;
completedAt: number;
elapsedMs: number;
gradedCount: number;
correctCount: number;
totalPoints: number;
scorePoints: number;
passingScore: number;
exam: Exam;
state: ExamState;
}
export type GradeStatus = 'correct' | 'wrong' | 'ungraded' | 'unanswered'
export type GradeStatus = "correct" | "wrong" | "ungraded" | "unanswered";
export interface GradeResult {
status: GradeStatus
correct: boolean
points: number
status: GradeStatus;
correct: boolean;
points: number;
}
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
export type ThemeMode = 'light' | 'dark'
export type FontSize = 'small' | 'medium' | 'large'
export type FeedbackTweak = 'auto' | 'immediate' | 'end'
export type ThemeMode = "light" | "dark";
export type FontSize = "small" | "medium" | "large";
export type FeedbackTweak = "auto" | "immediate" | "end";
export interface Tweaks {
theme: ThemeMode
fontSize: FontSize
feedbackMode: FeedbackTweak
theme: ThemeMode;
fontSize: FontSize;
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 react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
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()] })
],
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
test: {
environment: 'node',
environment: "node",
globals: true,
},
})
});