Compare commits

...
18 Commits
Author SHA1 Message Date
LaurensBartelds 62af83ce53 feat: add MyExams, MyResults, Settings, and Workspace components
Release Pipeline / build-and-release (push) Failing after 41s
- Implemented MyExams component for managing user exams, including loading, deleting, and creating new exams.
- Created MyResults component to display user results with options to review and delete results.
- Developed Settings component for updating user display name and changing password.
- Introduced Workspace component to serve as a container for MyExams, MyResults, and Settings, with navigation between tabs.
2026-04-25 20:20:23 +02:00
LaurensBartelds ad777ef1c3 feat: implement setup wizard for initial configuration and user registration 2026-04-25 16:48:23 +02:00
LaurensBartelds f7b93f8612 feat: enhance user registration with email verification configuration and status handling 2026-04-25 16:31:27 +02:00
LaurensBartelds 4f33444653 fix: update exam route parameter format and improve question index calculation
Release Pipeline / build-and-release (push) Successful in 5m29s
2026-04-25 16:18:07 +02:00
LaurensBartelds 29d9264cfb feat: optimize state updates using flushSync for smoother exam transitions
Release Pipeline / build-and-release (push) Successful in 5m52s
2026-04-25 13:30:34 +02:00
LaurensBartelds 107ebf4d47 feat: implement admin dashboard with site settings, user management, and activity monitoring 2026-04-25 00:14:57 +02:00
LaurensBartelds 4eeacc38c5 feat: replace screen state machine with React Router v7 URL routing
Release Pipeline / build-and-release (push) Successful in 5m24s
- Install react-router-dom
- Add slugify utility with full TDD test suite (7 tests)
- Wrap app in BrowserRouter in main.tsx
- Add optional onNavigate prop to ExamRunner.goTo
- Create ExamRoute component (deep-link restore + back/forward sync)
- Migrate App.tsx: replace screen useState with <Routes>, all setScreen()
  calls replaced with navigate(), add activateExam for deep-link activation,
  use useLocation for verify/reset-password special layouts
- Remove unused AppScreen type from types.ts
2026-04-24 23:55:52 +02:00
LaurensBarteldsandClaude Sonnet 4.6 e97d90e316 docs: add React Router URL routing implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 23:48:23 +02:00
LaurensBarteldsandClaude Sonnet 4.6 1c13f4849a docs: add React Router URL routing design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 23:39:06 +02:00
LaurensBartelds 40fe1479f9 fix: update SSH key handling in deploy workflow to remove unnecessary transformations
Release Pipeline / build-and-release (push) Successful in 5m41s
2026-04-24 23:02:21 +02:00
LaurensBartelds 7b4288fe86 fix: update route handling for static files to improve frontend serving
Release Pipeline / build-and-release (push) Successful in 5m36s
2026-04-24 22:52:53 +02:00
LaurensBartelds 9539e3d740 feat: refactor SSH key handling in deploy workflow to use ssh-agent 2026-04-24 22:35:45 +02:00
LaurensBartelds 10ce58fdfe feat: update deploy workflow to enhance SSH key handling and add git status checks 2026-04-24 22:30:38 +02:00
LaurensBartelds aeed804460 feat: enhance permissions in settings.local.json and update deploy workflow for SSH key handling 2026-04-24 22:20:08 +02:00
LaurensBartelds e21610880b Update release workflow: quotes and asset uploads
Release Pipeline / build-and-release (push) Successful in 5m14s
2026-04-24 16:49:27 +02:00
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
35 changed files with 7166 additions and 1990 deletions

No files matched your search

+11 -1
View File
@@ -23,7 +23,17 @@
"Bash(xargs git *)", "Bash(xargs git *)",
"Bash(awk '{print $2}')", "Bash(awk '{print $2}')",
"Bash(git tag *)", "Bash(git tag *)",
"Bash(git push *)" "Bash(git push *)",
"Bash(xargs grep *)",
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/**)",
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/**)",
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/**)",
"Bash(bash \"C:/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh\" --project-dir \"c:/Users/laure/Documents/Gitea/brainwave\")",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" status --short)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
] ]
} }
} }
+9 -6
View File
@@ -18,16 +18,19 @@ jobs:
- name: Install Ansible - name: Install Ansible
run: pip3 install --quiet --break-system-packages "ansible>=9,<11" run: pip3 install --quiet --break-system-packages "ansible>=9,<11"
- name: Write SSH Key - name: Setup SSH Agent
run: | run: |
printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" > /tmp/ansible_key eval "$(ssh-agent -s)"
chmod 600 /tmp/ansible_key printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" | ssh-add -
ssh-add -l
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Write Inventory - name: Write Inventory
# StrictHostKeyChecking=no is intentional for a known home-network host. # StrictHostKeyChecking=no is intentional for a known home-network host.
# To harden, add the host fingerprint as a secret and use a known_hosts file. # To harden, add the host fingerprint as a secret and use a known_hosts file.
run: | run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_private_key_file=/tmp/ansible_key ansible_ssh_common_args="-o StrictHostKeyChecking=no"\n' \ printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
"${{ secrets.ANSIBLE_HOST }}" \ "${{ secrets.ANSIBLE_HOST }}" \
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini "${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
@@ -54,8 +57,8 @@ jobs:
-i /tmp/inventory.ini \ -i /tmp/inventory.ini \
-e "artifact_path=${WORKSPACE}" -e "artifact_path=${WORKSPACE}"
- name: Cleanup Secrets - name: Cleanup
if: always() if: always()
run: | run: |
rm -f /tmp/ansible_key ssh-agent -k || true
rm -f /tmp/inventory.ini rm -f /tmp/inventory.ini
+42 -10
View File
@@ -3,11 +3,13 @@ name: Release Pipeline
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -15,7 +17,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -81,12 +83,42 @@ jobs:
EOF EOF
- name: Create Release - 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: 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!"
+3
View File
@@ -1,3 +1,6 @@
backend/brainwave.db-shm backend/brainwave.db-shm
backend/brainwave.db-wal backend/brainwave.db-wal
.claude/settings.local.json .claude/settings.local.json
.superpowers/
backend/db/brainwave.db-shm
backend/db/brainwave.db-wal
+3 -1
View File
@@ -1,15 +1,17 @@
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import fs from 'fs'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { runMigrations } from './migrations.js' import { runMigrations } from './migrations.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db') const DB_PATH = path.join(__dirname, '..', '..', 'db', 'brainwave.db')
let db: Database.Database | null = null let db: Database.Database | null = null
export function getDb(): Database.Database { export function getDb(): Database.Database {
if (!db) { if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
db = new Database(DB_PATH) db = new Database(DB_PATH)
db.pragma('journal_mode = WAL') db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
+31 -17
View File
@@ -1,4 +1,4 @@
import type Database from 'better-sqlite3' import type Database from "better-sqlite3";
export function runMigrations(db: Database.Database): void { export function runMigrations(db: Database.Database): void {
db.exec(` db.exec(`
@@ -51,35 +51,49 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id); CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id); CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
`) `);
// ── Add role column if missing (existing databases) ── // ── Add role column if missing (existing databases) ──
const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[] const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
if (!cols.some(c => c.name === 'role')) { name: string;
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`) }[];
if (!cols.some((c) => c.name === "role")) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
} }
// ── Promote first user to admin if no admins exist ── // ── Promote first user to admin if no admins exist ──
const adminCount = db.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`).get() as { cnt: number } const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
.get() as { cnt: number };
if (adminCount.cnt === 0) { if (adminCount.cnt === 0) {
db.prepare(`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`).run() db.prepare(
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
).run();
} }
// ── Seed default settings ── // ── Seed default settings ──
const defaults: Record<string, string> = { const defaults: Record<string, string> = {
site_name: 'BrainWave', site_name: "BrainWave",
max_upload_mb: '5', max_upload_mb: "5",
allow_guest_mode: 'true', allow_guest_mode: "true",
registration_enabled: 'true', registration_enabled: "true",
default_theme: 'light', default_theme: "light",
maintenance_mode: 'false', maintenance_mode: "false",
maintenance_message: 'We are currently performing maintenance. Please check back soon.', maintenance_message:
} "We are currently performing maintenance. Please check back soon.",
smtp_host: "",
smtp_port: "587",
smtp_user: "",
smtp_pass: "",
smtp_from: "BrainWave <noreply@brainwave.local>",
smtp_secure: "auto",
app_url: "http://localhost:5173",
};
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?) INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING ON CONFLICT(key) DO NOTHING
`) `);
for (const [key, value] of Object.entries(defaults)) { for (const [key, value] of Object.entries(defaults)) {
upsert.run(key, value) upsert.run(key, value);
} }
} }
+3 -1
View File
@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js' import examRoutes from './routes/exams.js'
import resultRoutes from './routes/results.js' import resultRoutes from './routes/results.js'
import adminRoutes from './routes/admin.js' import adminRoutes from './routes/admin.js'
import setupRoutes from './routes/setup.js'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -24,6 +25,7 @@ getDb()
console.log('✓ Database initialized') console.log('✓ Database initialized')
// Routes // Routes
app.use('/api/setup', setupRoutes)
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes) app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes) app.use('/api/results', resultRoutes)
@@ -39,7 +41,7 @@ const frontendDistPath = path.join(__dirname, '../../frontend/dist')
if (fs.existsSync(frontendDistPath)) { if (fs.existsSync(frontendDistPath)) {
console.log(`Serving frontend from: ${frontendDistPath}`) console.log(`Serving frontend from: ${frontendDistPath}`)
app.use(express.static(frontendDistPath)) app.use(express.static(frontendDistPath))
app.get('*', (req, res) => { app.get('*path', (req, res) => {
if (!req.path.startsWith('/api')) { if (!req.path.startsWith('/api')) {
res.sendFile(path.join(frontendDistPath, 'index.html')) res.sendFile(path.join(frontendDistPath, 'index.html'))
} else { } else {
+177 -99
View File
@@ -1,30 +1,44 @@
import { Router } from 'express' import { Router } from "express";
import { getDb } from '../db/database.js' import { getDb } from "../db/database.js";
import { requireAuth, requireAdmin } from '../middleware/auth.js' import { requireAuth, requireAdmin } from "../middleware/auth.js";
import { sendTestEmail } from "../utils/email.js";
import type { SmtpConfig } from "../utils/email.js";
const router = Router() const router = Router();
// All admin routes require authentication + admin role // All admin routes require authentication + admin role
router.use(requireAuth, requireAdmin) router.use(requireAuth, requireAdmin);
// ─── Dashboard Stats ───────────────────────────────────── // ─── Dashboard Stats ─────────────────────────────────────
router.get('/stats', (_req, res) => { router.get("/stats", (_req, res) => {
try { try {
const db = getDb() const db = getDb();
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number } const users = db.prepare("SELECT COUNT(*) AS cnt FROM users").get() as {
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number } cnt: number;
const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number } };
const avgScore = db.prepare( const exams = db
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results' .prepare("SELECT COUNT(*) AS cnt FROM saved_exams")
).get() as { avg: number } .get() as { cnt: number };
const recentResults = db.prepare(` const results = db
.prepare("SELECT COUNT(*) AS cnt FROM exam_results")
.get() as { cnt: number };
const avgScore = db
.prepare(
"SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results",
)
.get() as { avg: number };
const recentResults = db
.prepare(
`
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at, SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
u.display_name, u.email u.display_name, u.email
FROM exam_results er FROM exam_results er
JOIN users u ON u.id = er.user_id JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC ORDER BY er.completed_at DESC
LIMIT 10 LIMIT 10
`).all() `,
)
.all();
res.json({ res.json({
totalUsers: users.cnt, totalUsers: users.cnt,
@@ -32,159 +46,223 @@ router.get('/stats', (_req, res) => {
totalResults: results.cnt, totalResults: results.cnt,
averageScore: Math.round(avgScore.avg * 10) / 10, averageScore: Math.round(avgScore.avg * 10) / 10,
recentResults, recentResults,
}) });
} catch (err) { } catch (err) {
console.error('Admin stats error:', err) console.error("Admin stats error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Get All Settings ──────────────────────────────────── // ─── Get All Settings ────────────────────────────────────
router.get('/settings', (_req, res) => { router.get("/settings", (_req, res) => {
try { try {
const db = getDb() const db = getDb();
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[] const rows = db
const settings: Record<string, string> = {} .prepare("SELECT key, value FROM site_settings ORDER BY key")
.all() as { key: string; value: string }[];
const settings: Record<string, string> = {};
for (const row of rows) { for (const row of rows) {
settings[row.key] = row.value settings[row.key] = row.value;
} }
res.json({ settings }) res.json({ settings });
} catch (err) { } catch (err) {
console.error('Admin get settings error:', err) console.error("Admin get settings error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Update Settings ───────────────────────────────────── // ─── Update Settings ─────────────────────────────────────
router.put('/settings', (req, res) => { router.put("/settings", (req, res) => {
try { try {
const { settings } = req.body as { settings: Record<string, string> } const { settings } = req.body as { settings: Record<string, string> };
if (!settings || typeof settings !== 'object') { if (!settings || typeof settings !== "object") {
res.status(400).json({ error: 'Settings object is required' }) res.status(400).json({ error: "Settings object is required" });
return return;
} }
const validKeys = [ const validKeys = [
'site_name', 'max_upload_mb', 'allow_guest_mode', "site_name",
'registration_enabled', 'default_theme', 'maintenance_mode', "max_upload_mb",
'maintenance_message', "allow_guest_mode",
] "registration_enabled",
"default_theme",
"maintenance_mode",
"maintenance_message",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_pass",
"smtp_from",
"smtp_secure",
"app_url",
];
const db = getDb() const db = getDb();
const update = db.prepare(` const update = db.prepare(`
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ? UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
`) `);
const updateMany = db.transaction((entries: [string, string][]) => { const updateMany = db.transaction((entries: [string, string][]) => {
for (const [key, value] of entries) { for (const [key, value] of entries) {
if (!validKeys.includes(key)) continue if (!validKeys.includes(key)) continue;
update.run(String(value), key) update.run(String(value), key);
} }
}) });
updateMany(Object.entries(settings)) updateMany(Object.entries(settings));
res.json({ message: 'Settings updated successfully' }) res.json({ message: "Settings updated successfully" });
} catch (err) { } catch (err) {
console.error('Admin update settings error:', err) console.error("Admin update settings error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── List All Users ────────────────────────────────────── // ─── List All Users ──────────────────────────────────────
router.get('/users', (req, res) => { router.get("/users", (req, res) => {
try { try {
const limit = Math.min(Number(req.query['limit']) || 50, 200) const limit = Math.min(Number(req.query["limit"]) || 50, 200);
const offset = Number(req.query['offset']) || 0 const offset = Number(req.query["offset"]) || 0;
const search = (req.query['search'] as string) || '' const search = (req.query["search"] as string) || "";
const db = getDb() const db = getDb();
let query = ` let query = `
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at, SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at,
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count, (SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count,
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count (SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
FROM users u FROM users u
` `;
let countQuery = 'SELECT COUNT(*) AS total FROM users' let countQuery = "SELECT COUNT(*) AS total FROM users";
const params: unknown[] = [] const params: unknown[] = [];
const countParams: unknown[] = [] const countParams: unknown[] = [];
if (search) { if (search) {
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?` const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`;
query += where query += where;
countQuery += where.replace('u.', '') countQuery += where.replace("u.", "");
params.push(`%${search}%`, `%${search}%`) params.push(`%${search}%`, `%${search}%`);
countParams.push(`%${search}%`, `%${search}%`) countParams.push(`%${search}%`, `%${search}%`);
} }
query += ' ORDER BY u.created_at DESC LIMIT ? OFFSET ?' query += " ORDER BY u.created_at DESC LIMIT ? OFFSET ?";
params.push(limit, offset) params.push(limit, offset);
const users = db.prepare(query).all(...params) const users = db.prepare(query).all(...params);
const countRow = db.prepare(countQuery).get(...countParams) as { total: number } const countRow = db.prepare(countQuery).get(...countParams) as {
total: number;
};
res.json({ users, total: countRow.total }) res.json({ users, total: countRow.total });
} catch (err) { } catch (err) {
console.error('Admin list users error:', err) console.error("Admin list users error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Change User Role ──────────────────────────────────── // ─── Change User Role ────────────────────────────────────
router.patch('/users/:id/role', (req, res) => { router.patch("/users/:id/role", (req, res) => {
try { try {
const userId = Number(req.params['id']) const userId = Number(req.params["id"]);
const { role } = req.body as { role: string } const { role } = req.body as { role: string };
if (!['user', 'admin'].includes(role)) { if (!["user", "admin"].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' }) res.status(400).json({ error: 'Role must be "user" or "admin"' });
return return;
} }
// Prevent self-demotion // Prevent self-demotion
if (userId === req.user!.userId && role !== 'admin') { if (userId === req.user!.userId && role !== "admin") {
res.status(400).json({ error: 'You cannot remove your own admin role' }) res.status(400).json({ error: "You cannot remove your own admin role" });
return return;
} }
const db = getDb() const db = getDb();
const result = db.prepare('UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?').run(role, userId) const result = db
.prepare(
"UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?",
)
.run(role, userId);
if (result.changes === 0) { if (result.changes === 0) {
res.status(404).json({ error: 'User not found' }) res.status(404).json({ error: "User not found" });
return return;
} }
res.json({ message: `User role updated to ${role}` }) res.json({ message: `User role updated to ${role}` });
} catch (err) { } catch (err) {
console.error('Admin change role error:', err) console.error("Admin change role error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
// ─── Delete User ───────────────────────────────────────── // ─── Delete User ─────────────────────────────────────────
router.delete('/users/:id', (req, res) => { router.delete("/users/:id", (req, res) => {
try { try {
const userId = Number(req.params['id']) const userId = Number(req.params["id"]);
// Prevent self-deletion // Prevent self-deletion
if (userId === req.user!.userId) { if (userId === req.user!.userId) {
res.status(400).json({ error: 'You cannot delete your own account' }) res.status(400).json({ error: "You cannot delete your own account" });
return return;
} }
const db = getDb() const db = getDb();
const result = db.prepare('DELETE FROM users WHERE id = ?').run(userId) const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
if (result.changes === 0) { if (result.changes === 0) {
res.status(404).json({ error: 'User not found' }) res.status(404).json({ error: "User not found" });
return return;
} }
res.json({ message: 'User deleted' }) res.json({ message: "User deleted" });
} catch (err) { } catch (err) {
console.error('Admin delete user error:', err) console.error("Admin delete user error:", err);
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: "Internal server error" });
} }
}) });
export default router // ─── Test Email ───────────────────────────────────────────
router.post("/test-email", async (req, res) => {
try {
// The body may contain optional SMTP override settings (for testing before saving)
// and a `to` field (defaults to the requesting admin's email)
const { to, smtp } = req.body as {
to?: string;
smtp?: {
host: string;
port: string;
user: string;
pass: string;
from: string;
secure: string;
};
};
const recipient = to || req.user!.email;
let overrideConfig: SmtpConfig | undefined;
if (smtp?.host) {
const port = Number(smtp.port ?? "587");
overrideConfig = {
host: smtp.host,
port,
user: smtp.user ?? "",
pass: smtp.pass ?? "",
from: smtp.from ?? "BrainWave <noreply@brainwave.local>",
secure:
smtp.secure === "true" || (smtp.secure === "auto" && port === 465),
};
}
await sendTestEmail(recipient, overrideConfig);
res.json({ message: `Test email sent to ${recipient}` });
} catch (err) {
console.error("Test email error:", err);
res
.status(500)
.json({
error: err instanceof Error ? err.message : "Failed to send test email",
});
}
});
export default router;
+97 -12
View File
@@ -2,8 +2,9 @@ import { Router } from 'express'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js' import { getDb } from '../db/database.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js' import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js' import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import { needsSetup } from './setup.js'
const router = Router() const router = Router()
const BCRYPT_ROUNDS = 12 const BCRYPT_ROUNDS = 12
@@ -13,6 +14,11 @@ const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
// ─── Register ──────────────────────────────────────────── // ─── Register ────────────────────────────────────────────
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
if (needsSetup()) {
res.status(403).json({ error: 'Initial setup is required before registration' })
return
}
const { email, password, displayName } = req.body const { email, password, displayName } = req.body
if (!email || !password || !displayName) { if (!email || !password || !displayName) {
@@ -40,20 +46,35 @@ router.post('/register', async (req, res) => {
} }
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS) const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const verifyToken = generateVerificationToken() const emailConfigured = isEmailConfigured()
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
let verifyToken: string | null = null
let verifyExpires: number | null = null
let emailVerified = 1
if (emailConfigured) {
verifyToken = generateVerificationToken()
verifyExpires = Date.now() + VERIFY_EXPIRY_MS
emailVerified = 0
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires) INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires, email_verified)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires) `).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires, emailVerified)
await sendVerificationEmail(email, verifyToken) if (emailConfigured) {
await sendVerificationEmail(email, verifyToken!)
res.status(201).json({ res.status(201).json({
message: 'Account created. Please check your email to verify your account.', message: 'Account created. Please check your email to verify your account.',
userId: result.lastInsertRowid, userId: result.lastInsertRowid,
}) })
} else {
res.status(201).json({
message: 'Account created and verified. You can now log in.',
userId: result.lastInsertRowid,
})
}
} catch (err) { } catch (err) {
console.error('Register error:', err) console.error('Register error:', err)
res.status(500).json({ error: 'Internal server error' }) res.status(500).json({ error: 'Internal server error' })
@@ -245,4 +266,68 @@ router.get('/me', requireAuth, (req, res) => {
}) })
}) })
// ─── Update Current User ─────────────────────────────────
router.patch('/me', requireAuth, (req, res) => {
try {
const { displayName } = req.body
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
return
}
const db = getDb()
db.prepare(`
UPDATE users SET display_name = ?, updated_at = unixepoch() WHERE id = ?
`).run(displayName.trim(), req.user!.userId)
res.json({ message: 'Profile updated successfully' })
} catch (err) {
console.error('Update profile error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Change Password ─────────────────────────────────────
router.post('/change-password', requireAuth, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
res.status(400).json({ error: 'Current password and new password are required' })
return
}
if (typeof newPassword !== 'string' || newPassword.length < 8) {
res.status(400).json({ error: 'New password must be at least 8 characters' })
return
}
const db = getDb()
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(
req.user!.userId
) as Record<string, unknown> | undefined
if (!user) {
res.status(404).json({ error: 'User not found' })
return
}
const valid = await bcrypt.compare(currentPassword as string, user['password_hash'] as string)
if (!valid) {
res.status(401).json({ error: 'Current password is incorrect' })
return
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
db.prepare(`
UPDATE users SET password_hash = ?, updated_at = unixepoch() WHERE id = ?
`).run(passwordHash, req.user!.userId)
res.json({ message: 'Password changed successfully' })
} catch (err) {
console.error('Change password error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router export default router
+29
View File
@@ -76,6 +76,35 @@ router.post('/', requireAuth, (req, res) => {
} }
}) })
// ─── Update Exam ─────────────────────────────────────────
router.put('/:id', requireAuth, (req, res) => {
try {
const { exam } = req.body
if (!exam || !exam.title || !exam.questions?.length) {
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
return
}
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(exam.title, exam.subtitle ?? null, JSON.stringify(exam), Number(req.params['id']), req.user!.userId)
if (result.changes === 0) {
res.status(404).json({ error: 'Exam not found' })
return
}
res.json({ message: 'Exam updated successfully' })
} catch (err) {
console.error('Update exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Delete Exam ───────────────────────────────────────── // ─── Delete Exam ─────────────────────────────────────────
router.delete('/:id', requireAuth, (req, res) => { router.delete('/:id', requireAuth, (req, res) => {
try { try {
+122
View File
@@ -0,0 +1,122 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js'
import { signToken } from '../utils/tokens.js'
const router = Router()
const BCRYPT_ROUNDS = 12
function needsSetup(): boolean {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
return row.cnt === 0
}
// ─── Check Setup Status ──────────────────────────────────
router.get('/status', (_req, res) => {
try {
res.json({ needsSetup: needsSetup() })
} catch (err) {
console.error('Setup status error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Perform Initial Setup ───────────────────────────────
router.post('/', async (req, res) => {
try {
if (!needsSetup()) {
res.status(403).json({ error: 'Setup has already been completed' })
return
}
const {
email,
password,
displayName,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
// Validate admin credentials
if (!email || !password || !displayName) {
res.status(400).json({ error: 'Email, password, and display name are required' })
return
}
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
res.status(400).json({ error: 'Invalid email address' })
return
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' })
return
}
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
return
}
const db = getDb()
// Create admin user (auto-verified since no SMTP yet)
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified)
VALUES (?, ?, ?, 'admin', 1)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim())
const userId = result.lastInsertRowid as number
// Save settings
const settings: Record<string, string> = {
app_url: typeof appUrl === 'string' && appUrl.trim() ? appUrl.trim() : 'http://localhost:5173',
}
if (typeof smtpHost === 'string' && smtpHost.trim()) {
settings.smtp_host = smtpHost.trim()
settings.smtp_port = String(Number(smtpPort) || 587)
settings.smtp_user = typeof smtpUser === 'string' ? smtpUser : ''
settings.smtp_pass = typeof smtpPass === 'string' ? smtpPass : ''
settings.smtp_from = typeof smtpFrom === 'string' && smtpFrom.trim() ? smtpFrom.trim() : 'BrainWave <noreply@brainwave.local>'
settings.smtp_secure = typeof smtpSecure === 'string' ? smtpSecure : 'auto'
}
const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`)
for (const [key, value] of Object.entries(settings)) {
upsert.run(key, value)
}
// Generate JWT and log admin in immediately
const token = signToken({
userId,
email: email.toLowerCase().trim(),
displayName: displayName.trim(),
role: 'admin',
})
res.status(201).json({
message: 'Setup completed successfully',
token,
user: {
id: userId,
email: email.toLowerCase().trim(),
displayName: displayName.trim(),
role: 'admin',
},
})
} catch (err) {
console.error('Setup error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router
export { needsSetup }
+145 -41
View File
@@ -1,46 +1,119 @@
import nodemailer from 'nodemailer' import nodemailer from "nodemailer";
import { getDb } from "../db/database.js";
const APP_URL = process.env['APP_URL'] ?? 'http://localhost:5173' // ─── Types ────────────────────────────────────────────────────────────────────
const SMTP_HOST = process.env['SMTP_HOST'] export interface SmtpConfig {
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587') host: string;
const SMTP_USER = process.env['SMTP_USER'] port: number;
const SMTP_PASS = process.env['SMTP_PASS'] user: string;
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>' pass: string;
from: string;
const isDev = !SMTP_HOST secure: boolean;
let transporter: nodemailer.Transporter | null = null
function getTransporter(): nodemailer.Transporter | null {
if (isDev) return null
if (!transporter) {
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: { user: SMTP_USER, pass: SMTP_PASS },
})
}
return transporter
} }
async function sendMail(to: string, subject: string, html: string): Promise<void> { // ─── App URL ──────────────────────────────────────────────────────────────────
const t = getTransporter()
if (!t) { export function getAppUrl(): string {
console.log('\n══════════════════════════════════════════════════') try {
console.log(`📧 EMAIL (dev mode) → ${to}`) const db = getDb();
console.log(` Subject: ${subject}`) const row = db
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`) .prepare("SELECT value FROM site_settings WHERE key = ?")
console.log('══════════════════════════════════════════════════\n') .get("app_url") as { value: string } | undefined;
return return (
(row?.value?.trim() || process.env["APP_URL"]) ?? "http://localhost:5173"
);
} catch {
return process.env["APP_URL"] ?? "http://localhost:5173";
} }
await t.sendMail({ from: SMTP_FROM, to, subject, html })
} }
export async function sendVerificationEmail(to: string, token: string): Promise<void> { // ─── SMTP Config ──────────────────────────────────────────────────────────────
const link = `${APP_URL}/verify?token=${token}`
await sendMail(to, 'Verify your BrainWave account', ` function getSetting(key: string): string {
const db = getDb();
const row = db
.prepare("SELECT value FROM site_settings WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? "";
}
export function isEmailConfigured(): boolean {
return !!getSetting("smtp_host").trim();
}
function getSmtpConfig(): SmtpConfig | null {
const host = getSetting("smtp_host").trim();
if (!host) return null;
const portStr = getSetting("smtp_port").trim() || "587";
const port = Number(portStr);
const user = getSetting("smtp_user").trim();
const pass = getSetting("smtp_pass").trim();
const from =
getSetting("smtp_from").trim() || "BrainWave <noreply@brainwave.local>";
const secureStr = getSetting("smtp_secure").trim() || "auto";
let secure: boolean;
if (secureStr === "true") {
secure = true;
} else if (secureStr === "false") {
secure = false;
} else {
// 'auto': derive from port
secure = port === 465;
}
return { host, port, user, pass, from, secure };
}
// ─── Transporter ─────────────────────────────────────────────────────────────
function buildTransporter(cfg: SmtpConfig): nodemailer.Transporter {
return nodemailer.createTransport({
host: cfg.host,
port: cfg.port,
secure: cfg.secure,
auth: cfg.user ? { user: cfg.user, pass: cfg.pass } : undefined,
});
}
// ─── Core Send ────────────────────────────────────────────────────────────────
async function sendMail(
to: string,
subject: string,
html: string,
overrideConfig?: SmtpConfig,
): Promise<void> {
const cfg = overrideConfig ?? getSmtpConfig();
if (!cfg) {
// Dev mode — no SMTP configured, log to console
console.log("\n══════════════════════════════════════════════════");
console.log(`📧 EMAIL (dev mode) → ${to}`);
console.log(` Subject: ${subject}`);
console.log(` Body:\n${html.replace(/<[^>]*>/g, "")}`);
console.log("══════════════════════════════════════════════════\n");
return;
}
const transporter = buildTransporter(cfg);
await transporter.sendMail({ from: cfg.from, to, subject, html });
}
// ─── Exported Email Functions ─────────────────────────────────────────────────
export async function sendVerificationEmail(
to: string,
token: string,
): Promise<void> {
const appUrl = getAppUrl();
const link = `${appUrl}/verify?token=${token}`;
await sendMail(
to,
"Verify your BrainWave account",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;"> <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2> <h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;"> <p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
@@ -56,12 +129,20 @@ export async function sendVerificationEmail(to: string, token: string): Promise<
This link expires in 24 hours. If you didn't create an account, you can ignore this email. This link expires in 24 hours. If you didn't create an account, you can ignore this email.
</p> </p>
</div> </div>
`) `,
);
} }
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> { export async function sendPasswordResetEmail(
const link = `${APP_URL}/reset-password?token=${token}` to: string,
await sendMail(to, 'Reset your BrainWave password', ` token: string,
): Promise<void> {
const appUrl = getAppUrl();
const link = `${appUrl}/reset-password?token=${token}`;
await sendMail(
to,
"Reset your BrainWave password",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;"> <div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2> <h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;"> <p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
@@ -77,5 +158,28 @@ export async function sendPasswordResetEmail(to: string, token: string): Promise
This link expires in 1 hour. If you didn't request a password reset, you can ignore this email. This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
</p> </p>
</div> </div>
`) `,
);
}
export async function sendTestEmail(
to: string,
overrideConfig?: SmtpConfig,
): Promise<void> {
await sendMail(
to,
"Test email from BrainWave",
`
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">This is a test email from BrainWave</h2>
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
If you're reading this, your SMTP configuration is working correctly.
</p>
<p style="font-size: 13px; color: #888; margin-top: 32px;">
Sent at: ${new Date().toUTCString()}
</p>
</div>
`,
overrideConfig,
);
} }
@@ -0,0 +1,818 @@
# React Router URL Routing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the custom screen-state machine in App.tsx with React Router v7 so the URL reflects the active exam and question number (e.g. `/exam/my-exam/q3`).
**Architecture:** Install `react-router-dom`, wrap the app in `BrowserRouter`, replace the `screen` useState with `<Routes>` / `<Route>`, and replace all `setScreen()` calls with `useNavigate()`. A new `ExamRoute` component handles deep-link restoration from localStorage and keeps `currentIdx` in sync with the `:n` URL param (enabling browser back/forward).
**Tech Stack:** React 19, react-router-dom v7, Vite, TypeScript, Vitest
---
## File Map
| Action | File |
| ------ | ---- |
| Create | `frontend/src/utils/slug.ts` |
| Create | `frontend/src/utils/slug.test.ts` |
| Create | `frontend/src/components/ExamRoute.tsx` |
| Modify | `frontend/package.json` |
| Modify | `frontend/vite.config.ts` |
| Modify | `frontend/src/main.tsx` |
| Modify | `frontend/src/components/ExamRunner.tsx` |
| Modify | `frontend/src/App.tsx` |
| Modify | `frontend/src/types.ts` |
---
## Task 1: Install react-router-dom
**Files:**
- Modify: `frontend/package.json`
- [ ] **Step 1: Install the package**
Run from the `frontend/` directory:
```bash
npm install react-router-dom
```
Expected: `react-router-dom` appears under `"dependencies"` in `package.json`. No type errors.
- [ ] **Step 2: Verify TypeScript types are available**
Run:
```bash
npm run build 2>&1 | head -5
```
Expected: build succeeds (or fails only on pre-existing errors — not on missing `react-router-dom` types).
- [ ] **Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "feat: install react-router-dom"
```
---
## Task 2: Add slugify utility (TDD)
**Files:**
- Create: `frontend/src/utils/slug.test.ts`
- Create: `frontend/src/utils/slug.ts`
- [ ] **Step 1: Write the failing tests**
Create `frontend/src/utils/slug.test.ts`:
```typescript
import { describe, it, expect } from 'vitest'
import { slugify } from './slug'
describe('slugify', () => {
it('lowercases', () => {
expect(slugify('My Exam')).toBe('my-exam')
})
it('replaces spaces with hyphens', () => {
expect(slugify('hello world')).toBe('hello-world')
})
it('replaces special chars with hyphens', () => {
expect(slugify('Exam: Advanced!')).toBe('exam-advanced')
})
it('collapses consecutive hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('trims leading and trailing hyphens', () => {
expect(slugify('!hello!')).toBe('hello')
})
it('preserves numbers', () => {
expect(slugify('Exam 2024')).toBe('exam-2024')
})
it('handles empty string', () => {
expect(slugify('')).toBe('')
})
})
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
cd frontend && npm run test:run -- src/utils/slug.test.ts
```
Expected: FAIL — `Cannot find module './slug'`
- [ ] **Step 3: Implement slugify**
Create `frontend/src/utils/slug.ts`:
```typescript
export function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
```
- [ ] **Step 4: Run tests to confirm they pass**
```bash
cd frontend && npm run test:run -- src/utils/slug.test.ts
```
Expected: 7 passing tests.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/utils/slug.ts frontend/src/utils/slug.test.ts
git commit -m "feat: add slugify utility"
```
---
## Task 3: Update Vite config and wrap app in BrowserRouter
**Files:**
- Modify: `frontend/vite.config.ts`
- Modify: `frontend/src/main.tsx`
- [ ] **Step 1: Add historyApiFallback to Vite config**
In `frontend/vite.config.ts`, add `historyApiFallback: true` to the `server` block:
```typescript
import { defineConfig } from 'vitest/config'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
export default defineConfig({
plugins: [
react(),
babel({ presets: [reactCompilerPreset()] })
],
server: {
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
test: {
environment: 'node',
globals: true,
},
})
```
- [ ] **Step 2: Wrap the app in BrowserRouter**
Replace the contents of `frontend/src/main.tsx`:
```tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import 'katex/dist/katex.min.css'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
```
- [ ] **Step 3: Verify build still passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds — `App` does not yet use any router hooks so wrapping it is safe.
- [ ] **Step 4: Commit**
```bash
git add frontend/vite.config.ts frontend/src/main.tsx
git commit -m "feat: wrap app in BrowserRouter and enable history fallback"
```
---
## Task 4: Update ExamRunner with onNavigate prop
**Files:**
- Modify: `frontend/src/components/ExamRunner.tsx`
The only change is adding an optional `onNavigate` prop and calling it from `goTo`. Everything else stays identical.
- [ ] **Step 1: Add the prop and update goTo**
In `frontend/src/components/ExamRunner.tsx`, update the `Props` interface and `goTo` callback:
```typescript
// Change the Props interface (lines 9-15) to:
interface Props {
exam: Exam
state: ExamState
setState: React.Dispatch<React.SetStateAction<ExamState>>
onSubmit: () => void
onExit: () => void
onNavigate?: (i: number) => void
}
// Change the function signature (line 17) to:
export default function ExamRunner({ exam, state, setState, onSubmit, onExit, onNavigate }: Readonly<Props>) {
// Replace the goTo callback (lines 29-30) with:
const goTo = useCallback((i: number) => {
const next = Math.max(0, Math.min(total - 1, i))
setState((s) => ({ ...s, currentIdx: next }))
onNavigate?.(next)
}, [total, onNavigate])
```
- [ ] **Step 2: Verify build passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds — `onNavigate` is optional so existing callers are unaffected.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/ExamRunner.tsx
git commit -m "feat: add optional onNavigate prop to ExamRunner"
```
---
## Task 5: Create ExamRoute component
**Files:**
- Create: `frontend/src/components/ExamRoute.tsx`
This component owns three responsibilities:
1. On mount, if no exam is active, attempt to restore from localStorage using the `:slug` param; redirect to `/` if no match.
2. On every render, sync the `:n` URL param to `examState.currentIdx` (enables browser back/forward).
3. Render `ExamRunner` with an `onNavigate` handler that updates the URL.
- [ ] **Step 1: Create the file**
Create `frontend/src/components/ExamRoute.tsx`:
```tsx
import { useParams, useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import ExamRunner from './ExamRunner'
import { loadState } from '../utils/storage'
import { slugify } from '../utils/slug'
import type { Exam, ExamState } from '../types'
interface Props {
exam: Exam | null
examState: ExamState | null
onActivate: (exam: Exam, state: ExamState) => void
setExamState: Dispatch<SetStateAction<ExamState>>
onSubmit: () => void
onExit: () => void
}
export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
const { slug, n } = useParams<{ slug: string; n: string }>()
const navigate = useNavigate()
const questionIdx = Math.max(0, (Number(n) || 1) - 1)
// Deep link: restore from localStorage if no exam is active on mount
useEffect(() => {
if (!exam) {
const saved = loadState()
if (saved?.exam && slugify(saved.exam.title) === slug) {
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
} else {
navigate('/', { replace: true })
}
}
// Intentionally runs only on mount — dep changes after mount are handled by the sync effect below
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Sync :n URL param to currentIdx (handles browser back/forward navigation)
useEffect(() => {
setExamState((s) => s ? { ...s, currentIdx: questionIdx } : s)
}, [questionIdx, setExamState])
if (!exam || !examState) return null
return (
<ExamRunner
exam={exam}
state={examState}
setState={setExamState}
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
onSubmit={onSubmit}
onExit={onExit}
/>
)
}
```
- [ ] **Step 2: Verify build passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds. `ExamRoute` is not yet imported by anything so no usages to break.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/ExamRoute.tsx
git commit -m "feat: add ExamRoute component with deep-link restore and back/forward sync"
```
---
## Task 6: Migrate App.tsx to React Router
**Files:**
- Modify: `frontend/src/App.tsx`
This is the main migration. Replace the `screen` state machine with `<Routes>`, replace all `setScreen()` calls with `navigate()`, add `activateExam` for deep-link activation, and use `useLocation` for the verify/reset-password special layouts.
- [ ] **Step 1: Replace App.tsx with the migrated version**
Replace the entire contents of `frontend/src/App.tsx`:
```tsx
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
import type { Exam, ExamState, HistoryEntry, Tweaks } from './types'
import { gradeQuestion } from './utils/grading'
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage'
import { apiFetch, isAuthenticated } from './utils/api'
import { slugify } from './utils/slug'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import Landing from './components/Landing'
import ExamRoute from './components/ExamRoute'
import Results from './components/Results'
import TweaksPanel from './components/TweaksPanel'
import AuthModal from './components/AuthModal'
import EmailVerify from './components/EmailVerify'
import ResetPassword from './components/ResetPassword'
import AdminDashboard from './components/AdminDashboard'
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
const withNotes = (state: ExamState): ExamState => ({
...state,
notes: state.notes ?? '',
})
function AppInner() {
const { user, loading: authLoading, logout } = useAuth()
const navigate = useNavigate()
const { pathname } = useLocation()
const [exam, setExam] = useState<Exam | null>(null)
const [examState, setExamState] = useState<ExamState | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS)
const [tweaksOpen, setTweaksOpen] = useState(false)
const [authModalOpen, setAuthModalOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
useEffect(() => {
if (!authLoading && user && isAuthenticated()) {
apiFetch<{ results: Array<{
id: number; title: string; score_points: number; total_points: number;
correct_count: number; graded_count: number; passing_score: number;
elapsed_ms: number; completed_at: number; exam_id: number | null;
}> }>('/api/results')
.then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id),
title: r.title,
completedAt: r.completed_at * 1000,
elapsedMs: r.elapsed_ms,
gradedCount: r.graded_count,
correctCount: r.correct_count,
totalPoints: r.total_points,
scorePoints: r.score_points,
passingScore: r.passing_score,
exam: { title: r.title, questions: [] } as unknown as Exam,
state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState,
_remoteId: r.id,
}))
setHistory(apiHistory)
})
.catch(() => {
setHistory(loadHistory())
})
}
}, [user, authLoading])
useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' }
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
}, [tweaks])
useEffect(() => {
if (pathname.startsWith('/exam/') && exam && examState) saveState({ exam, state: examState })
}, [exam, examState, pathname])
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value }))
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => {
setExamState((prev) => {
if (!prev) return prev
return typeof nextState === 'function'
? (nextState as (prevState: ExamState) => ExamState)(prev)
: nextState
})
}
const activateExam = (data: Exam, state: ExamState) => {
setExam(data)
setExamState(withNotes(state))
}
const loadExam = (data: Exam) => {
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
setExam(data)
setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm })
navigate(`/exam/${slugify(data.title)}/q1`)
}
const resume = () => {
const saved = loadState()
if (saved?.exam && saved?.state) {
setExam(saved.exam)
setExamState(withNotes(saved.state))
navigate(`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`)
}
}
const submit = useCallback(() => {
if (!exam || !examState) return
const completedAt = Date.now()
const elapsedMs = completedAt - examState.startedAt
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id]))
const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
title: exam.title,
completedAt,
elapsedMs,
gradedCount: graded.filter((r) => r.status !== 'ungraded').length,
correctCount: graded.filter((r) => r.correct).length,
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
scorePoints: graded.reduce((a, r) => a + r.points, 0),
passingScore: exam.passingScore ?? 60,
exam,
state: { ...examState, completedAt },
}
if (user && isAuthenticated()) {
void apiFetch('/api/results', {
method: 'POST',
body: JSON.stringify({
title: entry.title,
scorePoints: entry.scorePoints,
totalPoints: entry.totalPoints,
correctCount: entry.correctCount,
gradedCount: entry.gradedCount,
passingScore: entry.passingScore,
elapsedMs: entry.elapsedMs,
notes: entry.state.notes,
state: entry.state,
exam: entry.exam,
}),
}).catch(() => {
appendHistory(entry)
})
} else {
appendHistory(entry)
}
setHistory((prev) => [entry, ...prev])
clearState()
setExamState((s) => s ? { ...s, completedAt } : s)
navigate('/results')
}, [exam, examState, user, navigate])
const exit = () => {
setExam(null)
setExamState(null)
navigate('/')
}
const restart = () => { if (exam) loadExam(exam) }
const reviewAttempt = (entry: HistoryEntry) => {
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId
if (remoteId && user && isAuthenticated()) {
void apiFetch<{
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number;
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number;
}>(`/api/results/${remoteId}`)
.then((data) => {
setExam(data.exam)
setExamState(withNotes(data.state))
navigate(`/review/${slugify(entry.title)}`)
})
.catch(() => {
setExam(entry.exam)
setExamState(withNotes(entry.state))
navigate(`/review/${slugify(entry.title)}`)
})
} else {
setExam(entry.exam)
setExamState(withNotes(entry.state))
navigate(`/review/${slugify(entry.title)}`)
}
}
const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) {
clearHistory()
setHistory([])
}
}
const handleAuthFlowDone = () => {
navigate('/')
setAuthModalOpen(true)
}
if (pathname === '/verify') {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<EmailVerify onDone={handleAuthFlowDone} />
</div>
)
}
if (pathname === '/reset-password') {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<ResetPassword onDone={handleAuthFlowDone} />
</div>
)
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<button
className="brand-name"
onClick={exit}
aria-label="Home"
style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }}
>
BrainWave
</button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
</div>
<div className="topbar-meta">
{pathname.startsWith('/exam/') && exam && (
<>
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
</>
)}
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
{!authLoading && !user && (
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
Sign In
</button>
)}
{!authLoading && user && (
<div className="user-menu-wrap">
<button
className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu"
>
{user.displayName.charAt(0).toUpperCase()}
</button>
{userMenuOpen && (
<div className="user-menu">
<div className="user-menu-header">
<div className="user-menu-name">{user.displayName}</div>
<div className="user-menu-email">{user.email}</div>
</div>
<div className="user-menu-divider" />
{user.role === 'admin' && (
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); navigate('/admin') }}>
Admin Dashboard
</button>
)}
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
Sign Out
</button>
</div>
)}
</div>
)}
</div>
</header>
<Routes>
<Route path="/" element={
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
} />
<Route path="/exam/:slug/q:n" element={
<ExamRoute
exam={exam}
examState={examState}
onActivate={activateExam}
setExamState={setExamStateStrict}
onSubmit={submit}
onExit={exit}
/>
} />
<Route path="/results" element={
exam && examState
? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={false} />
: <Navigate to="/" replace />
} />
<Route path="/review/:slug" element={
exam && examState
? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={true} />
: <Navigate to="/" replace />
} />
<Route path="/admin" element={
user?.role === 'admin' ? <AdminDashboard onExit={exit} /> : <Navigate to="/" replace />
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => navigate('/reset-password')}
/>
)}
</div>
)
}
export default function App() {
return (
<AuthProvider>
<AppInner />
</AuthProvider>
)
}
```
- [ ] **Step 2: Verify the build passes**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: build succeeds with no TypeScript errors.
- [ ] **Step 3: Run all tests**
```bash
cd frontend && npm run test:run
```
Expected: all tests pass (slugify + pre-existing utils).
- [ ] **Step 4: Commit**
```bash
git add frontend/src/App.tsx
git commit -m "feat: migrate App.tsx to React Router — replace screen state with declarative routes"
```
---
## Task 7: Remove AppScreen type from types.ts
**Files:**
- Modify: `frontend/src/types.ts`
`AppScreen` is no longer referenced anywhere after the App.tsx migration.
- [ ] **Step 1: Remove the type**
In `frontend/src/types.ts`, delete line 116:
```typescript
// Delete this line:
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
```
- [ ] **Step 2: Verify build still passes**
```bash
cd frontend && npm run build 2>&1 | tail -5
```
Expected: build succeeds. If there are errors referencing `AppScreen`, search for usages:
```bash
grep -r "AppScreen" frontend/src
```
Remove any remaining import of `AppScreen` from those files.
- [ ] **Step 3: Run all tests**
```bash
cd frontend && npm run test:run
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/types.ts
git commit -m "chore: remove unused AppScreen type"
```
---
## Task 8: Manual smoke test
No automated tests cover the React Router integration (the test environment is `node`, not jsdom). Verify the feature manually.
- [ ] **Step 1: Start the dev server**
```bash
cd frontend && npm run dev
```
- [ ] **Step 2: Verify landing page**
Open `http://localhost:5173`. URL should be `/`. Landing page should render.
- [ ] **Step 3: Start an exam and verify URL updates**
Load any exam. The URL should change to `/exam/<slug>/q1`.
Navigate to question 3 (sidebar or Next button). URL should change to `/exam/<slug>/q3`.
- [ ] **Step 4: Verify browser back/forward**
Press the browser Back button. URL should go to `/exam/<slug>/q2` and question 2 should display.
- [ ] **Step 5: Verify deep link**
While on question 5, copy the URL. Open a new tab and paste it. The exam should resume at question 5 (restored from localStorage).
- [ ] **Step 6: Verify submit → results URL**
Submit the exam. URL should change to `/results`.
- [ ] **Step 7: Verify review URL**
From the landing history, click a past attempt to review. URL should change to `/review/<slug>`.
- [ ] **Step 8: Verify auth flow routes**
Navigate to `http://localhost:5173/verify`. The email verify screen should render (simplified header, no nav).
Navigate to `http://localhost:5173/reset-password`. The reset password screen should render.
- [ ] **Step 9: Verify unknown route redirect**
Navigate to `http://localhost:5173/does-not-exist`. Should redirect to `/`.
@@ -0,0 +1,112 @@
# React Router URL Routing — Design Spec
**Date:** 2026-04-24
**Status:** Approved
## Overview
Add React Router v7 to the Brainwave frontend so the URL reflects the active exam and current question number. A user on question 3 of "My Exam" will see `/exam/my-exam/q3` in the address bar. Back/forward browser navigation works naturally, and deep links to a specific question are supported.
## Route Structure
| URL | Screen |
| --- | ------ |
| `/` | Landing |
| `/exam/:slug/q:n` | ExamRunner — question `n` (1-indexed) of exam identified by `slug` |
| `/results` | Results (post-submission) |
| `/review/:slug` | Results in review-only mode |
| `/verify` | Email verification |
| `/reset-password` | Password reset |
| `/admin` | Admin dashboard (admin users only) |
`:slug` is the exam title converted to lowercase kebab-case (e.g. `"My Exam 2024"` → `"my-exam-2024"`). `:n` is the 1-indexed question number.
## Architecture
### New dependency
`react-router-dom` v7 added to `frontend/package.json` dependencies.
### New utility — `frontend/src/utils/slug.ts`
Pure function `slugify(title: string): string`:
- Lowercase
- Replace non-alphanumeric characters with hyphens
- Collapse consecutive hyphens
- Trim leading/trailing hyphens
### `main.tsx`
Wrap `<App>` in `<BrowserRouter>`.
### `App.tsx`
- Remove `screen` state and `AppScreen` type usage.
- Replace the URL-on-mount `useEffect` with React Router's declarative routing.
- Use `<Routes>` / `<Route>` to render screen components.
- Replace all `setScreen()` calls with `useNavigate()`:
- `loadExam()` → `navigate('/exam/${slug}/q1')`
- `submit()` → `navigate('/results')`
- `exit()` → `navigate('/')`
- `reviewAttempt()` → `navigate('/review/${slug}')`
- Admin menu item → `navigate('/admin')`
- `handleAuthFlowDone()` → `navigate('/')`
- `exam` and `examState` remain in React state — no change to storage, grading, or submission logic.
### `ExamRunner.tsx`
- Accept an `onNavigate: (i: number) => void` prop, called from `goTo()` after updating `examState`.
- `App.tsx` passes a handler that calls `navigate('/exam/${slug}/q${i+1}')`.
- ExamRunner has no direct knowledge of React Router — it remains router-agnostic.
### Exam route handler in `App.tsx`
- Reads `:n` and `:slug` from `useParams()`.
- On mount and whenever `:n` changes (back/forward), calls `setExamState` to set `currentIdx = n - 1`.
- Handles the deep-link case: if no active exam in state, checks localStorage for a matching slug; resumes or redirects to `/`.
### Deep linking
When the app loads on `/exam/:slug/q:n`:
- Check localStorage for a saved exam state (`brainwave.exam.state`).
- If the saved exam's slugified title matches `:slug`, resume with `currentIdx = n - 1`.
- If no match, redirect to `/`.
### Vite config
Add `server.historyApiFallback: true` to `vite.config.ts` so the dev server serves `index.html` for all non-asset paths. The production backend already handles this (it serves `index.html` for non-API routes).
## Data Flow
```text
User clicks question 5 in sidebar
→ goTo(4) called in ExamRunner
→ setState updates examState.currentIdx = 4
→ onNavigate(4) called
→ navigate('/exam/my-exam/q5') in App
→ URL bar updates
→ :n param changes
→ useEffect in App exam route calls setExamState [no-op, already 4]
User presses browser Back
→ URL changes to /exam/my-exam/q4
→ :n param changes to '4'
→ useEffect in App exam route calls setExamState({ currentIdx: 3 })
→ ExamRunner re-renders at question 4
```
## What Does Not Change
- `Results.tsx`, `Landing.tsx`, all question components
- `AuthContext`, storage utils, grading utils, API utils
- Backend — no changes required
- `ExamState` shape and localStorage persistence
- `AppScreen` type removed from `types.ts`
## Out of Scope
- Animated route transitions
- Scroll restoration between questions
- Sharing/bookmarking exam links to others (exam data is client-side only)
+59 -1
View File
@@ -11,7 +11,8 @@
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"katex": "^0.16.45", "katex": "^0.16.45",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
@@ -1791,6 +1792,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3075,6 +3089,44 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3142,6 +3194,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+2 -1
View File
@@ -15,7 +15,8 @@
"dompurify": "^3.4.1", "dompurify": "^3.4.1",
"katex": "^0.16.45", "katex": "^0.16.45",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
+378 -156
View File
@@ -1,57 +1,86 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types' import { flushSync } from "react-dom";
import { gradeQuestion } from './utils/grading' import {
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage' useNavigate,
import { apiFetch, isAuthenticated } from './utils/api' useLocation,
import { AuthProvider, useAuth } from './contexts/AuthContext' Routes,
import Landing from './components/Landing' Route,
import ExamRunner from './components/ExamRunner' Navigate,
import Results from './components/Results' } from "react-router-dom";
import TweaksPanel from './components/TweaksPanel' import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
import AuthModal from './components/AuthModal' import { gradeQuestion } from "./utils/grading";
import EmailVerify from './components/EmailVerify' import {
import ResetPassword from './components/ResetPassword' loadState,
import AdminDashboard from './components/AdminDashboard' saveState,
clearState,
loadHistory,
appendHistory,
clearHistory,
} from "./utils/storage";
import { apiFetch, isAuthenticated } from "./utils/api";
import { getExamUrlId } 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";
import SetupWizard from "./components/SetupWizard";
import Workspace from "./components/Workspace";
import ExamEditor from "./components/ExamEditor";
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' } const DEFAULT_TWEAKS: Tweaks = {
theme: "light",
fontSize: "medium",
feedbackMode: "auto",
};
const withNotes = (state: ExamState): ExamState => ({ const withNotes = (state: ExamState): ExamState => ({
...state, ...state,
notes: state.notes ?? '', notes: state.notes ?? "",
}) });
function AppInner() { function AppInner() {
const { user, loading: authLoading, logout } = useAuth() const { user, loading: authLoading, needsSetup, refreshUser, 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)
// Check URL for verify/reset-password routes on mount if (needsSetup) {
useEffect(() => { return (
const path = globalThis.location.pathname <SetupWizard
const search = globalThis.location.search onComplete={() => {
if (path === '/verify' || search.includes('token=') && path === '/verify') { void refreshUser()
setScreen('verify') }}
} else if (path === '/reset-password') { />
setScreen('reset-password') )
} else if (path === '/admin') { }
setScreen('admin') 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);
// Load history from API when authenticated
useEffect(() => { useEffect(() => {
if (!authLoading && user && isAuthenticated()) { if (!authLoading && user && isAuthenticated()) {
apiFetch<{ results: Array<{ apiFetch<{
id: number; title: string; score_points: number; total_points: number; results: Array<{
correct_count: number; graded_count: number; passing_score: number; id: number;
elapsed_ms: number; completed_at: number; exam_id: number | null; title: string;
}> }>('/api/results') score_points: number;
total_points: number;
correct_count: number;
graded_count: number;
passing_score: number;
elapsed_ms: number;
completed_at: number;
exam_id: number | null;
}>;
}>("/api/results")
.then((data) => { .then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({ const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id), id: String(r.id),
@@ -64,75 +93,136 @@ function AppInner() {
scorePoints: r.score_points, scorePoints: r.score_points,
passingScore: r.passing_score, passingScore: r.passing_score,
exam: { title: r.title, questions: [] } as unknown as Exam, exam: { title: r.title, questions: [] } as unknown as Exam,
state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState, state: {
currentIdx: 0,
answers: {},
flags: {},
notes: "",
startedAt: 0,
feedbackMode: "end" as const,
} as ExamState,
_remoteId: r.id, _remoteId: r.id,
})) }));
setHistory(apiHistory) setHistory(apiHistory);
}) })
.catch(() => { .catch(() => {
// Fall back to localStorage setHistory(loadHistory());
setHistory(loadHistory()) });
})
} }
}, [user, authLoading]) }, [user, authLoading]);
useEffect(() => { useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme document.documentElement.dataset["theme"] = tweaks.theme;
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' } const sizeMap: Record<string, string> = {
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px' small: "14px",
}, [tweaks]) medium: "15px",
large: "17px",
};
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? "15px";
}, [tweaks]);
useEffect(() => { useEffect(() => {
if (screen === 'exam' && exam && examState) saveState({ exam, state: examState }) if (pathname.startsWith("/exam/") && exam && examState)
}, [exam, examState, screen]) saveState({ exam, state: examState });
}, [exam, examState, pathname]);
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
useEffect(() => {
if (pathname.startsWith("/review/") && !exam) {
const reviewExam = localStorage.getItem('brainwave.review.exam')
const reviewState = localStorage.getItem('brainwave.review.state')
if (reviewExam && reviewState) {
try {
const parsedExam = JSON.parse(reviewExam) as Exam
const parsedState = JSON.parse(reviewState) as ExamState
setExam(parsedExam)
setExamState(withNotes(parsedState))
localStorage.removeItem('brainwave.review.exam')
localStorage.removeItem('brainwave.review.state')
} catch {
// ignore parse errors
}
}
}
}, [pathname, exam]);
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value })) setTweaks((t) => ({ ...t, [key]: value }));
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => { const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
setExamState((prev) => { (nextState) => {
if (!prev) return prev setExamState((prev) => {
return typeof nextState === 'function' if (!prev) return prev;
? (nextState as (prevState: ExamState) => ExamState)(prev) return typeof nextState === "function"
: nextState ? (nextState as (prevState: ExamState) => ExamState)(prev)
}) : nextState;
} });
},
[],
);
const activateExam = (data: Exam, state: ExamState) => {
setExam(data);
setExamState(withNotes(state));
};
const loadExam = (data: Exam) => { const loadExam = (data: Exam) => {
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode const fm =
setExam(data) tweaks.feedbackMode === "auto"
setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm }) ? (data.feedbackMode ?? "end")
setScreen('exam') : tweaks.feedbackMode;
} flushSync(() => {
setExam(data);
setExamState({
currentIdx: 0,
answers: {},
flags: {},
notes: "",
revealed: {},
startedAt: Date.now(),
feedbackMode: fm,
});
});
navigate(`/exam/${getExamUrlId(data)}/q1`);
};
const resume = () => { const resume = () => {
const saved = loadState() const saved = loadState();
if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(withNotes(saved.state)); setScreen('exam') } if (saved?.exam && saved?.state) {
} flushSync(() => {
setExam(saved.exam);
setExamState(withNotes(saved.state));
});
navigate(
`/exam/${getExamUrlId(saved.exam)}/q${saved.state.currentIdx + 1}`,
);
}
};
const submit = useCallback(() => { const submit = useCallback(() => {
if (!exam || !examState) return if (!exam || !examState) return;
const completedAt = Date.now() const completedAt = Date.now();
const elapsedMs = completedAt - examState.startedAt const elapsedMs = completedAt - examState.startedAt;
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id])) const graded = exam.questions.map((q) =>
gradeQuestion(q, examState.answers[q.id]),
);
const entry: HistoryEntry = { const entry: HistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
title: exam.title, title: exam.title,
completedAt, completedAt,
elapsedMs, elapsedMs,
gradedCount: graded.filter((r) => r.status !== 'ungraded').length, gradedCount: graded.filter((r) => r.status !== "ungraded").length,
correctCount: graded.filter((r) => r.correct).length, correctCount: graded.filter((r) => r.correct).length,
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0), totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
scorePoints: graded.reduce((a, r) => a + r.points, 0), scorePoints: graded.reduce((a, r) => a + r.points, 0),
passingScore: exam.passingScore ?? 60, passingScore: exam.passingScore ?? 60,
exam, exam,
state: { ...examState, completedAt }, state: { ...examState, completedAt },
} };
// Save to API if authenticated
if (user && isAuthenticated()) { if (user && isAuthenticated()) {
void apiFetch('/api/results', { void apiFetch("/api/results", {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
title: entry.title, title: entry.title,
scorePoints: entry.scorePoints, scorePoints: entry.scorePoints,
@@ -146,59 +236,79 @@ function AppInner() {
exam: entry.exam, exam: entry.exam,
}), }),
}).catch(() => { }).catch(() => {
// Fallback: save locally appendHistory(entry);
appendHistory(entry) });
})
} else { } else {
appendHistory(entry) appendHistory(entry);
} }
// Always update local state for immediate display setHistory((prev) => [entry, ...prev]);
setHistory((prev) => [entry, ...prev]) clearState();
clearState() setExamState((s) => (s ? { ...s, completedAt } : s));
setExamState((s) => s ? { ...s, completedAt } : s) navigate("/results");
setScreen('results') }, [exam, examState, user, navigate]);
}, [exam, examState, user])
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) } const exit = () => {
const restart = () => { if (exam) loadExam(exam) } setExam(null);
setExamState(null);
navigate("/");
};
const restart = () => {
if (exam) loadExam(exam);
};
const reviewAttempt = (entry: HistoryEntry) => { const reviewAttempt = (entry: HistoryEntry) => {
// If this is a remote result, fetch full data const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId
if (remoteId && user && isAuthenticated()) { if (remoteId && user && isAuthenticated()) {
void apiFetch<{ void apiFetch<{
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number; exam: Exam;
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number; state: ExamState;
title: string;
scorePoints: number;
totalPoints: number;
correctCount: number;
gradedCount: number;
passingScore: number;
elapsedMs: number;
completedAt: number;
}>(`/api/results/${remoteId}`) }>(`/api/results/${remoteId}`)
.then((data) => { .then((data) => {
setExam(data.exam) flushSync(() => {
setExamState(withNotes(data.state)) setExam(data.exam);
setScreen('review') setExamState(withNotes(data.state));
});
navigate(`/review/${getExamUrlId(entry.exam)}`);
}) })
.catch(() => { .catch(() => {
// Fallback to local data flushSync(() => {
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') setExam(entry.exam);
}) setExamState(withNotes(entry.state));
});
navigate(`/review/${getExamUrlId(entry.exam)}`);
});
} else { } else {
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review') flushSync(() => {
setExam(entry.exam);
setExamState(withNotes(entry.state));
});
navigate(`/review/${getExamUrlId(entry.exam)}`);
} }
} };
const clearAllHistory = () => { const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) { if (confirm("Clear all past attempts?")) {
clearHistory() clearHistory();
setHistory([]) setHistory([]);
} }
} };
const handleAuthFlowDone = () => { const handleAuthFlowDone = () => {
globalThis.history.replaceState({}, '', '/') navigate("/");
setScreen('landing') setAuthModalOpen(true);
setAuthModalOpen(true) };
}
if (screen === 'verify') { if (pathname === "/verify") {
return ( return (
<div className="shell"> <div className="shell">
<header className="topbar"> <header className="topbar">
@@ -209,10 +319,10 @@ function AppInner() {
</header> </header>
<EmailVerify onDone={handleAuthFlowDone} /> <EmailVerify onDone={handleAuthFlowDone} />
</div> </div>
) );
} }
if (screen === 'reset-password') { if (pathname === "/reset-password") {
return ( return (
<div className="shell"> <div className="shell">
<header className="topbar"> <header className="topbar">
@@ -223,7 +333,7 @@ function AppInner() {
</header> </header>
<ResetPassword onDone={handleAuthFlowDone} /> <ResetPassword onDone={handleAuthFlowDone} />
</div> </div>
) );
} }
return ( return (
@@ -235,24 +345,47 @@ function AppInner() {
className="brand-name" className="brand-name"
onClick={exit} onClick={exit}
aria-label="Home" aria-label="Home"
style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }} style={{
background: "none",
border: "none",
padding: 0,
margin: 0,
cursor: "pointer",
font: "inherit",
color: "inherit",
}}
> >
BrainWave BrainWave
</button> </button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span> <span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
/ practice
</span>
</div> </div>
<div className="topbar-meta"> <div className="topbar-meta">
{screen === 'exam' && exam && ( {pathname.startsWith("/exam/") && exam && (
<> <>
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span> <span className="mono" style={{ fontSize: 12 }}>
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button> {exam.title}
</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>
Exit
</button>
</> </>
)} )}
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button> <button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
>
⚙
</button>
{/* Auth buttons */}
{!authLoading && !user && ( {!authLoading && !user && (
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}> <button
className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)}
>
Sign In Sign In
</button> </button>
)} )}
@@ -266,48 +399,137 @@ function AppInner() {
{user.displayName.charAt(0).toUpperCase()} {user.displayName.charAt(0).toUpperCase()}
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div className="user-menu"> <div className="user-menu">
<div className="user-menu-header"> <div className="user-menu-header">
<div className="user-menu-name">{user.displayName}</div> <div className="user-menu-name">{user.displayName}</div>
<div className="user-menu-email">{user.email}</div> <div className="user-menu-email">{user.email}</div>
</div>
<div className="user-menu-divider" />
{user.role === 'admin' && (
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); setScreen('admin'); globalThis.history.pushState({}, '', '/admin') }}>
Admin Dashboard
</button>
)}
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
Sign Out
</button>
</div> </div>
<div className="user-menu-divider" />
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
navigate("/workspace");
}}
>
My Workspace
</button>
{user.role === "admin" && (
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
navigate("/admin");
}}
>
Admin Dashboard
</button>
)}
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
logout();
}}
>
Sign Out
</button>
</div>
)} )}
</div> </div>
)} )}
</div> </div>
</header> </header>
{screen === 'landing' && ( <Routes>
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} /> <Route
path="/"
element={
<Landing
onLoadExam={loadExam}
history={history}
onResume={resume}
onClearHistory={clearAllHistory}
onReviewAttempt={reviewAttempt}
/>
}
/>
<Route
path="/exam/:slug/:n"
element={
<ExamRoute
exam={exam}
examState={examState}
onActivate={activateExam}
setExamState={setExamStateStrict}
onSubmit={submit}
onExit={exit}
/>
}
/>
<Route
path="/results"
element={
exam && examState ? (
<Results
exam={exam}
state={examState}
onRestart={restart}
onExit={exit}
reviewOnly={false}
/>
) : (
<Navigate to="/" replace />
)
}
/>
<Route
path="/review/:slug"
element={
exam && examState ? (
<Results
exam={exam}
state={examState}
onRestart={restart}
onExit={exit}
reviewOnly={true}
/>
) : (
<Navigate to="/" replace />
)
}
/>
<Route
path="/admin"
element={
user?.role === "admin" ? (
<AdminDashboard onExit={exit} />
) : (
<Navigate to="/" replace />
)
}
/>
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
<Route path="/workspace/:tab" element={<Workspace />} />
<Route path="/exam-editor/:id" element={<ExamEditor />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{tweaksOpen && (
<TweaksPanel
tweaks={tweaks}
update={updateTweak}
onClose={() => setTweaksOpen(false)}
/>
)} )}
{screen === 'admin' && user?.role === 'admin' && (
<AdminDashboard onExit={exit} />
)}
{screen === 'exam' && exam && examState && (
<ExamRunner exam={exam} state={examState} setState={setExamStateStrict} onSubmit={submit} onExit={exit} />
)}
{(screen === 'results' || screen === 'review') && exam && examState && (
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
)}
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
{authModalOpen && ( {authModalOpen && (
<AuthModal <AuthModal
onClose={() => setAuthModalOpen(false)} onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => setScreen('reset-password')} onForgotPassword={() => navigate("/reset-password")}
/> />
)} )}
</div> </div>
) );
} }
export default function App() { export default function App() {
@@ -315,5 +537,5 @@ export default function App() {
<AuthProvider> <AuthProvider>
<AppInner /> <AppInner />
</AuthProvider> </AuthProvider>
) );
} }
+492 -155
View File
@@ -1,155 +1,208 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from "react";
import { apiFetch } from '../utils/api' import { apiFetch } from "../utils/api";
interface SiteSettings { interface SiteSettings {
site_name: string site_name: string;
max_upload_mb: string max_upload_mb: string;
allow_guest_mode: string allow_guest_mode: string;
registration_enabled: string registration_enabled: string;
default_theme: string default_theme: string;
maintenance_mode: string maintenance_mode: string;
maintenance_message: string maintenance_message: string;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: string;
app_url: string;
} }
interface DashboardStats { interface DashboardStats {
totalUsers: number totalUsers: number;
totalExams: number totalExams: number;
totalResults: number totalResults: number;
averageScore: number averageScore: number;
recentResults: { recentResults: {
id: number id: number;
title: string title: string;
score_points: number score_points: number;
total_points: number total_points: number;
completed_at: number completed_at: number;
display_name: string display_name: string;
email: string email: string;
}[] }[];
} }
interface AdminUser { interface AdminUser {
id: number id: number;
email: string email: string;
display_name: string display_name: string;
role: 'user' | 'admin' role: "user" | "admin";
email_verified: number email_verified: number;
created_at: number created_at: number;
results_count: number results_count: number;
exams_count: number exams_count: number;
} }
type AdminTab = 'overview' | 'settings' | 'users' type AdminTab = "overview" | "settings" | "users";
interface Props { interface Props {
onExit: () => void onExit: () => void;
} }
export default function AdminDashboard({ onExit }: Readonly<Props>) { export default function AdminDashboard({ onExit }: Readonly<Props>) {
const [tab, setTab] = useState<AdminTab>('overview') const [tab, setTab] = useState<AdminTab>("overview");
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null);
const [settings, setSettings] = useState<SiteSettings | null>(null) const [settings, setSettings] = useState<SiteSettings | null>(null);
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null) const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([]);
const [usersTotal, setUsersTotal] = useState(0) const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('') const [userSearch, setUserSearch] = useState("");
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null) const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null) const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const [testEmailTo, setTestEmailTo] = useState("");
const [testEmailMsg, setTestEmailMsg] = useState<{
ok: boolean;
text: string;
} | null>(null);
const [testEmailSending, setTestEmailSending] = useState(false);
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
try { try {
const data = await apiFetch<DashboardStats>('/api/admin/stats') const data = await apiFetch<DashboardStats>("/api/admin/stats");
setStats(data) setStats(data);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
try { try {
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings') const data = await apiFetch<{ settings: SiteSettings }>(
setSettings(data.settings) "/api/admin/settings",
setSettingsDraft(data.settings) );
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
const loadUsers = useCallback(async (search = '') => { const loadUsers = useCallback(async (search = "") => {
try { try {
const q = search ? `?search=${encodeURIComponent(search)}` : '' const q = search ? `?search=${encodeURIComponent(search)}` : "";
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`) const data = await apiFetch<{ users: AdminUser[]; total: number }>(
setUsers(data.users) `/api/admin/users${q}`,
setUsersTotal(data.total) );
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
void loadStats() void loadStats();
void loadSettings() void loadSettings();
void loadUsers() void loadUsers();
}, [loadStats, loadSettings, loadUsers]) }, [loadStats, loadSettings, loadUsers]);
const saveSettings = async () => { const saveSettings = async () => {
if (!settingsDraft) return if (!settingsDraft) return;
setSaving(true) setSaving(true);
setSaveMsg(null) setSaveMsg(null);
try { try {
await apiFetch('/api/admin/settings', { await apiFetch("/api/admin/settings", {
method: 'PUT', method: "PUT",
body: JSON.stringify({ settings: settingsDraft }), body: JSON.stringify({ settings: settingsDraft }),
}) });
setSettings(settingsDraft) setSettings(settingsDraft);
setSaveMsg('Settings saved successfully') setSaveMsg("Settings saved successfully");
setTimeout(() => setSaveMsg(null), 3000) setTimeout(() => setSaveMsg(null), 3000);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} finally { } finally {
setSaving(false) setSaving(false);
} }
} };
const changeRole = async (userId: number, newRole: 'user' | 'admin') => { const changeRole = async (userId: number, newRole: "user" | "admin") => {
try { try {
await apiFetch(`/api/admin/users/${userId}/role`, { await apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH', method: "PATCH",
body: JSON.stringify({ role: newRole }), body: JSON.stringify({ role: newRole }),
}) });
await loadUsers(userSearch) await loadUsers(userSearch);
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
} };
const deleteUser = async (userId: number) => { const deleteUser = async (userId: number) => {
try { try {
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' }) await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
setDeleteConfirm(null) setDeleteConfirm(null);
await loadUsers(userSearch) await loadUsers(userSearch);
await loadStats() await loadStats();
} catch (e) { } catch (e) {
setError((e as Error).message) setError((e as Error).message);
} }
} };
const sendTestEmail = async () => {
if (!settingsDraft) return;
setTestEmailSending(true);
setTestEmailMsg(null);
try {
const smtp = {
host: settingsDraft.smtp_host,
port: settingsDraft.smtp_port,
user: settingsDraft.smtp_user,
pass: settingsDraft.smtp_pass,
from: settingsDraft.smtp_from,
secure: settingsDraft.smtp_secure,
};
await apiFetch<{ message: string }>("/api/admin/test-email", {
method: "POST",
body: JSON.stringify({ to: testEmailTo || undefined, smtp }),
});
setTestEmailMsg({
ok: true,
text: "Test email sent successfully! Check your inbox.",
});
} catch (e) {
setTestEmailMsg({ ok: false, text: (e as Error).message });
} finally {
setTestEmailSending(false);
setTimeout(() => setTestEmailMsg(null), 6000);
}
};
const handleUserSearch = (e: React.FormEvent) => { const handleUserSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
void loadUsers(userSearch) void loadUsers(userSearch);
} };
const updateDraft = (key: keyof SiteSettings, value: string) => { const updateDraft = (key: keyof SiteSettings, value: string) => {
setSettingsDraft((d) => d ? { ...d, [key]: value } : d) setSettingsDraft((d) => (d ? { ...d, [key]: value } : d));
} };
const hasChanges = settings && settingsDraft && JSON.stringify(settings) !== JSON.stringify(settingsDraft) const hasChanges =
settings &&
settingsDraft &&
JSON.stringify(settings) !== JSON.stringify(settingsDraft);
const formatDate = (ts: number) => { const formatDate = (ts: number) => {
const d = new Date(ts * 1000) const d = new Date(ts * 1000);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) return d.toLocaleDateString("en-US", {
} month: "short",
day: "numeric",
year: "numeric",
});
};
return ( return (
<div className="admin"> <div className="admin">
@@ -166,19 +219,21 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{error && ( {error && (
<div className="admin-alert admin-alert-error"> <div className="admin-alert admin-alert-error">
<span>{error}</span> <span>{error}</span>
<button onClick={() => setError(null)} className="admin-alert-close">×</button> <button onClick={() => setError(null)} className="admin-alert-close">
×
</button>
</div> </div>
)} )}
<div className="admin-tabs"> <div className="admin-tabs">
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => ( {(["overview", "settings", "users"] as AdminTab[]).map((t) => (
<button <button
key={t} key={t}
className={`admin-tab${tab === t ? ' active' : ''}`} className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)} onClick={() => setTab(t)}
> >
<span className="admin-tab-icon"> <span className="admin-tab-icon">
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'} {t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
</span> </span>
{t.charAt(0).toUpperCase() + t.slice(1)} {t.charAt(0).toUpperCase() + t.slice(1)}
</button> </button>
@@ -186,27 +241,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
{/* ─── Overview Tab ─── */} {/* ─── Overview Tab ─── */}
{tab === 'overview' && ( {tab === "overview" && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon users-icon">⊕</div> <div className="admin-stat-icon users-icon">⊕</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value">{stats?.totalUsers ?? '—'}</div> <div className="admin-stat-value">
{stats?.totalUsers ?? "—"}
</div>
<div className="admin-stat-label">Total Users</div> <div className="admin-stat-label">Total Users</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon exams-icon">◇</div> <div className="admin-stat-icon exams-icon">◇</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value">{stats?.totalExams ?? '—'}</div> <div className="admin-stat-value">
{stats?.totalExams ?? "—"}
</div>
<div className="admin-stat-label">Saved Exams</div> <div className="admin-stat-label">Saved Exams</div>
</div> </div>
</div> </div>
<div className="admin-stat-card"> <div className="admin-stat-card">
<div className="admin-stat-icon results-icon">✦</div> <div className="admin-stat-icon results-icon">✦</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value">{stats?.totalResults ?? '—'}</div> <div className="admin-stat-value">
{stats?.totalResults ?? "—"}
</div>
<div className="admin-stat-label">Exams Taken</div> <div className="admin-stat-label">Exams Taken</div>
</div> </div>
</div> </div>
@@ -214,7 +275,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-icon avg-icon">%</div> <div className="admin-stat-icon avg-icon">%</div>
<div className="admin-stat-info"> <div className="admin-stat-info">
<div className="admin-stat-value"> <div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : '—'} {stats ? `${stats.averageScore}%` : "—"}
</div> </div>
<div className="admin-stat-label">Avg. Score</div> <div className="admin-stat-label">Avg. Score</div>
</div> </div>
@@ -230,7 +291,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-empty">No recent activity yet</div> <div className="admin-empty">No recent activity yet</div>
)} )}
{stats?.recentResults?.map((r) => { {stats?.recentResults?.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0 const pct = r.total_points
? Math.round((r.score_points / r.total_points) * 100)
: 0;
return ( return (
<div key={r.id} className="admin-activity-row"> <div key={r.id} className="admin-activity-row">
<div className="admin-activity-user"> <div className="admin-activity-user">
@@ -238,15 +301,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{r.display_name.charAt(0).toUpperCase()} {r.display_name.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<div className="admin-activity-name">{r.display_name}</div> <div className="admin-activity-name">
{r.display_name}
</div>
<div className="admin-activity-email">{r.email}</div> <div className="admin-activity-email">{r.email}</div>
</div> </div>
</div> </div>
<div className="admin-activity-exam">{r.title}</div> <div className="admin-activity-exam">{r.title}</div>
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div> <div
<div className="admin-activity-date">{formatDate(r.completed_at)}</div> className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
>
{pct}%
</div>
<div className="admin-activity-date">
{formatDate(r.completed_at)}
</div>
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
@@ -254,7 +325,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)} )}
{/* ─── Settings Tab ─── */} {/* ─── Settings Tab ─── */}
{tab === 'settings' && settingsDraft && ( {tab === "settings" && settingsDraft && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
{saveMsg && ( {saveMsg && (
<div className="admin-alert admin-alert-success">{saveMsg}</div> <div className="admin-alert admin-alert-success">{saveMsg}</div>
@@ -266,21 +337,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
<div className="admin-panel-body admin-settings-form"> <div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-site-name"> <label
className="admin-setting-label"
htmlFor="setting-site-name"
>
<span className="admin-setting-title">Site Name</span> <span className="admin-setting-title">Site Name</span>
<span className="admin-setting-desc">The name displayed throughout the application</span> <span className="admin-setting-desc">
The name displayed throughout the application
</span>
</label> </label>
<input <input
id="setting-site-name" id="setting-site-name"
className="input admin-setting-input" className="input admin-setting-input"
value={settingsDraft.site_name} value={settingsDraft.site_name}
onChange={(e) => updateDraft('site_name', e.target.value)} onChange={(e) => updateDraft("site_name", e.target.value)}
/> />
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-max-upload"> <label
<span className="admin-setting-title">Max Upload Size (MB)</span> className="admin-setting-label"
<span className="admin-setting-desc">Maximum file size for exam JSON uploads</span> htmlFor="setting-max-upload"
>
<span className="admin-setting-title">
Max Upload Size (MB)
</span>
<span className="admin-setting-desc">
Maximum file size for exam JSON uploads
</span>
</label> </label>
<input <input
id="setting-max-upload" id="setting-max-upload"
@@ -289,23 +372,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
min="1" min="1"
max="50" max="50"
value={settingsDraft.max_upload_mb} value={settingsDraft.max_upload_mb}
onChange={(e) => updateDraft('max_upload_mb', e.target.value)} onChange={(e) => updateDraft("max_upload_mb", e.target.value)}
/> />
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-default-theme"> <label
className="admin-setting-label"
htmlFor="setting-default-theme"
>
<span className="admin-setting-title">Default Theme</span> <span className="admin-setting-title">Default Theme</span>
<span className="admin-setting-desc">Default theme for new visitors</span> <span className="admin-setting-desc">
Default theme for new visitors
</span>
</label> </label>
<div className="admin-setting-toggle-group"> <div className="admin-setting-toggle-group">
<button <button
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`} className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
onClick={() => updateDraft('default_theme', 'light')} onClick={() => updateDraft("default_theme", "light")}
>☀ Light</button> >
☀ Light
</button>
<button <button
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`} className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft('default_theme', 'dark')} onClick={() => updateDraft("default_theme", "dark")}
>☾ Dark</button> >
☾ Dark
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -319,27 +411,47 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Allow Guest Mode</span> <span className="admin-setting-title">Allow Guest Mode</span>
<span className="admin-setting-desc">Let unauthenticated users take exams without signing in</span> <span className="admin-setting-desc">
Let unauthenticated users take exams without signing in
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.allow_guest_mode === "true" ? " on" : ""}`}
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"allow_guest_mode",
settingsDraft.allow_guest_mode === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.allow_guest_mode === 'true'} aria-checked={settingsDraft.allow_guest_mode === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
</div> </div>
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Registration Enabled</span> <span className="admin-setting-title">
<span className="admin-setting-desc">Allow new users to create accounts</span> Registration Enabled
</span>
<span className="admin-setting-desc">
Allow new users to create accounts
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.registration_enabled === "true" ? " on" : ""}`}
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"registration_enabled",
settingsDraft.registration_enabled === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.registration_enabled === 'true'} aria-checked={settingsDraft.registration_enabled === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
@@ -347,6 +459,186 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
</div> </div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Email / SMTP</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-app-url"
>
<span className="admin-setting-title">App URL</span>
<span className="admin-setting-desc">
Base URL used in email links (e.g.
https://brainwave.example.com)
</span>
</label>
<input
id="setting-app-url"
className="input admin-setting-input"
value={settingsDraft.app_url}
onChange={(e) => updateDraft("app_url", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-host"
>
<span className="admin-setting-title">SMTP Host</span>
<span className="admin-setting-desc">
Hostname of your SMTP server
</span>
</label>
<input
id="setting-smtp-host"
className="input admin-setting-input"
placeholder="smtp.example.com"
value={settingsDraft.smtp_host}
onChange={(e) => updateDraft("smtp_host", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-port"
>
<span className="admin-setting-title">SMTP Port</span>
<span className="admin-setting-desc">
Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)
</span>
</label>
<input
id="setting-smtp-port"
className="input admin-setting-input"
type="number"
min="1"
max="65535"
value={settingsDraft.smtp_port}
onChange={(e) => updateDraft("smtp_port", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-user"
>
<span className="admin-setting-title">Username</span>
<span className="admin-setting-desc">
SMTP authentication username
</span>
</label>
<input
id="setting-smtp-user"
className="input admin-setting-input"
autoComplete="off"
value={settingsDraft.smtp_user}
onChange={(e) => updateDraft("smtp_user", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-pass"
>
<span className="admin-setting-title">Password</span>
<span className="admin-setting-desc">
SMTP authentication password
</span>
</label>
<input
id="setting-smtp-pass"
className="input admin-setting-input"
type="password"
autoComplete="new-password"
value={settingsDraft.smtp_pass}
onChange={(e) => updateDraft("smtp_pass", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label
className="admin-setting-label"
htmlFor="setting-smtp-from"
>
<span className="admin-setting-title">From Address</span>
<span className="admin-setting-desc">
The sender name and address shown in emails
</span>
</label>
<input
id="setting-smtp-from"
className="input admin-setting-input"
placeholder="BrainWave <noreply@example.com>"
value={settingsDraft.smtp_from}
onChange={(e) => updateDraft("smtp_from", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">TLS Mode</span>
<span className="admin-setting-desc">
Auto detects from port (465→SSL). Use explicit override if
needed.
</span>
</label>
<div className="admin-setting-toggle-group">
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "auto")}
>
Auto
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "true")}
>
Force TLS
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "false")}
>
Off
</button>
</div>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">Send Test Email</span>
<span className="admin-setting-desc">
Sends a test email using the settings above (uses draft
values, no need to save first)
</span>
</div>
<div className="admin-test-email-row">
<input
className="input admin-setting-input"
placeholder="Recipient address (defaults to your account email)"
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
type="email"
/>
<button
className="btn btn-sm"
onClick={() => void sendTestEmail()}
disabled={testEmailSending || !settingsDraft?.smtp_host}
>
{testEmailSending ? "Sending…" : "Send Test"}
</button>
</div>
{testEmailMsg && (
<div
className={`admin-alert ${testEmailMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
>
{testEmailMsg.text}
</div>
)}
</div>
</div>
</div>
<div className="admin-panel"> <div className="admin-panel">
<div className="admin-panel-header"> <div className="admin-panel-header">
<h3>Maintenance</h3> <h3>Maintenance</h3>
@@ -355,28 +647,46 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label"> <label className="admin-setting-label">
<span className="admin-setting-title">Maintenance Mode</span> <span className="admin-setting-title">Maintenance Mode</span>
<span className="admin-setting-desc">Take the site offline for non-admin users</span> <span className="admin-setting-desc">
Take the site offline for non-admin users
</span>
</label> </label>
<button <button
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`} className={`admin-switch${settingsDraft.maintenance_mode === "true" ? " on" : ""}`}
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')} onClick={() =>
updateDraft(
"maintenance_mode",
settingsDraft.maintenance_mode === "true"
? "false"
: "true",
)
}
role="switch" role="switch"
aria-checked={settingsDraft.maintenance_mode === 'true'} aria-checked={settingsDraft.maintenance_mode === "true"}
> >
<span className="admin-switch-thumb" /> <span className="admin-switch-thumb" />
</button> </button>
</div> </div>
{settingsDraft.maintenance_mode === 'true' && ( {settingsDraft.maintenance_mode === "true" && (
<div className="admin-setting-row"> <div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-maint-msg"> <label
<span className="admin-setting-title">Maintenance Message</span> className="admin-setting-label"
<span className="admin-setting-desc">Message displayed to visitors during maintenance</span> htmlFor="setting-maint-msg"
>
<span className="admin-setting-title">
Maintenance Message
</span>
<span className="admin-setting-desc">
Message displayed to visitors during maintenance
</span>
</label> </label>
<textarea <textarea
id="setting-maint-msg" id="setting-maint-msg"
className="textarea admin-setting-textarea" className="textarea admin-setting-textarea"
value={settingsDraft.maintenance_message} value={settingsDraft.maintenance_message}
onChange={(e) => updateDraft('maintenance_message', e.target.value)} onChange={(e) =>
updateDraft("maintenance_message", e.target.value)
}
rows={3} rows={3}
/> />
</div> </div>
@@ -397,14 +707,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void saveSettings()} onClick={() => void saveSettings()}
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
> >
{saving ? 'Saving…' : 'Save Settings'} {saving ? "Saving…" : "Save Settings"}
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ─── Users Tab ─── */} {/* ─── Users Tab ─── */}
{tab === 'users' && ( {tab === "users" && (
<div className="admin-section fade-in"> <div className="admin-section fade-in">
<div className="admin-users-toolbar"> <div className="admin-users-toolbar">
<form onSubmit={handleUserSearch} className="admin-search-form"> <form onSubmit={handleUserSearch} className="admin-search-form">
@@ -414,9 +724,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
value={userSearch} value={userSearch}
onChange={(e) => setUserSearch(e.target.value)} onChange={(e) => setUserSearch(e.target.value)}
/> />
<button className="btn btn-sm" type="submit">Search</button> <button className="btn btn-sm" type="submit">
Search
</button>
</form> </form>
<div className="admin-users-count mono">{usersTotal} user{usersTotal !== 1 ? 's' : ''}</div> <div className="admin-users-count mono">
{usersTotal} user{usersTotal !== 1 ? "s" : ""}
</div>
</div> </div>
<div className="admin-panel"> <div className="admin-panel">
@@ -442,7 +756,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{u.display_name.charAt(0).toUpperCase()} {u.display_name.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<div className="admin-user-name">{u.display_name}</div> <div className="admin-user-name">
{u.display_name}
</div>
<div className="admin-user-email">{u.email}</div> <div className="admin-user-email">{u.email}</div>
</div> </div>
</div> </div>
@@ -453,25 +769,44 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</span> </span>
</td> </td>
<td> <td>
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} /> <span
className={`admin-verified-dot ${u.email_verified ? "yes" : "no"}`}
/>
</td> </td>
<td className="mono">{u.exams_count}</td> <td className="mono">{u.exams_count}</td>
<td className="mono">{u.results_count}</td> <td className="mono">{u.results_count}</td>
<td className="admin-date-cell">{formatDate(u.created_at)}</td> <td className="admin-date-cell">
{formatDate(u.created_at)}
</td>
<td> <td>
<div className="admin-actions"> <div className="admin-actions">
<select <select
className="admin-role-select" className="admin-role-select"
value={u.role} value={u.role}
onChange={(e) => void changeRole(u.id, e.target.value as 'user' | 'admin')} onChange={(e) =>
void changeRole(
u.id,
e.target.value as "user" | "admin",
)
}
> >
<option value="user">User</option> <option value="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
{deleteConfirm === u.id ? ( {deleteConfirm === u.id ? (
<div className="admin-delete-confirm"> <div className="admin-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteUser(u.id)}>Confirm</button> <button
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>Cancel</button> className="btn btn-sm admin-btn-danger"
onClick={() => void deleteUser(u.id)}
>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</button>
</div> </div>
) : ( ) : (
<button <button
@@ -488,7 +823,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
))} ))}
{users.length === 0 && ( {users.length === 0 && (
<tr> <tr>
<td colSpan={7} className="admin-empty">No users found</td> <td colSpan={7} className="admin-empty">
No users found
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -498,5 +835,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div> </div>
)} )}
</div> </div>
) );
} }
+764
View File
@@ -0,0 +1,764 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import type { Exam, Question, QuestionType, Choice, MatchItem } from '../types'
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
{ value: 'multiple-choice', label: 'Multiple Choice' },
{ value: 'multiple-select', label: 'Multiple Select' },
{ value: 'short-answer', label: 'Short Answer' },
{ value: 'translation', label: 'Translation' },
{ value: 'math-input', label: 'Math Input' },
{ value: 'essay', label: 'Essay' },
{ value: 'match', label: 'Match' },
{ value: 'order', label: 'Order' },
]
const BLANK_EXAM: Exam = {
title: 'Untitled Exam',
subtitle: '',
questions: [],
}
function makeBlankQuestion(type: QuestionType, index: number): Question {
const id = `q${index + 1}`
const base: Question = { id, type, prompt: '', points: 1 }
switch (type) {
case 'multiple-choice':
return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: 'a' }
case 'multiple-select':
return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: [] }
case 'short-answer':
case 'translation':
case 'math-input':
return { ...base, answer: '', acceptedForms: [], caseSensitive: false }
case 'essay':
return { ...base, minWords: 0, rubric: [] }
case 'match':
return { ...base, left: [], right: [], answer: {} }
case 'order':
return { ...base, items: [], answer: [] }
default:
return base
}
}
export default function ExamEditor() {
const { id: paramId } = useParams<{ id: string }>()
const navigate = useNavigate()
const isNew = paramId === 'new'
const examId = isNew ? null : Number(paramId)
const [exam, setExam] = useState<Exam>(BLANK_EXAM)
const [selectedIdx, setSelectedIdx] = useState(0)
const [loading, setLoading] = useState(!isNew)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(isNew)
// Load existing exam
useEffect(() => {
if (isNew) return
setLoading(true)
apiFetch<{ exam: Exam }>(`/api/exams/${examId}`)
.then((data) => {
setExam(data.exam)
setHasChanges(false)
})
.catch((e) => setError((e as Error).message))
.finally(() => setLoading(false))
}, [examId, isNew])
const updateExam = useCallback((fn: (draft: Exam) => void) => {
setExam((prev) => {
const draft = structuredClone(prev)
fn(draft)
return draft
})
setHasChanges(true)
setSaveMsg(null)
}, [])
const addQuestion = (type: QuestionType) => {
updateExam((draft) => {
const q = makeBlankQuestion(type, draft.questions.length)
draft.questions.push(q)
})
setSelectedIdx(exam.questions.length)
}
const removeQuestion = (idx: number) => {
updateExam((draft) => {
draft.questions.splice(idx, 1)
})
if (selectedIdx >= idx && selectedIdx > 0) {
setSelectedIdx(selectedIdx - 1)
}
}
const saveExam = async () => {
if (!exam.title.trim()) {
setError('Exam title is required')
return
}
if (exam.questions.length === 0) {
setError('Add at least one question')
return
}
setSaving(true)
setError(null)
try {
if (isNew) {
const data = await apiFetch<{ id: number }>('/api/exams', {
method: 'POST',
body: JSON.stringify({ exam }),
})
setSaveMsg('Exam created successfully')
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await apiFetch(`/api/exams/${examId}`, {
method: 'PUT',
body: JSON.stringify({ exam }),
})
setSaveMsg('Exam saved')
setHasChanges(false)
}
setTimeout(() => setSaveMsg(null), 3000)
} catch (e) {
setError((e as Error).message)
} finally {
setSaving(false)
}
}
const selectedQuestion = exam.questions[selectedIdx]
const truncate = (s: string, n = 40) => (s.length > n ? s.slice(0, n) + '…' : s)
return (
<div className="exam-editor-shell">
{/* Header */}
<header className="exam-editor-header">
<div className="exam-editor-header-left">
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/exams')}>
← Back
</button>
<input
className="exam-editor-title-input"
value={exam.title}
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
placeholder="Exam title"
/>
</div>
<div className="exam-editor-header-right">
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
{hasChanges && <span className="exam-editor-unsaved">Unsaved changes</span>}
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
{saving ? 'Saving…' : isNew ? 'Create Exam' : 'Save'}
</button>
</div>
</header>
{error && (
<div className="workspace-alert workspace-alert-error exam-editor-alert">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="exam-editor-loading">Loading…</div>
) : (
<div className="exam-editor-body">
{/* Sidebar */}
<aside className="exam-editor-sidebar">
<div className="sidebar-section-label">Questions</div>
<div className="nav-list">
{exam.questions.map((q, i) => (
<button
key={q.id}
className={`nav-item${selectedIdx === i ? ' active' : ''}`}
onClick={() => setSelectedIdx(i)}
>
<span className="nav-item-num">{i + 1}</span>
<span className="nav-item-title">{truncate(q.prompt) || 'Untitled'}</span>
<span
className="exam-editor-q-remove"
onClick={(e) => { e.stopPropagation(); removeQuestion(i) }}
title="Remove"
>
✕
</span>
</button>
))}
</div>
<div className="exam-editor-add">
<div className="sidebar-section-label" style={{ marginTop: 24 }}>Add Question</div>
<div className="exam-editor-type-grid">
{QUESTION_TYPES.map((t) => (
<button
key={t.value}
className="exam-editor-type-btn"
onClick={() => addQuestion(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
</aside>
{/* Main Editor */}
<main className="exam-editor-main">
{selectedQuestion ? (
<QuestionEditor
question={selectedQuestion}
onChange={(fn) => {
updateExam((draft) => {
const q = draft.questions[selectedIdx]
if (q) fn(q)
})
}}
/>
) : (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No questions yet</div>
<div className="workspace-empty-desc">Select a question type from the sidebar to add your first question.</div>
</div>
)}
</main>
</div>
)}
</div>
)
}
/* ─── Question Editor ─── */
interface QEProps {
question: Question
onChange: (fn: (q: Question) => void) => void
}
function QuestionEditor({ question, onChange }: QEProps) {
const update = <K extends keyof Question>(key: K, value: Question[K]) => {
onChange((q) => { q[key] = value })
}
return (
<div className="question-form">
<div className="question-form-header">
<span className="mono faint" style={{ fontSize: 12 }}>Question Editor</span>
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
</div>
<div className="question-form-row">
<label className="question-form-label">Prompt</label>
<textarea
className="textarea"
value={question.prompt}
onChange={(e) => update('prompt', e.target.value)}
rows={3}
placeholder="Enter the question prompt..."
/>
</div>
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Category (optional)</label>
<input
className="input"
value={question.category ?? ''}
onChange={(e) => update('category', e.target.value || undefined)}
placeholder="e.g. Algebra"
/>
</div>
<div style={{ width: 100 }}>
<label className="question-form-label">Points</label>
<input
className="input"
type="number"
min={1}
value={question.points ?? 1}
onChange={(e) => update('points', Math.max(1, Number(e.target.value)))}
/>
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Explanation (optional)</label>
<textarea
className="textarea"
value={question.explanation ?? ''}
onChange={(e) => update('explanation', e.target.value || undefined)}
rows={2}
placeholder="Explanation shown after grading..."
/>
</div>
{/* Type-specific editors */}
{question.type === 'multiple-choice' && <MultipleChoiceEditor q={question} onChange={onChange} />}
{question.type === 'multiple-select' && <MultipleSelectEditor q={question} onChange={onChange} />}
{(question.type === 'short-answer' || question.type === 'translation' || question.type === 'math-input') && (
<TextAnswerEditor q={question} onChange={onChange} />
)}
{question.type === 'translation' && (
<div className="question-form-row-inline">
<div>
<label className="question-form-label">Source Language</label>
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder="e.g. English" />
</div>
<div>
<label className="question-form-label">Target Language</label>
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder="e.g. Spanish" />
</div>
</div>
)}
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
</div>
)
}
/* ─── Multiple Choice ─── */
function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const choices = q.choices ?? []
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
const addChoice = () => {
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
setChoices([...choices, { id: nextId, text: '' }])
}
const removeChoice = (idx: number) => {
const next = choices.filter((_, i) => i !== idx)
// Renumber
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
setChoices(renumbered)
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
}
const updateChoice = (idx: number, text: string) => {
const next = [...choices]
next[idx] = { ...next[idx], text }
setChoices(next)
}
return (
<div className="question-form-section">
<label className="question-form-label">Choices</label>
<div className="choice-editor-list">
{choices.map((c, i) => (
<div key={c.id} className="choice-editor-row">
<input
type="radio"
name="mc-answer"
checked={q.answer === c.id}
onChange={() => setAnswer(c.id)}
title="Mark as correct"
/>
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
<input
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
+ Add Choice
</button>
</div>
)
}
/* ─── Multiple Select ─── */
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const choices = q.choices ?? []
const answer = (q.answer as string[]) ?? []
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
const toggleAnswer = (id: string) => {
const next = answer.includes(id) ? answer.filter((a) => a !== id) : [...answer, id]
onChange((q) => { q.answer = next })
}
const addChoice = () => {
const nextId = String.fromCharCode(97 + choices.length)
setChoices([...choices, { id: nextId, text: '' }])
}
const removeChoice = (idx: number) => {
const removedId = choices[idx].id
const next = choices.filter((_, i) => i !== idx)
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
setChoices(renumbered)
onChange((q) => { q.answer = (q.answer as string[]).filter((a) => a !== removedId) })
}
const updateChoice = (idx: number, text: string) => {
const next = [...choices]
next[idx] = { ...next[idx], text }
setChoices(next)
}
return (
<div className="question-form-section">
<label className="question-form-label">Choices (check correct answers)</label>
<div className="choice-editor-list">
{choices.map((c, i) => (
<div key={c.id} className="choice-editor-row">
<input
type="checkbox"
checked={answer.includes(c.id)}
onChange={() => toggleAnswer(c.id)}
/>
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
<input
className="input"
value={c.text}
onChange={(e) => updateChoice(i, e.target.value)}
placeholder={`Choice ${c.id}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
</div>
))}
</div>
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
+ Add Choice
</button>
</div>
)
}
/* ─── Text Answer (short, translation, math) ─── */
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const accepted = q.acceptedForms ?? []
return (
<div className="question-form-section">
<div className="question-form-row">
<label className="question-form-label">Correct Answer</label>
<input
className="input"
value={(q.answer as string) ?? ''}
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
placeholder="Enter the correct answer"
/>
</div>
<div className="question-form-row">
<label className="question-form-label">Accepted Forms (optional alternatives)</label>
{accepted.map((form, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={form}
onChange={(e) => {
const next = [...accepted]
next[i] = e.target.value
onChange((q) => { q.acceptedForms = next })
}}
placeholder="Alternative answer"
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
const next = accepted.filter((_, idx) => idx !== i)
onChange((q) => { q.acceptedForms = next })
}}
>
✕
</button>
</div>
))}
<button
className="btn btn-sm"
onClick={() => onChange((q) => { q.acceptedForms = [...(q.acceptedForms ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Alternative
</button>
</div>
<div className="question-form-row-inline" style={{ marginTop: 8 }}>
<label className="question-form-check">
<input
type="checkbox"
checked={q.caseSensitive ?? false}
onChange={(e) => onChange((q) => { q.caseSensitive = e.target.checked })}
/>
Case sensitive
</label>
<label className="question-form-check">
<input
type="checkbox"
checked={q.ignorePunctuation ?? false}
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
/>
Ignore punctuation
</label>
<label className="question-form-check">
<input
type="checkbox"
checked={q.ignoreAccents ?? false}
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
/>
Ignore accents
</label>
</div>
</div>
)
}
/* ─── Essay ─── */
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const rubric = q.rubric ?? []
return (
<div className="question-form-section">
<div className="question-form-row-inline">
<div style={{ width: 140 }}>
<label className="question-form-label">Min Words</label>
<input
className="input"
type="number"
min={0}
value={q.minWords ?? 0}
onChange={(e) => onChange((q) => { q.minWords = Number(e.target.value) })}
/>
</div>
</div>
<div className="question-form-row">
<label className="question-form-label">Rubric Criteria</label>
{rubric.map((item, i) => (
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item}
onChange={(e) => {
const next = [...rubric]
next[i] = e.target.value
onChange((q) => { q.rubric = next })
}}
placeholder="Criterion"
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
const next = rubric.filter((_, idx) => idx !== i)
onChange((q) => { q.rubric = next })
}}
>
✕
</button>
</div>
))}
<button
className="btn btn-sm"
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
style={{ marginTop: 4 }}
>
+ Add Criterion
</button>
</div>
</div>
)
}
/* ─── Match ─── */
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const left = q.left ?? []
const right = q.right ?? []
const answer = (q.answer as Record<string, string>) ?? {}
const addLeft = () => {
const id = `l${left.length + 1}`
onChange((q) => { q.left = [...left, { id, text: '' }] })
}
const addRight = () => {
const id = `r${right.length + 1}`
onChange((q) => { q.right = [...right, { id, text: '' }] })
}
const removeLeft = (idx: number) => {
const removed = left[idx]
onChange((q) => {
q.left = left.filter((_, i) => i !== idx)
const ans = { ...(q.answer as Record<string, string>) }
delete ans[removed.id]
q.answer = ans
})
}
const removeRight = (idx: number) => {
const removed = right[idx]
onChange((q) => {
q.right = right.filter((_, i) => i !== idx)
const ans = { ...(q.answer as Record<string, string>) }
for (const k of Object.keys(ans)) {
if (ans[k] === removed.id) delete ans[k]
}
q.answer = ans
})
}
return (
<div className="question-form-section">
<div className="match-editor-grid">
<div>
<label className="question-form-label">Left Column</label>
{left.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item.text}
onChange={(e) => {
const next = [...left]
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.left = next })
}}
placeholder={`Left item ${i + 1}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>+ Add Left</button>
</div>
<div>
<label className="question-form-label">Right Column</label>
{right.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<input
className="input"
value={item.text}
onChange={(e) => {
const next = [...right]
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.right = next })
}}
placeholder={`Right item ${i + 1}`}
/>
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
</div>
))}
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>+ Add Right</button>
</div>
</div>
{left.length > 0 && right.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">Correct Pairings</label>
{left.map((l) => (
<div key={l.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<span style={{ minWidth: 120, fontSize: 14 }}>{l.text || l.id}</span>
<span className="faint">→</span>
<select
className="input"
style={{ width: 200 }}
value={answer[l.id] ?? ''}
onChange={(e) => {
const next = { ...answer, [l.id]: e.target.value }
if (!e.target.value) delete next[l.id]
onChange((q) => { q.answer = next })
}}
>
<option value="">— Select —</option>
{right.map((r) => (
<option key={r.id} value={r.id}>{r.text || r.id}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
)
}
/* ─── Order ─── */
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
const items = q.items ?? []
const answer = (q.answer as string[]) ?? []
const addItem = () => {
const id = `s${items.length + 1}`
onChange((q) => { q.items = [...items, { id, text: '' }] })
}
const removeItem = (idx: number) => {
const removed = items[idx]
onChange((q) => {
q.items = items.filter((_, i) => i !== idx)
q.answer = answer.filter((a) => a !== removed.id)
})
}
const moveItem = (idx: number, dir: -1 | 1) => {
const next = [...items]
const swap = idx + dir
if (swap < 0 || swap >= next.length) return
;[next[idx], next[swap]] = [next[swap], next[idx]]
onChange((q) => { q.items = next })
}
return (
<div className="question-form-section">
<label className="question-form-label">Items (drag not implemented, use arrows to reorder)</label>
{items.map((item, i) => (
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
<span className="mono faint" style={{ fontSize: 12, width: 24 }}>{i + 1}</span>
<input
className="input"
value={item.text}
onChange={(e) => {
const next = [...items]
next[i] = { ...next[i], text: e.target.value }
onChange((q) => { q.items = next })
}}
placeholder={`Item ${i + 1}`}
/>
<div className="order-editor-controls">
<button className="btn btn-ghost btn-sm" disabled={i === 0} onClick={() => moveItem(i, -1)}>↑</button>
<button className="btn btn-ghost btn-sm" disabled={i === items.length - 1} onClick={() => moveItem(i, 1)}>↓</button>
<button className="btn btn-ghost btn-sm" onClick={() => removeItem(i)}>✕</button>
</div>
</div>
))}
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>+ Add Item</button>
{items.length > 0 && (
<div style={{ marginTop: 16 }}>
<label className="question-form-label">Correct Order</label>
<div className="choice-editor-row">
{items.map((item, i) => (
<span key={item.id} className="mono faint" style={{ fontSize: 13, marginRight: 4 }}>
{i + 1}. {item.text || item.id}
</span>
))}
</div>
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
The correct order is the order shown above. Reorder items to set the correct sequence.
</p>
{/* Sync answer to current item order */}
<button
className="btn btn-sm"
style={{ marginTop: 8 }}
onClick={() => onChange((q) => { q.answer = items.map((it) => it.id) })}
>
Set Current Order as Correct
</button>
</div>
)}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
"use no memo";
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 { getExamUrlId } from '../utils/slug'
import type { Exam, ExamState } from '../types'
interface Props {
exam: Exam | null
examState: ExamState | null
onActivate: (exam: Exam, state: ExamState) => void
setExamState: Dispatch<SetStateAction<ExamState>>
onSubmit: () => void
onExit: () => void
}
export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
const { slug, n } = useParams<{ slug: string; n: string }>()
const navigate = useNavigate()
const questionIdx = Math.max(0, (Number((n ?? '').replace(/^q/, '')) || 1) - 1)
// Deep link: restore from localStorage if no exam is active on mount
useEffect(() => {
if (!exam) {
const saved = loadState()
if (saved?.exam && getExamUrlId(saved.exam) === 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
key={n}
exam={exam}
state={examState}
setState={setExamState}
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
onSubmit={onSubmit}
onExit={onExit}
n={n}
/>
)
}
+231 -107
View File
@@ -1,107 +1,148 @@
import { useState, useEffect, useCallback } from 'react' "use no memo";
import type { Exam, ExamState, Question } from '../types' import { useState, useEffect, useCallback } from "react";
import { gradeQuestion } from '../utils/grading' import type { Exam, ExamState, Question } from "../types";
import { formatTime } from '../utils/formatting' import { gradeQuestion } from "../utils/grading";
import Tex from './Tex' import { formatTime } from "../utils/formatting";
import QuestionMedia from './QuestionMedia' import Tex from "./Tex";
import QuestionRenderer from './questions/QuestionRenderer' import QuestionMedia from "./QuestionMedia";
import QuestionRenderer from "./questions/QuestionRenderer";
interface Props { interface Props {
exam: Exam exam: Exam;
state: ExamState state: ExamState;
setState: React.Dispatch<React.SetStateAction<ExamState>> setState: React.Dispatch<React.SetStateAction<ExamState>>;
onSubmit: () => void onSubmit: () => void;
onExit: () => void onExit: () => void;
onNavigate?: (i: number) => void;
n?: string;
} }
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly<Props>) { export default function ExamRunner({
const { currentIdx, answers, flags, startedAt, feedbackMode } = state exam,
const q = exam.questions[currentIdx] state,
const total = exam.questions.length setState,
const notes = state.notes ?? '' onSubmit,
onExit,
onNavigate,
n,
}: Readonly<Props>) {
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
const { answers, flags, startedAt, feedbackMode } = state;
const currentIdx = urlIdx;
const q = exam.questions[currentIdx];
const total = exam.questions.length;
const notes = state.notes ?? "";
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]) const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed]) useEffect(() => {
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const setAnswer = (val: unknown) => const setAnswer = (val: unknown) =>
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } })) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
const goTo = useCallback((i: number) => const goTo = useCallback(
setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total]) (i: number) => {
const next = Math.max(0, Math.min(total - 1, i));
setState((s) => ({ ...s, currentIdx: next }));
onNavigate?.(next);
},
[total, onNavigate],
);
const toggleFlag = useCallback(() => const toggleFlag = useCallback(
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id]) () =>
setState((s) => ({
...s,
flags: { ...s.flags, [q.id]: !s.flags[q.id] },
})),
[q.id],
);
const updateNotes = (value: string) => const updateNotes = (value: string) =>
setState((s) => ({ ...s, notes: value.slice(0, 1000) })) setState((s) => ({ ...s, notes: value.slice(0, 1000) }));
const checkAnswer = () => { const checkAnswer = () => {
setState((s) => ({ setState((s) => ({
...s, ...s,
revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true }, revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true },
})) }));
setRevealed(true) setRevealed(true);
} };
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now());
useEffect(() => { useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000) const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t) return () => clearInterval(t);
}, []) }, []);
const elapsedMs = now - startedAt const elapsedMs = now - startedAt;
const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null const remainingMs = exam.timeLimitMinutes
const timeUrgent = remainingMs != null && remainingMs < 60_000 ? exam.timeLimitMinutes * 60_000 - elapsedMs
: null;
const timeUrgent = remainingMs != null && remainingMs < 60_000;
useEffect(() => { useEffect(() => {
if (remainingMs != null && remainingMs <= 0) onSubmit() if (remainingMs != null && remainingMs <= 0) onSubmit();
}, [remainingMs, onSubmit]) }, [remainingMs, onSubmit]);
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1) if (e.key === "ArrowRight" || e.key === "j") goTo(currentIdx + 1);
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1) if (e.key === "ArrowLeft" || e.key === "k") goTo(currentIdx - 1);
if (e.key === 'f') toggleFlag() if (e.key === "f") toggleFlag();
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) { if (/^[1-9]$/.test(e.key) && q.type === "multiple-choice" && q.choices) {
const ci = Number.parseInt(e.key, 10) - 1 const ci = Number.parseInt(e.key, 10) - 1;
const choices = q.choices const choices = q.choices;
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } })) if (choices[ci])
setState((s) => ({
...s,
answers: { ...s.answers, [q.id]: choices[ci].id },
}));
} }
} };
globalThis.addEventListener('keydown', handler) globalThis.addEventListener("keydown", handler);
return () => globalThis.removeEventListener('keydown', handler) return () => globalThis.removeEventListener("keydown", handler);
}, [currentIdx, q, goTo, toggleFlag, setState]) }, [currentIdx, q, goTo, toggleFlag, setState]);
const graded = feedbackMode === 'immediate' && revealed const graded = feedbackMode === "immediate" && revealed;
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0) const answered =
const result = graded ? gradeQuestion(q, answers[q.id]) : null answers[q.id] != null &&
(!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0);
const result = graded ? gradeQuestion(q, answers[q.id]) : null;
const navStatus = (qq: Question): string => { const navStatus = (qq: Question): string => {
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) { if (feedbackMode === "immediate" && state.revealed?.[qq.id]) {
const r = gradeQuestion(qq, answers[qq.id]) const r = gradeQuestion(qq, answers[qq.id]);
if (r.status === 'correct') return 'correct' if (r.status === "correct") return "correct";
if (r.status === 'wrong') return 'wrong' if (r.status === "wrong") return "wrong";
// fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay) // fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay)
} }
if (flags[qq.id]) return 'flagged' if (flags[qq.id]) return "flagged";
const a = answers[qq.id] const a = answers[qq.id];
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered' if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0))
return '' return "answered";
} return "";
};
const answeredCount = exam.questions.filter((qq) => { const answeredCount = exam.questions.filter((qq) => {
const st = navStatus(qq) const st = navStatus(qq);
return st === 'answered' || st === 'correct' || st === 'wrong' return st === "answered" || st === "correct" || st === "wrong";
}).length }).length;
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0) const totalPossible = exam.questions.reduce(
let answerText = '' (acc, qq) => acc + (qq.points ?? 1),
if (!Array.isArray(q.answer) && typeof q.answer === 'string') { 0,
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer );
let answerText = "";
if (!Array.isArray(q.answer) && typeof q.answer === "string") {
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer;
} else if (Array.isArray(q.answer)) { } else if (Array.isArray(q.answer)) {
answerText = q.answer.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ') answerText = q.answer
.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id)
.join(", ");
} }
return ( return (
@@ -113,78 +154,155 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
<div className="sidebar-section-label">Questions</div> <div className="sidebar-section-label">Questions</div>
<div className="nav-list"> <div className="nav-list">
{exam.questions.map((qq, i) => { {exam.questions.map((qq, i) => {
const st = navStatus(qq) const st = navStatus(qq);
return ( return (
<button key={qq.id} className={`nav-item${i === currentIdx ? ' active' : ''}`} onClick={() => goTo(i)}> <button
<span className="nav-item-num">{String(i + 1).padStart(2, '0')}</span> key={qq.id}
<span className="nav-item-title"> className={`nav-item${i === currentIdx ? " active" : ""}`}
{qq.category ? `${qq.category} · ` : ''}{qq.type.replace('-', ' ')} onClick={() => goTo(i)}
>
<span className="nav-item-num">
{String(i + 1).padStart(2, "0")}
</span> </span>
<span className={`nav-dot${st ? ' ' + st : ''}`} /> <span className="nav-item-title">
{qq.category ? `${qq.category} · ` : ""}
{qq.type.replace("-", " ")}
</span>
<span className={`nav-dot${st ? " " + st : ""}`} />
</button> </button>
) );
})} })}
</div> </div>
<div className="sidebar-section-label">Progress</div> <div className="sidebar-section-label">Progress</div>
<div className="mono" style={{ fontSize: 13, color: 'var(--ink-2)' }}> <div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
{answeredCount} of {total} answered {answeredCount} of {total} answered
</div> </div>
<div className="progress-bar"> <div className="progress-bar">
<div style={{ width: `${(answeredCount / total) * 100}%` }} /> <div style={{ width: `${(answeredCount / total) * 100}%` }} />
</div> </div>
<div style={{ marginTop: 32, display: 'flex', flexDirection: 'column', gap: 8 }}> <div
<button className="btn btn-primary" onClick={onSubmit}>Submit exam</button> style={{
<button className="btn btn-ghost btn-sm" onClick={onExit}>Exit to home</button> marginTop: 32,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
</button>
<button className="btn btn-ghost btn-sm" onClick={onExit}>
Exit to home
</button>
</div> </div>
</aside> </aside>
<main className="main"> <main className="main">
<header className="question-header"> <header className="question-header">
<span className="q-index">Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}</span> <span className="q-index">
Question {String(currentIdx + 1).padStart(2, "0")} /{" "}
{String(total).padStart(2, "0")}
</span>
{q.category && <span className="q-category">{q.category}</span>} {q.category && <span className="q-category">{q.category}</span>}
<span className="q-points">{q.points ?? 1} {(q.points ?? 1) === 1 ? 'pt' : 'pts'}</span> <span className="q-points">
{q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"}
</span>
</header> </header>
<Tex tag="div" className="q-prompt">{q.prompt}</Tex> <Tex tag="div" className="q-prompt">
{q.prompt}
</Tex>
<QuestionMedia q={q} /> <QuestionMedia q={q} />
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={setAnswer} locked={graded && q.type !== 'essay'} graded={graded} /> <QuestionRenderer
key={q.id}
q={q}
value={answers[q.id]}
onChange={setAnswer}
locked={graded && q.type !== "essay"}
graded={graded}
/>
{graded && result && q.type !== 'essay' && ( {graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? 'correct' : 'wrong'}`}> <div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label">{result.correct ? 'Correct' : 'Not quite'}</div> <div className="feedback-label">
{result.correct ? "Correct" : "Not quite"}
</div>
<div className="feedback-body"> <div className="feedback-body">
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && ( {!result.correct &&
q.answer &&
q.type !== "match" &&
q.type !== "order" && (
<p>
<strong>Answer:</strong> <Tex>{answerText}</Tex>
</p>
)}
{q.explanation && (
<p> <p>
<strong>Answer:</strong>{' '} <Tex>{q.explanation}</Tex>
<Tex>{answerText}</Tex>
</p> </p>
)} )}
{q.explanation && <p><Tex>{q.explanation}</Tex></p>}
</div> </div>
</div> </div>
)} )}
<footer className="exam-footer"> <footer className="exam-footer">
<button className="btn" onClick={() => goTo(currentIdx - 1)} disabled={currentIdx === 0}>← Previous</button> <button
className="btn"
onClick={() => goTo(currentIdx - 1)}
disabled={currentIdx === 0}
>
← Previous
</button>
<button className="btn btn-ghost" onClick={toggleFlag}> <button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'} {flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"}
</button> </button>
<span className="spacer" /> <span className="spacer" />
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && ( {feedbackMode === "immediate" &&
<button className="btn btn-primary" onClick={checkAnswer}>Check answer</button> !revealed &&
q.type !== "essay" &&
answered && (
<button className="btn btn-primary" onClick={checkAnswer}>
Check answer
</button>
)}
{currentIdx < total - 1 ? (
<button
className="btn btn-primary"
onClick={() => goTo(currentIdx + 1)}
>
Next →
</button>
) : (
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
</button>
)} )}
{currentIdx < total - 1
? <button className="btn btn-primary" onClick={() => goTo(currentIdx + 1)}>Next →</button>
: <button className="btn btn-primary" onClick={onSubmit}>Submit exam</button>}
</footer> </footer>
<div style={{ marginTop: 48, fontSize: 11.5, color: 'var(--ink-4)', display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div
<span><span className="kbd">←</span> <span className="kbd">→</span> navigate</span> style={{
<span><span className="kbd">F</span> flag</span> marginTop: 48,
{q.type === 'multiple-choice' && q.choices && ( fontSize: 11.5,
<span><span className="kbd">1</span>–<span className="kbd">{q.choices.length}</span> select</span> color: "var(--ink-4)",
display: "flex",
gap: 16,
flexWrap: "wrap",
}}
>
<span>
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
navigate
</span>
<span>
<span className="kbd">F</span> flag
</span>
{q.type === "multiple-choice" && q.choices && (
<span>
<span className="kbd">1</span>–
<span className="kbd">{q.choices.length}</span> select
</span>
)} )}
</div> </div>
</main> </main>
@@ -197,7 +315,11 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{remainingMs != null && ( {remainingMs != null && (
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Remaining</div> <div className="meta-label">Remaining</div>
<div className={`meta-value mono${timeUrgent ? ' timer-urgent' : ''}`}>{formatTime(Math.max(0, remainingMs))}</div> <div
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
>
{formatTime(Math.max(0, remainingMs))}
</div>
</div> </div>
)} )}
<div className="meta-block"> <div className="meta-block">
@@ -207,7 +329,9 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
<div className="meta-block"> <div className="meta-block">
<div className="meta-label">Mode</div> <div className="meta-label">Mode</div>
<div className="meta-value mono" style={{ fontSize: 13 }}> <div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'} {feedbackMode === "immediate"
? "Immediate feedback"
: "Grade at end"}
</div> </div>
</div> </div>
<div className="meta-block notes-block"> <div className="meta-block notes-block">
@@ -230,5 +354,5 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
)} )}
</aside> </aside>
</div> </div>
) );
} }
+5 -1
View File
@@ -395,7 +395,11 @@ Set a 20-minute time limit. Include explanations for each answer."
if (f) void handleFile(f) if (f) void handleFile(f)
}} }}
> >
<div className="dropzone-icon" /> <div className="dropzone-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<h3>Drop your exam JSON here</h3> <h3>Drop your exam JSON here</h3>
<p>or click to browse · max 5 MB</p> <p>or click to browse · max 5 MB</p>
<input <input
+137
View File
@@ -0,0 +1,137 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import type { Exam } from '../types'
interface SavedExam {
id: number
title: string
subtitle: string | null
question_count: number
created_at: number
updated_at: number
}
export default function MyExams() {
const navigate = useNavigate()
const [exams, setExams] = useState<SavedExam[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const loadExams = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<SavedExam[]>('/api/exams')
setExams(data)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadExams()
}, [loadExams])
const deleteExam = async (id: number) => {
try {
await apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
setDeleteConfirm(null)
void loadExams()
} catch (e) {
setError((e as Error).message)
}
}
const createNewExam = () => {
navigate('/exam-editor/new')
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">My Exams</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ New Exam
</button>
</div>
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">Loading…</div>
) : exams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">No saved exams yet</div>
<div className="workspace-empty-desc">
Upload an exam from the home page, or create a new one to get started.
</div>
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
Create Your First Exam
</button>
</div>
) : (
<div className="workspace-list">
{exams.map((exam) => (
<div key={exam.id} className="workspace-card">
<div className="workspace-card-body">
<div className="workspace-card-title">{exam.title}</div>
{exam.subtitle && (
<div className="workspace-card-sub">{exam.subtitle}</div>
)}
<div className="workspace-card-meta">
<span>{exam.question_count} question{exam.question_count !== 1 ? 's' : ''}</span>
<span>·</span>
<span>Updated {formatDate(exam.updated_at)}</span>
</div>
</div>
<div className="workspace-card-actions">
<button
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
>
Edit
</button>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
Confirm
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
Cancel
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title="Delete exam"
>
✕
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import { getExamUrlId } from '../utils/slug'
import type { Exam, ExamState } from '../types'
interface ResultItem {
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
}
export default function MyResults() {
const navigate = useNavigate()
const [results, setResults] = useState<ResultItem[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const loadResults = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<{ results: ResultItem[]; total: number }>('/api/results')
setResults(data.results)
setTotal(data.total)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadResults()
}, [loadResults])
const deleteResult = async (id: number) => {
try {
await apiFetch(`/api/results/${id}`, { method: 'DELETE' })
setDeleteConfirm(null)
void loadResults()
} catch (e) {
setError((e as Error).message)
}
}
const reviewResult = async (item: ResultItem) => {
try {
const data = await apiFetch<{
exam: Exam
state: ExamState
title: string
scorePoints: number
totalPoints: number
correctCount: number
gradedCount: number
passingScore: number
elapsedMs: number
completedAt: number
}>(`/api/results/${item.id}`)
// Store in session for review - we'll navigate to review route
// Use localStorage as a bridge since review expects exam/examState in App state
localStorage.setItem('brainwave.review.exam', JSON.stringify(data.exam))
localStorage.setItem('brainwave.review.state', JSON.stringify({
...data.state,
completedAt: data.completedAt * 1000,
}))
navigate(`/review/${getExamUrlId(data.exam)}`)
} catch (e) {
setError((e as Error).message)
}
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const formatTime = (ms: number) => {
const m = Math.floor(ms / 60000)
const s = Math.floor((ms % 60000) / 1000)
return `${m}m ${s}s`
}
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">Results</h2>
</div>
<div className="mono muted" style={{ fontSize: 13 }}>{total} attempt{total !== 1 ? 's' : ''}</div>
</div>
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">Loading…</div>
) : results.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">✦</div>
<div className="workspace-empty-title">No attempts yet</div>
<div className="workspace-empty-desc">
Take an exam to see your results here.
</div>
</div>
) : (
<div className="workspace-list">
{results.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
const passed = pct >= (r.passing_score ?? 60)
return (
<div key={r.id} className="workspace-card">
<div className="workspace-card-body">
<div className="workspace-card-title">{r.title}</div>
<div className="workspace-card-meta">
<span>{formatDate(r.completed_at)}</span>
<span>·</span>
<span>{formatTime(r.elapsed_ms)}</span>
<span>·</span>
<span>{r.correct_count}/{r.graded_count} correct</span>
</div>
</div>
<div className="workspace-card-actions" style={{ gap: 8 }}>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
Review
</button>
{deleteConfirm === r.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
Confirm
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
Cancel
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(r.id)}
title="Delete result"
>
✕
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { useState, useCallback } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { apiFetch } from '../utils/api'
export default function Settings() {
const { user, refreshUser } = useAuth()
const [displayName, setDisplayName] = useState(user?.displayName ?? '')
const [savingName, setSavingName] = useState(false)
const [nameMsg, setNameMsg] = useState<{ ok: boolean; text: string } | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [savingPassword, setSavingPassword] = useState(false)
const [passwordMsg, setPasswordMsg] = useState<{ ok: boolean; text: string } | null>(null)
const updateDisplayName = useCallback(async () => {
const trimmed = displayName.trim()
if (!trimmed || trimmed.length > 100) {
setNameMsg({ ok: false, text: 'Display name is required (max 100 characters)' })
return
}
setSavingName(true)
setNameMsg(null)
try {
await apiFetch('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ displayName: trimmed }),
})
setNameMsg({ ok: true, text: 'Display name updated' })
await refreshUser()
} catch (e) {
setNameMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingName(false)
}
}, [displayName, refreshUser])
const changePassword = useCallback(async () => {
if (newPassword.length < 8) {
setPasswordMsg({ ok: false, text: 'New password must be at least 8 characters' })
return
}
if (newPassword !== confirmPassword) {
setPasswordMsg({ ok: false, text: 'Passwords do not match' })
return
}
setSavingPassword(true)
setPasswordMsg(null)
try {
await apiFetch('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
setPasswordMsg({ ok: true, text: 'Password changed successfully' })
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (e) {
setPasswordMsg({ ok: false, text: (e as Error).message })
} finally {
setSavingPassword(false)
}
}, [currentPassword, newPassword, confirmPassword])
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">My Workspace</div>
<h2 className="workspace-section-title">Settings</h2>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>Profile</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-display-name">
<span className="workspace-setting-title">Display Name</span>
<span className="workspace-setting-desc">Your name shown throughout the app</span>
</label>
<input
id="settings-display-name"
className="input workspace-setting-input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-email">
<span className="workspace-setting-title">Email</span>
<span className="workspace-setting-desc">Your email address</span>
</label>
<input
id="settings-email"
className="input workspace-setting-input"
value={user?.email ?? ''}
readOnly
disabled
style={{ opacity: 0.6 }}
/>
</div>
{nameMsg && (
<div className={`workspace-alert ${nameMsg.ok ? 'workspace-alert-success' : 'workspace-alert-error'}`}>
{nameMsg.text}
</div>
)}
<div className="workspace-settings-footer">
<button
className="btn btn-primary"
onClick={() => void updateDisplayName()}
disabled={savingName || displayName.trim() === user?.displayName}
>
{savingName ? 'Saving…' : 'Save Profile'}
</button>
</div>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-panel-header">
<h3>Security</h3>
</div>
<div className="workspace-panel-body workspace-settings-form">
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-current-password">
<span className="workspace-setting-title">Current Password</span>
</label>
<input
id="settings-current-password"
className="input workspace-setting-input"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-new-password">
<span className="workspace-setting-title">New Password</span>
<span className="workspace-setting-desc">Minimum 8 characters</span>
</label>
<input
id="settings-new-password"
className="input workspace-setting-input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="workspace-setting-row">
<label className="workspace-setting-label" htmlFor="settings-confirm-password">
<span className="workspace-setting-title">Confirm New Password</span>
</label>
<input
id="settings-confirm-password"
className="input workspace-setting-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{passwordMsg && (
<div className={`workspace-alert ${passwordMsg.ok ? 'workspace-alert-success' : 'workspace-alert-error'}`}>
{passwordMsg.text}
</div>
)}
<div className="workspace-settings-footer">
<button
className="btn btn-primary"
onClick={() => void changePassword()}
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
>
{savingPassword ? 'Changing…' : 'Change Password'}
</button>
</div>
</div>
</div>
</div>
)
}
+313
View File
@@ -0,0 +1,313 @@
import { useState } from 'react'
import { apiFetch, setToken } from '../utils/api'
interface Props {
onComplete: () => void
}
export default function SetupWizard({ onComplete }: Readonly<Props>) {
const [step, setStep] = useState(1)
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [smtpHost, setSmtpHost] = useState('')
const [smtpPort, setSmtpPort] = useState('587')
const [smtpUser, setSmtpUser] = useState('')
const [smtpPass, setSmtpPass] = useState('')
const [smtpFrom, setSmtpFrom] = useState('')
const [smtpSecure, setSmtpSecure] = useState('auto')
const [appUrl, setAppUrl] = useState(window.location.origin)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const validateStep1 = () => {
if (!email.trim() || !email.includes('@')) {
setError('Please enter a valid email address')
return false
}
if (!displayName.trim()) {
setError('Display name is required')
return false
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return false
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return false
}
return true
}
const handleNext = () => {
setError(null)
if (validateStep1()) setStep(2)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const data = await apiFetch<{
message: string
token: string
user: {
id: number
email: string
displayName: string
role: 'user' | 'admin'
}
}>('/api/setup', {
method: 'POST',
body: JSON.stringify({
email,
password,
displayName: displayName.trim(),
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom: smtpFrom || 'BrainWave <noreply@brainwave.local>',
smtpSecure,
appUrl,
}),
})
setToken(data.token)
onComplete()
} catch (err) {
setError(
err instanceof Error ? err.message : 'Setup failed. Please try again.',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="setup-page">
<div className="setup-card">
<div className="brand-mark" style={{ margin: '0 auto 16px' }} />
<h1 className="setup-title">Welcome to BrainWave</h1>
<p className="setup-subtitle">
Let's get your instance set up. This only takes a minute.
</p>
<div className="setup-steps">
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
<span className="setup-step-num">1</span>
<span className="setup-step-label">Admin Account</span>
</div>
<div className="setup-step-line" />
<div className={`setup-step ${step >= 2 ? 'active' : ''}`}>
<span className="setup-step-num">2</span>
<span className="setup-step-label">Email Settings</span>
</div>
</div>
{error && <div className="auth-error">{error}</div>}
{step === 1 && (
<>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-email">
Email
</label>
<input
id="setup-email"
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
autoComplete="email"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-name">
Display Name
</label>
<input
id="setup-name"
className="input"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
required
autoComplete="name"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-password">
Password
</label>
<input
id="setup-password"
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-confirm">
Confirm Password
</label>
<input
id="setup-confirm"
className="input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<button
className="btn btn-primary auth-submit"
onClick={handleNext}
style={{ marginTop: 8 }}
>
Continue →
</button>
</>
)}
{step === 2 && (
<form onSubmit={(e) => void handleSubmit(e)}>
<p className="setup-hint">
Configure your outgoing email server so BrainWave can send
verification and password-reset emails.
</p>
<p className="setup-hint">
You can leave these blank and configure them later in the admin
dashboard.
</p>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-host">
SMTP Host
</label>
<input
id="smtp-host"
className="input"
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.example.com"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-port">
SMTP Port
</label>
<input
id="smtp-port"
className="input"
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder="587"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-user">
SMTP Username
</label>
<input
id="smtp-user"
className="input"
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-pass">
SMTP Password
</label>
<input
id="smtp-pass"
className="input"
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="••••••••"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-from">
From Address
</label>
<input
id="smtp-from"
className="input"
type="text"
value={smtpFrom}
onChange={(e) => setSmtpFrom(e.target.value)}
placeholder="BrainWave <noreply@brainwave.local>"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-secure">
Security
</label>
<select
id="smtp-secure"
className="input"
value={smtpSecure}
onChange={(e) => setSmtpSecure(e.target.value)}
>
<option value="auto">Auto (use SSL on port 465)</option>
<option value="true">SSL/TLS</option>
<option value="false">None / STARTTLS</option>
</select>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="app-url">
App URL
</label>
<input
id="app-url"
className="input"
type="url"
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://brainwave.example.com"
required
/>
</div>
<div className="setup-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => setStep(1)}
>
← Back
</button>
<button
className="btn btn-primary auth-submit"
type="submit"
disabled={submitting}
>
{submitting ? 'Setting up…' : 'Complete Setup'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useParams, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { apiFetch } from '../utils/api'
import MyExams from './MyExams'
import MyResults from './MyResults'
import Settings from './Settings'
type TabKey = 'exams' | 'results' | 'settings'
const TABS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'exams', label: 'My Exams', icon: '◇' },
{ key: 'results', label: 'Results', icon: '✦' },
{ key: 'settings', label: 'Settings', icon: '⚙' },
]
export default function Workspace() {
const { user, loading: authLoading } = useAuth()
const navigate = useNavigate()
const { tab } = useParams<{ tab: string }>()
const activeTab = (tab as TabKey) ?? 'exams'
const [examCount, setExamCount] = useState(0)
const [resultCount, setResultCount] = useState(0)
const loadCounts = useCallback(async () => {
try {
const exams = await apiFetch<{ id: number }[]>('/api/exams')
setExamCount(exams.length)
const results = await apiFetch<{ results: unknown[]; total: number }>('/api/results')
setResultCount(results.total)
} catch {
// silently ignore
}
}, [])
useEffect(() => {
if (user) void loadCounts()
}, [user, loadCounts])
if (authLoading) return null
if (!user) return <Navigate to="/" replace />
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
return (
<div className="workspace-shell">
<aside className="workspace-sidebar">
<div className="workspace-profile">
<div className="workspace-avatar">
{user.displayName.charAt(0).toUpperCase()}
</div>
<div className="workspace-name">{user.displayName}</div>
<div className="workspace-email">{user.email}</div>
</div>
<nav className="workspace-nav">
{TABS.map((t) => (
<button
key={t.key}
className={`workspace-nav-item${activeTab === t.key ? ' active' : ''}`}
onClick={() => setTab(t.key)}
>
<span className="workspace-nav-icon">{t.icon}</span>
<span className="workspace-nav-label">{t.label}</span>
{t.key === 'exams' && examCount > 0 && (
<span className="workspace-nav-badge">{examCount}</span>
)}
{t.key === 'results' && resultCount > 0 && (
<span className="workspace-nav-badge">{resultCount}</span>
)}
</button>
))}
</nav>
</aside>
<main className="workspace-main">
{activeTab === 'exams' && <MyExams />}
{activeTab === 'results' && <MyResults />}
{activeTab === 'settings' && <Settings />}
</main>
</div>
)
}
+19 -6
View File
@@ -12,6 +12,8 @@ export interface User {
interface AuthContextValue { interface AuthContextValue {
user: User | null user: User | null
loading: boolean loading: boolean
needsSetup: boolean
setUser: (user: User | null) => void
login: (email: string, password: string) => Promise<void> login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName: string) => Promise<string> register: (email: string, password: string, displayName: string) => Promise<string>
logout: () => void logout: () => void
@@ -23,14 +25,25 @@ const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
try { try {
// Always check setup status first
const setupStatus = await apiFetch<{ needsSetup: boolean }>('/api/setup/status')
if (setupStatus.needsSetup) {
setNeedsSetup(true)
setUser(null)
setLoading(false)
return
}
setNeedsSetup(false)
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me') const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' }) setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
} catch { } catch {
@@ -68,7 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []) }, [])
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}> <AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
+2511 -1271
View File
File diff suppressed because it is too large. Load diff
+11 -8
View File
@@ -1,11 +1,14 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import 'katex/dist/katex.min.css' import { BrowserRouter } from "react-router-dom";
import './index.css' import "katex/dist/katex.min.css";
import App from './App.tsx' import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>, </StrictMode>,
) );
+83 -83
View File
@@ -1,125 +1,125 @@
// src/types.ts // src/types.ts
export type QuestionType = export type QuestionType =
| 'multiple-choice' | "multiple-choice"
| 'multiple-select' | "multiple-select"
| 'short-answer' | "short-answer"
| 'translation' | "translation"
| 'math-input' | "math-input"
| 'essay' | "essay"
| 'match' | "match"
| 'order' | "order";
export interface Choice { export interface Choice {
id: string id: string;
text: string text: string;
} }
export interface MatchItem { export interface MatchItem {
id: string id: string;
text: string text: string;
} }
export interface QuestionImage { export interface QuestionImage {
src: string src: string;
alt?: string alt?: string;
caption?: string caption?: string;
} }
export interface QuestionAudio { export interface QuestionAudio {
src: string src: string;
} }
export interface QuestionCode { export interface QuestionCode {
language: string language: string;
content: string content: string;
} }
export interface QuestionTable { export interface QuestionTable {
headers: string[] headers: string[];
rows: string[][] rows: string[][];
} }
export interface Question { export interface Question {
id: string id: string;
type: QuestionType type: QuestionType;
category?: string category?: string;
points?: number points?: number;
prompt: string prompt: string;
explanation?: string explanation?: string;
image?: QuestionImage image?: QuestionImage;
audio?: QuestionAudio audio?: QuestionAudio;
code?: QuestionCode code?: QuestionCode;
table?: QuestionTable table?: QuestionTable;
svg?: string svg?: string;
answer?: string | string[] | Record<string, string> answer?: string | string[] | Record<string, string>;
acceptedForms?: string[] acceptedForms?: string[];
caseSensitive?: boolean caseSensitive?: boolean;
ignorePunctuation?: boolean ignorePunctuation?: boolean;
ignoreAccents?: boolean ignoreAccents?: boolean;
sourceLanguage?: string sourceLanguage?: string;
targetLanguage?: string targetLanguage?: string;
choices?: Choice[] choices?: Choice[];
minWords?: number minWords?: number;
rubric?: string[] rubric?: string[];
left?: MatchItem[] left?: MatchItem[];
right?: MatchItem[] right?: MatchItem[];
items?: MatchItem[] items?: MatchItem[];
} }
export interface Exam { export interface Exam {
title: string id?: string;
subtitle?: string title: string;
author?: string subtitle?: string;
timeLimitMinutes?: number | null author?: string;
feedbackMode?: 'end' | 'immediate' timeLimitMinutes?: number | null;
passingScore?: number feedbackMode?: "end" | "immediate";
categories?: string[] passingScore?: number;
questions: Question[] categories?: string[];
questions: Question[];
} }
export type FeedbackMode = 'immediate' | 'end' export type FeedbackMode = "immediate" | "end";
export interface ExamState { export interface ExamState {
currentIdx: number currentIdx: number;
answers: Record<string, unknown> answers: Record<string, unknown>;
flags: Record<string, boolean> flags: Record<string, boolean>;
notes: string notes: string;
revealed?: Record<string, boolean> revealed?: Record<string, boolean>;
startedAt: number startedAt: number;
completedAt?: number completedAt?: number;
feedbackMode: FeedbackMode feedbackMode: FeedbackMode;
} }
export interface HistoryEntry { export interface HistoryEntry {
id: string id: string;
title: string title: string;
completedAt: number completedAt: number;
elapsedMs: number elapsedMs: number;
gradedCount: number gradedCount: number;
correctCount: number correctCount: number;
totalPoints: number totalPoints: number;
scorePoints: number scorePoints: number;
passingScore: number passingScore: number;
exam: Exam exam: Exam;
state: ExamState state: ExamState;
} }
export type GradeStatus = 'correct' | 'wrong' | 'ungraded' | 'unanswered' export type GradeStatus = "correct" | "wrong" | "ungraded" | "unanswered";
export interface GradeResult { export interface GradeResult {
status: GradeStatus status: GradeStatus;
correct: boolean correct: boolean;
points: number points: number;
} }
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin' export type ThemeMode = "light" | "dark";
export type ThemeMode = 'light' | 'dark' export type FontSize = "small" | "medium" | "large";
export type FontSize = 'small' | 'medium' | 'large' export type FeedbackTweak = "auto" | "immediate" | "end";
export type FeedbackTweak = 'auto' | 'immediate' | 'end'
export interface Tweaks { export interface Tweaks {
theme: ThemeMode theme: ThemeMode;
fontSize: FontSize fontSize: FontSize;
feedbackMode: FeedbackTweak feedbackMode: FeedbackTweak;
} }
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { slugify } from './slug'
describe('slugify', () => {
it('lowercases', () => {
expect(slugify('My Exam')).toBe('my-exam')
})
it('replaces spaces with hyphens', () => {
expect(slugify('hello world')).toBe('hello-world')
})
it('replaces special chars with hyphens', () => {
expect(slugify('Exam: Advanced!')).toBe('exam-advanced')
})
it('collapses consecutive hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('trims leading and trailing hyphens', () => {
expect(slugify('!hello!')).toBe('hello')
})
it('preserves numbers', () => {
expect(slugify('Exam 2024')).toBe('exam-2024')
})
it('handles empty string', () => {
expect(slugify('')).toBe('')
})
})
+22
View File
@@ -0,0 +1,22 @@
export function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
export function getExamUrlId(exam: { id?: string; title: string }): string {
const slug = slugify(exam.title)
if (exam.id) return `${slug}-${exam.id}`
return slug
}
export function extractExamSlug(examId: string): string {
const lastDash = examId.lastIndexOf('-')
if (lastDash === -1) return examId
// Check if the part after the last dash looks like a generated id (8+ chars of a-z0-9)
const suffix = examId.slice(lastDash + 1)
if (/^[a-z0-9]{8,}$/.test(suffix)) return examId.slice(0, lastDash)
return examId
}
+11 -1
View File
@@ -13,4 +13,14 @@ export const validateExam = (data: unknown): string | null => {
return null return null
} }
export const parseExam = (data: unknown): Exam => data as Exam function generateId(): string {
return Math.random().toString(36).slice(2, 10)
}
export const parseExam = (data: unknown): Exam => {
const exam = data as Exam
if (!exam.id) {
exam.id = generateId()
}
return exam
}
+7 -12
View File
@@ -1,23 +1,18 @@
import { defineConfig } from 'vitest/config' import { defineConfig } from "vitest/config";
import react, { reactCompilerPreset } from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import babel from '@rolldown/plugin-babel'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
react(),
babel({ presets: [reactCompilerPreset()] })
],
server: { server: {
proxy: { proxy: {
'/api': { "/api": {
target: 'http://localhost:3001', target: "http://localhost:3001",
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
test: { test: {
environment: 'node', environment: "node",
globals: true, globals: true,
}, },
}) });