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