Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ac7a7fae | ||
|
|
0b62723226 | ||
|
|
af317058fb | ||
|
|
95603f93a0 | ||
|
|
ae8a01bc54 | ||
|
|
2b7939d478 | ||
|
|
50efba6f34 | ||
|
|
92c1de650a | ||
|
|
83bb412b35 | ||
|
|
ca9c646b65 | ||
|
|
51e972d994 | ||
|
|
2907197308 | ||
|
|
e9c0a2615a | ||
|
|
98bba73192 | ||
|
|
739133e1f4 | ||
|
|
ef8fe6f938 | ||
|
|
a37c093cc4 | ||
|
|
6277826472 | ||
|
|
c64cdcd9e3 | ||
|
|
62af83ce53 | ||
|
|
ad777ef1c3 | ||
|
|
f7b93f8612 | ||
|
|
4f33444653 | ||
|
|
29d9264cfb | ||
|
|
107ebf4d47 |
No files matched your search
@@ -17,11 +17,11 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install Frontend Dependencies
|
- name: Install Frontend Dependencies
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npm ci
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Run Frontend Tests
|
- name: Run Frontend Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./backend
|
||||||
|
env:
|
||||||
|
JWT_SECRET: ci-test-secret
|
||||||
|
NODE_ENV: test
|
||||||
|
DB_PATH: ':memory:'
|
||||||
|
CORS_ORIGIN: 'http://localhost:5173'
|
||||||
|
APP_URL: 'http://localhost:5173'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: ./backend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: ./frontend/package-lock.json
|
||||||
|
- run: npm ci --legacy-peer-deps
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc -b --noEmit
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test:run
|
||||||
+6
-1
@@ -1,4 +1,9 @@
|
|||||||
backend/brainwave.db-shm
|
backend/brainwave.db-shm
|
||||||
backend/brainwave.db-wal
|
backend/brainwave.db-wal
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
backend/db/brainwave.db-shm
|
||||||
|
backend/db/brainwave.db-wal
|
||||||
|
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
|
||||||
|
brainwave.db-shm
|
||||||
|
brainwave.db-wal
|
||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
# Stage 1: Build the frontend
|
# Stage 1: Build the frontend
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --legacy-peer-deps
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build the backend
|
# Stage 2: Build the backend
|
||||||
FROM node:20-alpine AS backend-builder
|
FROM node:22-alpine AS backend-builder
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -15,7 +15,7 @@ COPY backend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Production
|
# Stage 3: Production
|
||||||
FROM node:20-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
# Install build tools for native modules like better-sqlite3
|
# Install build tools for native modules like better-sqlite3
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
+3
-2
@@ -12,7 +12,7 @@
|
|||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ deploy_dir }}"
|
path: "{{ deploy_dir }}"
|
||||||
state: directory
|
state: directory
|
||||||
mode: '0755'
|
mode: "0755"
|
||||||
|
|
||||||
- name: Copy ARM tarball
|
- name: Copy ARM tarball
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
@@ -33,4 +33,5 @@
|
|||||||
ansible.builtin.command: docker compose up -d
|
ansible.builtin.command: docker compose up -d
|
||||||
args:
|
args:
|
||||||
chdir: "{{ deploy_dir }}"
|
chdir: "{{ deploy_dir }}"
|
||||||
changed_when: true
|
register: compose_up
|
||||||
|
changed_when: "'Started' in compose_up.stdout or 'Created' in compose_up.stdout or 'Recreating' in compose_up.stdout"
|
||||||
Generated
+40
-1
@@ -11,9 +11,12 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.3"
|
"nodemailer": "^7.0.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
@@ -1434,6 +1437,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -1647,6 +1662,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -3374,6 +3404,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,12 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.3"
|
"nodemailer": "^7.0.3",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('env helpers', () => {
|
||||||
|
it('uses default value when env var is missing', () => {
|
||||||
|
delete process.env.TEST_VAR_UNIQUE
|
||||||
|
const env = (key: string, defaultValue?: string): string => {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
if (defaultValue !== undefined) return defaultValue
|
||||||
|
throw new Error(`Missing ${key}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
expect(env('TEST_VAR_UNIQUE', 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns env value when present', () => {
|
||||||
|
process.env.TEST_VAR_UNIQUE = 'value'
|
||||||
|
const env = (key: string, defaultValue?: string): string => {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
if (defaultValue !== undefined) return defaultValue
|
||||||
|
throw new Error(`Missing ${key}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
expect(env('TEST_VAR_UNIQUE')).toBe('value')
|
||||||
|
delete process.env.TEST_VAR_UNIQUE
|
||||||
|
})
|
||||||
|
|
||||||
|
it('envInt parses integer', () => {
|
||||||
|
process.env.TEST_INT = '42'
|
||||||
|
const envInt = (key: string, defaultValue?: number): number => {
|
||||||
|
const raw = process.env[key]
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
if (defaultValue !== undefined) return defaultValue
|
||||||
|
throw new Error(`Missing ${key}`)
|
||||||
|
}
|
||||||
|
const parsed = parseInt(raw, 10)
|
||||||
|
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
expect(envInt('TEST_INT')).toBe(42)
|
||||||
|
delete process.env.TEST_INT
|
||||||
|
})
|
||||||
|
|
||||||
|
it('envInt throws on NaN', () => {
|
||||||
|
process.env.TEST_INT = 'abc'
|
||||||
|
const envInt = (key: string, defaultValue?: number): number => {
|
||||||
|
const raw = process.env[key]
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
if (defaultValue !== undefined) return defaultValue
|
||||||
|
throw new Error(`Missing ${key}`)
|
||||||
|
}
|
||||||
|
const parsed = parseInt(raw, 10)
|
||||||
|
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
expect(() => envInt('TEST_INT')).toThrow('Invalid int TEST_INT')
|
||||||
|
delete process.env.TEST_INT
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { AppError } from './middleware/error.js'
|
||||||
|
|
||||||
|
function env(key: string, defaultValue?: string): string {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
if (defaultValue !== undefined) return defaultValue
|
||||||
|
throw new AppError(500, `Missing required environment variable: ${key}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function envInt(key: string, defaultValue?: number): number {
|
||||||
|
const raw = process.env[key]
|
||||||
|
if (raw === undefined || raw === '') {
|
||||||
|
if (defaultValue !== undefined) return defaultValue
|
||||||
|
throw new AppError(500, `Missing required environment variable: ${key}`)
|
||||||
|
}
|
||||||
|
const parsed = parseInt(raw, 10)
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new AppError(500, `Environment variable ${key} must be a valid integer, got: ${raw}`)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
env: env('NODE_ENV', 'development'),
|
||||||
|
port: envInt('PORT', 3001),
|
||||||
|
jwtSecret: env('JWT_SECRET'),
|
||||||
|
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||||
|
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||||
|
bodyLimit: env('BODY_LIMIT', '10mb'),
|
||||||
|
|
||||||
|
email: {
|
||||||
|
smtpHost: env('SMTP_HOST', ''),
|
||||||
|
smtpPort: envInt('SMTP_PORT', 587),
|
||||||
|
smtpUser: env('SMTP_USER', ''),
|
||||||
|
smtpPass: env('SMTP_PASS', ''),
|
||||||
|
fromAddress: env('EMAIL_FROM', 'BrainWave <noreply@brainwave.local>'),
|
||||||
|
secure: env('SMTP_SECURE', 'auto'),
|
||||||
|
},
|
||||||
|
|
||||||
|
appUrl: env('APP_URL', 'http://localhost:5173'),
|
||||||
|
|
||||||
|
rateLimiting: {
|
||||||
|
authWindowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
authMaxRequests: 10,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { config } from '../config.js'
|
||||||
import { runMigrations } from './migrations.js'
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const DB_PATH = path.isAbsolute(config.dbPath)
|
||||||
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db')
|
? config.dbPath
|
||||||
|
: path.join(process.cwd(), config.dbPath)
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
export function getDb(): Database.Database {
|
export function getDb(): Database.Database {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||||
db = new Database(DB_PATH)
|
db = new Database(DB_PATH)
|
||||||
db.pragma('journal_mode = WAL')
|
db.pragma('journal_mode = WAL')
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type Database from 'better-sqlite3'
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
export function runMigrations(db: Database.Database): void {
|
export function runMigrations(db: Database.Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -51,35 +51,49 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||||
`)
|
`);
|
||||||
|
|
||||||
// ── Add role column if missing (existing databases) ──
|
// ── Add role column if missing (existing databases) ──
|
||||||
const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
|
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
|
||||||
if (!cols.some(c => c.name === 'role')) {
|
name: string;
|
||||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
|
}[];
|
||||||
|
if (!cols.some((c) => c.name === "role")) {
|
||||||
|
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Promote first user to admin if no admins exist ──
|
// ── Promote first user to admin if no admins exist ──
|
||||||
const adminCount = db.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`).get() as { cnt: number }
|
const adminCount = db
|
||||||
|
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
|
||||||
|
.get() as { cnt: number };
|
||||||
if (adminCount.cnt === 0) {
|
if (adminCount.cnt === 0) {
|
||||||
db.prepare(`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`).run()
|
db.prepare(
|
||||||
|
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
|
||||||
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Seed default settings ──
|
// ── Seed default settings ──
|
||||||
const defaults: Record<string, string> = {
|
const defaults: Record<string, string> = {
|
||||||
site_name: 'BrainWave',
|
site_name: "BrainWave",
|
||||||
max_upload_mb: '5',
|
max_upload_mb: "5",
|
||||||
allow_guest_mode: 'true',
|
allow_guest_mode: "true",
|
||||||
registration_enabled: 'true',
|
registration_enabled: "true",
|
||||||
default_theme: 'light',
|
default_theme: "light",
|
||||||
maintenance_mode: 'false',
|
maintenance_mode: "false",
|
||||||
maintenance_message: 'We are currently performing maintenance. Please check back soon.',
|
maintenance_message:
|
||||||
}
|
"We are currently performing maintenance. Please check back soon.",
|
||||||
|
smtp_host: "",
|
||||||
|
smtp_port: "587",
|
||||||
|
smtp_user: "",
|
||||||
|
smtp_pass: "",
|
||||||
|
smtp_from: "BrainWave <noreply@brainwave.local>",
|
||||||
|
smtp_secure: "auto",
|
||||||
|
app_url: "http://localhost:5173",
|
||||||
|
};
|
||||||
const upsert = db.prepare(`
|
const upsert = db.prepare(`
|
||||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||||
ON CONFLICT(key) DO NOTHING
|
ON CONFLICT(key) DO NOTHING
|
||||||
`)
|
`);
|
||||||
for (const [key, value] of Object.entries(defaults)) {
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
upsert.run(key, value)
|
upsert.run(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+42
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import { getDb, closeDb } from './db/database.js'
|
import { getDb, closeDb } from './db/database.js'
|
||||||
@@ -5,6 +6,9 @@ import authRoutes from './routes/auth.js'
|
|||||||
import examRoutes from './routes/exams.js'
|
import examRoutes from './routes/exams.js'
|
||||||
import resultRoutes from './routes/results.js'
|
import resultRoutes from './routes/results.js'
|
||||||
import adminRoutes from './routes/admin.js'
|
import adminRoutes from './routes/admin.js'
|
||||||
|
import setupRoutes from './routes/setup.js'
|
||||||
|
import { errorHandler } from './middleware/error.js'
|
||||||
|
import { config } from './config.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -12,28 +16,61 @@ import { fileURLToPath } from 'url'
|
|||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
const PORT = Number(process.env['PORT'] ?? '3001')
|
const PORT = config.port
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' }))
|
app.use(cors({ origin: config.corsOrigin }))
|
||||||
app.use(express.json({ limit: '10mb' }))
|
app.use(express.json({ limit: config.bodyLimit }))
|
||||||
|
|
||||||
|
// Request logging
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now()
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start
|
||||||
|
console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`)
|
||||||
|
})
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
// Initialize database on startup
|
// Initialize database on startup
|
||||||
getDb()
|
getDb()
|
||||||
console.log('✓ Database initialized')
|
console.log('✓ Database initialized')
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
app.use('/api/setup', setupRoutes)
|
||||||
app.use('/api/auth', authRoutes)
|
app.use('/api/auth', authRoutes)
|
||||||
app.use('/api/exams', examRoutes)
|
app.use('/api/exams', examRoutes)
|
||||||
app.use('/api/results', resultRoutes)
|
app.use('/api/results', resultRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
|
|
||||||
// Health check
|
// Health check with subsystem status
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: Date.now() })
|
let dbStatus: 'connected' | 'error' = 'connected'
|
||||||
|
try {
|
||||||
|
getDb().prepare('SELECT 1').get()
|
||||||
|
} catch {
|
||||||
|
dbStatus = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks = {
|
||||||
|
database: dbStatus,
|
||||||
|
email: config.email.smtpHost ? 'configured' : 'not_configured',
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = dbStatus === 'connected' ? 'healthy' : 'unhealthy'
|
||||||
|
|
||||||
|
res.status(overall === 'healthy' ? 200 : 503).json({
|
||||||
|
status: overall,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
version: process.env['npm_package_version'] ?? '0.0.1',
|
||||||
|
checks,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Error handler (must be after routes)
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
// Serve static frontend files if they exist
|
// Serve static frontend files if they exist
|
||||||
const frontendDistPath = path.join(__dirname, '../../frontend/dist')
|
const frontendDistPath = path.join(__dirname, '../../frontend/dist')
|
||||||
if (fs.existsSync(frontendDistPath)) {
|
if (fs.existsSync(frontendDistPath)) {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user: JwtPayload
|
||||||
|
}
|
||||||
|
|
||||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
const header = req.headers.authorization
|
const header = req.headers.authorization
|
||||||
if (!header?.startsWith('Bearer ')) {
|
if (!header?.startsWith('Bearer ')) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { AppError } from './error'
|
||||||
|
|
||||||
|
describe('AppError', () => {
|
||||||
|
it('is instance of Error and AppError', () => {
|
||||||
|
const err = new AppError(404, 'Not found')
|
||||||
|
expect(err).toBeInstanceOf(AppError)
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets statusCode and message', () => {
|
||||||
|
const err = new AppError(500, 'Server error')
|
||||||
|
expect(err.statusCode).toBe(500)
|
||||||
|
expect(err.message).toBe('Server error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets name to AppError', () => {
|
||||||
|
const err = new AppError(400, 'Bad request')
|
||||||
|
expect(err.name).toBe('AppError')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public statusCode: number,
|
||||||
|
message: string,
|
||||||
|
public isOperational = true
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
Object.setPrototypeOf(this, AppError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
err: Error | AppError,
|
||||||
|
_req: any,
|
||||||
|
res: any,
|
||||||
|
_next: any
|
||||||
|
) {
|
||||||
|
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
||||||
|
const message = err.message || 'Internal Server Error';
|
||||||
|
|
||||||
|
console.error(`[Error] ${statusCode} - ${message}`);
|
||||||
|
if (!(err instanceof AppError) || !err.isOperational) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
status: 'error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
function getFirstIssueMessage(error: z.ZodError): string {
|
||||||
|
const issue = error.issues[0]
|
||||||
|
return issue?.message ?? 'Validation failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBody<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const result = schema.safeParse(req.body)
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ error: getFirstIssueMessage(result.error) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.body = result.data as z.infer<T>
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateQuery<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const result = schema.safeParse(req.query)
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ error: getFirstIssueMessage(result.error) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
;(req as unknown as Record<string, unknown>).parsedQuery = result.data
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateParams<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const result = schema.safeParse(req.params)
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ error: getFirstIssueMessage(result.error) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Object.assign(req.params, result.data)
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
export interface DbExam {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
title: string
|
||||||
|
subtitle: string | null
|
||||||
|
exam_json: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamListItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subtitle: string | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
question_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string): { id: number } {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(userId, title, subtitle, examJson)
|
||||||
|
return { id: result.lastInsertRowid as number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExamById(id: number, userId: number): DbExam | undefined {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare('SELECT * FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as DbExam | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExamsByUser(userId: number): ExamListItem[] {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, title, subtitle, created_at, updated_at,
|
||||||
|
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||||
|
FROM saved_exams
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`).all(userId) as ExamListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE saved_exams
|
||||||
|
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
`).run(title, subtitle, examJson, id, userId)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteExam(id: number, userId: number): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare('DELETE FROM saved_exams WHERE id = ? AND user_id = ?').run(id, userId)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countExams(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
|
||||||
|
return row.cnt
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
export interface DbResult {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
exam_id: number | null
|
||||||
|
title: string
|
||||||
|
score_points: number
|
||||||
|
total_points: number
|
||||||
|
correct_count: number
|
||||||
|
graded_count: number
|
||||||
|
passing_score: number
|
||||||
|
elapsed_ms: number
|
||||||
|
state_json: string
|
||||||
|
exam_json: string
|
||||||
|
completed_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultListItem {
|
||||||
|
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 function createResult(data: Omit<DbResult, 'id'>): { id: number } {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
data.user_id,
|
||||||
|
data.exam_id ?? null,
|
||||||
|
data.title,
|
||||||
|
data.score_points,
|
||||||
|
data.total_points,
|
||||||
|
data.correct_count,
|
||||||
|
data.graded_count,
|
||||||
|
data.passing_score ?? 60,
|
||||||
|
data.elapsed_ms ?? 0,
|
||||||
|
data.state_json,
|
||||||
|
data.exam_json
|
||||||
|
)
|
||||||
|
return { id: result.lastInsertRowid as number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResultById(id: number, userId: number): DbResult | undefined {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare('SELECT * FROM exam_results WHERE id = ? AND user_id = ?').get(id, userId) as DbResult | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listResultsByUser(userId: number, options: { limit?: number; offset?: number } = {}): { results: ResultListItem[]; total: number } {
|
||||||
|
const db = getDb()
|
||||||
|
const limit = Math.min(options.limit ?? 30, 100)
|
||||||
|
const offset = options.offset ?? 0
|
||||||
|
|
||||||
|
const results = db.prepare(`
|
||||||
|
SELECT id, title, score_points, total_points, correct_count, graded_count,
|
||||||
|
passing_score, elapsed_ms, completed_at, exam_id
|
||||||
|
FROM exam_results
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(userId, limit, offset) as ResultListItem[]
|
||||||
|
|
||||||
|
const countRow = db.prepare('SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?').get(userId) as { total: number }
|
||||||
|
|
||||||
|
return { results, total: countRow.total }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteResult(id: number, userId: number): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare('DELETE FROM exam_results WHERE id = ? AND user_id = ?').run(id, userId)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countResults(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number }
|
||||||
|
return row.cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAverageScore(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results'
|
||||||
|
).get() as { avg: number }
|
||||||
|
return Math.round(row.avg * 10) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentResults(limit: number): Array<DbResult & { display_name: string; email: string }> {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT er.*, 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 ?
|
||||||
|
`).all(limit) as Array<DbResult & { display_name: string; email: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countResultsByUser(userId: number): number {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results WHERE user_id = ?').get(userId) as { cnt: number }
|
||||||
|
return row.cnt
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
export interface Setting {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetting(key: string): string | undefined {
|
||||||
|
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 setSetting(key: string, value: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
|
||||||
|
`).run(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSettingsBatch(entries: Array<[string, string]>): void {
|
||||||
|
const db = getDb()
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
|
||||||
|
`)
|
||||||
|
const tx = db.transaction((items: Array<[string, string]>) => {
|
||||||
|
for (const [key, value] of items) {
|
||||||
|
stmt.run(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tx(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSettings(): 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
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedDefaultSettings(defaults: Record<string, string>): void {
|
||||||
|
const db = getDb()
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO NOTHING
|
||||||
|
`)
|
||||||
|
for (const [key, value] of Object.entries(defaults)) {
|
||||||
|
stmt.run(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
export interface DbUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
password_hash: string
|
||||||
|
display_name: string
|
||||||
|
role: 'user' | 'admin'
|
||||||
|
email_verified: number
|
||||||
|
verify_token: string | null
|
||||||
|
verify_expires: number | null
|
||||||
|
reset_token: string | null
|
||||||
|
reset_expires: number | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserData {
|
||||||
|
email: string
|
||||||
|
passwordHash: string
|
||||||
|
displayName: string
|
||||||
|
role?: 'user' | 'admin'
|
||||||
|
emailVerified?: number
|
||||||
|
verifyToken?: string | null
|
||||||
|
verifyExpires?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserData {
|
||||||
|
displayName?: string
|
||||||
|
passwordHash?: string
|
||||||
|
role?: 'user' | 'admin'
|
||||||
|
emailVerified?: number
|
||||||
|
verifyToken?: string | null
|
||||||
|
verifyExpires?: number | null
|
||||||
|
resetToken?: string | null
|
||||||
|
resetExpires?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(data: CreateUserData): { id: number } {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
data.email.toLowerCase().trim(),
|
||||||
|
data.passwordHash,
|
||||||
|
data.displayName.trim(),
|
||||||
|
data.role ?? 'user',
|
||||||
|
data.emailVerified ?? 1,
|
||||||
|
data.verifyToken ?? null,
|
||||||
|
data.verifyExpires ?? null
|
||||||
|
)
|
||||||
|
return { id: result.lastInsertRowid as number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserByEmail(email: string): DbUser | undefined {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim()) as DbUser | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserById(id: number): DbUser | undefined {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserByVerifyToken(token: string): DbUser | undefined {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserByResetToken(token: string): DbUser | undefined {
|
||||||
|
const db = getDb()
|
||||||
|
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: number, data: UpdateUserData): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: unknown[] = []
|
||||||
|
|
||||||
|
if (data.displayName !== undefined) {
|
||||||
|
fields.push('display_name = ?')
|
||||||
|
values.push(data.displayName.trim())
|
||||||
|
}
|
||||||
|
if (data.passwordHash !== undefined) {
|
||||||
|
fields.push('password_hash = ?')
|
||||||
|
values.push(data.passwordHash)
|
||||||
|
}
|
||||||
|
if (data.role !== undefined) {
|
||||||
|
fields.push('role = ?')
|
||||||
|
values.push(data.role)
|
||||||
|
}
|
||||||
|
if (data.emailVerified !== undefined) {
|
||||||
|
fields.push('email_verified = ?')
|
||||||
|
values.push(data.emailVerified)
|
||||||
|
}
|
||||||
|
if (data.verifyToken !== undefined) {
|
||||||
|
fields.push('verify_token = ?')
|
||||||
|
values.push(data.verifyToken)
|
||||||
|
}
|
||||||
|
if (data.verifyExpires !== undefined) {
|
||||||
|
fields.push('verify_expires = ?')
|
||||||
|
values.push(data.verifyExpires)
|
||||||
|
}
|
||||||
|
if (data.resetToken !== undefined) {
|
||||||
|
fields.push('reset_token = ?')
|
||||||
|
values.push(data.resetToken)
|
||||||
|
}
|
||||||
|
if (data.resetExpires !== undefined) {
|
||||||
|
fields.push('reset_expires = ?')
|
||||||
|
values.push(data.resetExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return false
|
||||||
|
|
||||||
|
fields.push('updated_at = unixepoch()')
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const result = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: number): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countUsers(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
|
||||||
|
return row.cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countAdmins(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'").get() as { cnt: number }
|
||||||
|
return row.cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: DbUser[]; total: number } {
|
||||||
|
const db = getDb()
|
||||||
|
const limit = Math.min(options.limit ?? 50, 200)
|
||||||
|
const offset = options.offset ?? 0
|
||||||
|
const search = options.search ?? ''
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM users'
|
||||||
|
let countQuery = 'SELECT COUNT(*) AS total FROM users'
|
||||||
|
const params: unknown[] = []
|
||||||
|
const countParams: unknown[] = []
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ' WHERE email LIKE ? OR display_name LIKE ?'
|
||||||
|
countQuery += ' WHERE email LIKE ? OR display_name LIKE ?'
|
||||||
|
params.push(`%${search}%`, `%${search}%`)
|
||||||
|
countParams.push(`%${search}%`, `%${search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||||
|
params.push(limit, offset)
|
||||||
|
|
||||||
|
const users = db.prepare(query).all(...params) as DbUser[]
|
||||||
|
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
|
||||||
|
|
||||||
|
return { users, total: countRow.total }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteFirstUserToAdmin(): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare("UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)").run()
|
||||||
|
}
|
||||||
+120
-168
@@ -1,190 +1,142 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { getDb } from '../db/database.js'
|
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import { requireAuth, requireAdmin } from '../middleware/auth.js'
|
import { requireAuth, requireAdmin } from '../middleware/auth.js'
|
||||||
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
|
import * as resultRepo from '../repositories/resultRepo.js'
|
||||||
|
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||||
|
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
||||||
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
// All admin routes require authentication + admin role
|
|
||||||
router.use(requireAuth, requireAdmin)
|
router.use(requireAuth, requireAdmin)
|
||||||
|
|
||||||
// ─── Dashboard Stats ─────────────────────────────────────
|
// GET /api/admin/users
|
||||||
router.get('/stats', (_req, res) => {
|
router.get(
|
||||||
try {
|
'/users',
|
||||||
const db = getDb()
|
validateQuery(listUsersQuerySchema),
|
||||||
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
|
asyncHandler(async (req: Request, res) => {
|
||||||
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
|
const parsed = (req as unknown as Record<string, unknown>).parsedQuery as { page: number; search?: string }
|
||||||
const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number }
|
const page = parsed.page ?? 1
|
||||||
const avgScore = db.prepare(
|
const limit = 20
|
||||||
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results'
|
const offset = (page - 1) * limit
|
||||||
).get() as { avg: number }
|
|
||||||
const recentResults = db.prepare(`
|
const { users, total } = userRepo.listUsers({
|
||||||
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
|
limit,
|
||||||
u.display_name, u.email
|
offset,
|
||||||
FROM exam_results er
|
search: parsed.search,
|
||||||
JOIN users u ON u.id = er.user_id
|
})
|
||||||
ORDER BY er.completed_at DESC
|
|
||||||
LIMIT 10
|
const totalPages = Math.ceil(total / limit)
|
||||||
`).all()
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalUsers: users.cnt,
|
users,
|
||||||
totalExams: exams.cnt,
|
total,
|
||||||
totalResults: results.cnt,
|
page,
|
||||||
averageScore: Math.round(avgScore.avg * 10) / 10,
|
totalPages,
|
||||||
recentResults,
|
limit,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
})
|
||||||
console.error('Admin stats error:', err)
|
)
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Get All Settings ────────────────────────────────────
|
// PUT /api/admin/users/:id/role
|
||||||
router.get('/settings', (_req, res) => {
|
router.put(
|
||||||
try {
|
'/users/:id/role',
|
||||||
const db = getDb()
|
validateParams(userIdParamSchema),
|
||||||
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[]
|
validateBody(updateUserRoleSchema),
|
||||||
const settings: Record<string, string> = {}
|
asyncHandler(async (req: Request, res) => {
|
||||||
for (const row of rows) {
|
const id = Number(req.params.id)
|
||||||
settings[row.key] = row.value
|
const { role } = req.body
|
||||||
}
|
|
||||||
res.json({ settings })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Admin get settings error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Update Settings ─────────────────────────────────────
|
const user = userRepo.getUserById(id)
|
||||||
router.put('/settings', (req, res) => {
|
if (!user) {
|
||||||
try {
|
throw new AppError(404, 'User not found')
|
||||||
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 = [
|
if (user.id === req.user!.userId) {
|
||||||
'site_name', 'max_upload_mb', 'allow_guest_mode',
|
throw new AppError(400, 'Cannot change your own role')
|
||||||
'registration_enabled', 'default_theme', 'maintenance_mode',
|
}
|
||||||
'maintenance_message',
|
|
||||||
]
|
|
||||||
|
|
||||||
const db = getDb()
|
if (role === 'user' && user.role === 'admin') {
|
||||||
const update = db.prepare(`
|
const adminCount = userRepo.countAdmins()
|
||||||
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
|
if (adminCount <= 1) {
|
||||||
`)
|
throw new AppError(400, 'Cannot remove the last admin')
|
||||||
|
|
||||||
const updateMany = db.transaction((entries: [string, string][]) => {
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
if (!validKeys.includes(key)) continue
|
|
||||||
update.run(String(value), key)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo.updateUser(id, { role })
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE /api/admin/users/:id
|
||||||
|
router.delete(
|
||||||
|
'/users/:id',
|
||||||
|
validateParams(userIdParamSchema),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
if (id === req.user!.userId) {
|
||||||
|
throw new AppError(400, 'Cannot delete your own account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userRepo.getUserById(id)
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(404, 'User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
const adminCount = userRepo.countAdmins()
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new AppError(400, 'Cannot delete the last admin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo.deleteUser(id)
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/admin/stats
|
||||||
|
router.get(
|
||||||
|
'/stats',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
totalUsers: userRepo.countUsers(),
|
||||||
|
totalExams: resultRepo.countResults(),
|
||||||
|
totalResults: resultRepo.countResults(),
|
||||||
|
averageScore: resultRepo.getAverageScore(),
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
updateMany(Object.entries(settings))
|
// GET /api/admin/recent-results
|
||||||
res.json({ message: 'Settings updated successfully' })
|
router.get(
|
||||||
} catch (err) {
|
'/recent-results',
|
||||||
console.error('Admin update settings error:', err)
|
asyncHandler(async (_req, res) => {
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
const results = resultRepo.getRecentResults(10)
|
||||||
}
|
res.json(results)
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// ─── List All Users ──────────────────────────────────────
|
// GET /api/admin/settings
|
||||||
router.get('/users', (req, res) => {
|
router.get(
|
||||||
try {
|
'/settings',
|
||||||
const limit = Math.min(Number(req.query['limit']) || 50, 200)
|
asyncHandler(async (_req, res) => {
|
||||||
const offset = Number(req.query['offset']) || 0
|
res.json(settingsRepo.getAllSettings())
|
||||||
const search = (req.query['search'] as string) || ''
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const db = getDb()
|
// PUT /api/admin/settings
|
||||||
let query = `
|
router.put(
|
||||||
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at,
|
'/settings',
|
||||||
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count,
|
validateBody(updateSettingSchema),
|
||||||
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
|
asyncHandler(async (req: Request, res) => {
|
||||||
FROM users u
|
const { key, value } = req.body
|
||||||
`
|
settingsRepo.setSetting(key, value)
|
||||||
let countQuery = 'SELECT COUNT(*) AS total FROM users'
|
res.json({ success: true })
|
||||||
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}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|
||||||
res.json({ users, total: countRow.total })
|
|
||||||
} catch (err) {
|
|
||||||
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) => {
|
|
||||||
try {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent self-demotion
|
|
||||||
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)
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
res.status(404).json({ error: 'User not found' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Delete User ─────────────────────────────────────────
|
|
||||||
router.delete('/users/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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.json({ message: 'User deleted' })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Admin delete user error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
+116
-238
@@ -1,248 +1,126 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import bcrypt from 'bcrypt'
|
import rateLimit from 'express-rate-limit'
|
||||||
import { getDb } from '../db/database.js'
|
import { validateBody } from '../middleware/validate.js'
|
||||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
|
||||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
|
||||||
import { requireAuth } from '../middleware/auth.js'
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
|
import * as authService from '../services/authService.js'
|
||||||
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
|
import {
|
||||||
|
registerSchema,
|
||||||
|
loginSchema,
|
||||||
|
updateProfileSchema,
|
||||||
|
changePasswordSchema,
|
||||||
|
verifyEmailSchema,
|
||||||
|
resetPasswordSchema,
|
||||||
|
forgotPasswordSchema,
|
||||||
|
} from '../validators/authSchemas.js'
|
||||||
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
const BCRYPT_ROUNDS = 12
|
|
||||||
const VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
||||||
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
|
|
||||||
|
|
||||||
// ─── Register ────────────────────────────────────────────
|
const authLimiter = rateLimit({
|
||||||
router.post('/register', async (req, res) => {
|
windowMs: 15 * 60 * 1000,
|
||||||
try {
|
max: 10,
|
||||||
const { email, password, displayName } = req.body
|
message: { error: 'Too many attempts, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
if (!email || !password || !displayName) {
|
skipSuccessfulRequests: false,
|
||||||
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()
|
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
|
|
||||||
if (existing) {
|
|
||||||
res.status(409).json({ error: 'An account with this email already exists' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
|
||||||
const verifyToken = generateVerificationToken()
|
|
||||||
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
await sendVerificationEmail(email, verifyToken)
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: 'Account created. Please check your email to verify your account.',
|
|
||||||
userId: result.lastInsertRowid,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Register error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Login ───────────────────────────────────────────────
|
// POST /api/auth/register
|
||||||
router.post('/login', async (req, res) => {
|
router.post(
|
||||||
try {
|
'/register',
|
||||||
const { email, password } = req.body
|
authLimiter,
|
||||||
|
validateBody(registerSchema),
|
||||||
if (!email || !password) {
|
asyncHandler(async (req, res) => {
|
||||||
res.status(400).json({ error: 'Email and password are required' })
|
const isFirstUser = userRepo.countUsers() === 0
|
||||||
return
|
const result = await authService.register(req.body, isFirstUser)
|
||||||
}
|
res.json(result)
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(
|
|
||||||
(email as string).toLowerCase().trim()
|
|
||||||
) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
res.status(401).json({ error: 'Invalid email or password' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password as string, user['password_hash'] as string)
|
|
||||||
if (!valid) {
|
|
||||||
res.status(401).json({ error: 'Invalid email or password' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user['email_verified']) {
|
|
||||||
res.status(403).json({ error: 'Please verify your email before logging in' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = signToken({
|
|
||||||
userId: user['id'] as number,
|
|
||||||
email: user['email'] as string,
|
|
||||||
displayName: user['display_name'] as string,
|
|
||||||
role: (user['role'] as 'user' | 'admin') ?? 'user',
|
|
||||||
})
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
token,
|
|
||||||
user: {
|
|
||||||
id: user['id'],
|
|
||||||
email: user['email'],
|
|
||||||
displayName: user['display_name'],
|
|
||||||
role: user['role'] ?? 'user',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Login error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Verify Email ────────────────────────────────────────
|
|
||||||
router.get('/verify-email', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { token } = req.query
|
|
||||||
|
|
||||||
if (!token || typeof token !== 'string') {
|
|
||||||
res.status(400).json({ error: 'Verification token is required' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
res.status(400).json({ error: 'Invalid verification token' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((user['verify_expires'] as number) < Date.now()) {
|
|
||||||
res.status(400).json({ error: 'Verification token has expired. Please register again.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL, updated_at = unixepoch()
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(user['id'])
|
|
||||||
|
|
||||||
res.json({ message: 'Email verified successfully. You can now log in.' })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Verify error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Forgot Password ────────────────────────────────────
|
|
||||||
router.post('/forgot-password', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email } = req.body
|
|
||||||
|
|
||||||
// Always return success to prevent email enumeration
|
|
||||||
if (!email || typeof email !== 'string') {
|
|
||||||
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(
|
|
||||||
email.toLowerCase().trim()
|
|
||||||
) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (user && user['email_verified']) {
|
|
||||||
const resetToken = generateVerificationToken()
|
|
||||||
const resetExpires = Date.now() + RESET_EXPIRY_MS
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = unixepoch()
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(resetToken, resetExpires, user['id'])
|
|
||||||
|
|
||||||
await sendPasswordResetEmail(email, resetToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Forgot password error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Reset Password ─────────────────────────────────────
|
|
||||||
router.post('/reset-password', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { token, newPassword } = req.body
|
|
||||||
|
|
||||||
if (!token || typeof token !== 'string') {
|
|
||||||
res.status(400).json({ error: 'Reset token is required' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!newPassword || 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 * FROM users WHERE reset_token = ?').get(token) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
res.status(400).json({ error: 'Invalid reset token' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((user['reset_expires'] as number) < Date.now()) {
|
|
||||||
res.status(400).json({ error: 'Reset token has expired. Please request a new one.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE users SET password_hash = ?, reset_token = NULL, reset_expires = NULL, updated_at = unixepoch()
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(passwordHash, user['id'])
|
|
||||||
|
|
||||||
res.json({ message: 'Password updated successfully. You can now log in with your new password.' })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Reset password error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Get Current User ────────────────────────────────────
|
|
||||||
router.get('/me', requireAuth, (req, res) => {
|
|
||||||
const db = getDb()
|
|
||||||
const user = db.prepare('SELECT id, email, display_name, role, created_at FROM users WHERE id = ?').get(
|
|
||||||
req.user!.userId
|
|
||||||
) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
res.status(404).json({ error: 'User not found' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
id: user['id'],
|
|
||||||
email: user['email'],
|
|
||||||
displayName: user['display_name'],
|
|
||||||
role: user['role'] ?? 'user',
|
|
||||||
createdAt: user['created_at'],
|
|
||||||
})
|
})
|
||||||
})
|
)
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
authLimiter,
|
||||||
|
validateBody(loginSchema),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = await authService.login(req.body)
|
||||||
|
res.json(result)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
router.get(
|
||||||
|
'/me',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const user = authService.getMe(req.user!.userId)
|
||||||
|
res.json(user)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// PUT /api/auth/me
|
||||||
|
router.put(
|
||||||
|
'/me',
|
||||||
|
requireAuth,
|
||||||
|
validateBody(updateProfileSchema),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const user = await authService.updateProfile(req.user!.userId, req.body)
|
||||||
|
res.json(user)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/auth/change-password
|
||||||
|
router.post(
|
||||||
|
'/change-password',
|
||||||
|
requireAuth,
|
||||||
|
validateBody(changePasswordSchema),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
await authService.changePassword(req.user!.userId, req.body)
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/auth/verify
|
||||||
|
router.get(
|
||||||
|
'/verify',
|
||||||
|
validateBody(verifyEmailSchema),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await authService.verifyEmail(req.body)
|
||||||
|
res.json({ message: 'Email verified successfully' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/auth/forgot-password
|
||||||
|
router.post(
|
||||||
|
'/forgot-password',
|
||||||
|
authLimiter,
|
||||||
|
validateBody(forgotPasswordSchema),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await authService.forgotPassword(req.body.email)
|
||||||
|
res.json({ message: 'If an account exists, a reset email has been sent' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/auth/reset-password
|
||||||
|
router.post(
|
||||||
|
'/reset-password',
|
||||||
|
authLimiter,
|
||||||
|
validateBody(resetPasswordSchema),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await authService.resetPassword(req.body)
|
||||||
|
res.json({ message: 'Password reset successfully' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE /api/auth/me
|
||||||
|
router.delete(
|
||||||
|
'/me',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
authService.deleteUserAccount(req.user!.userId)
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
+74
-82
@@ -1,99 +1,91 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { getDb } from '../db/database.js'
|
import { validateBody, validateParams } from '../middleware/validate.js'
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import { requireAuth } from '../middleware/auth.js'
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import * as examRepo from '../repositories/examRepo.js'
|
||||||
|
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||||
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
// ─── List User's Saved Exams ─────────────────────────────
|
// All exam routes require authentication
|
||||||
router.get('/', requireAuth, (req, res) => {
|
router.use(requireAuth)
|
||||||
try {
|
|
||||||
const db = getDb()
|
|
||||||
const exams = db.prepare(`
|
|
||||||
SELECT id, title, subtitle, created_at, updated_at,
|
|
||||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
|
||||||
FROM saved_exams
|
|
||||||
WHERE user_id = ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`).all(req.user!.userId)
|
|
||||||
|
|
||||||
|
// GET /api/exams
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const exams = examRepo.listExamsByUser(req.user!.userId)
|
||||||
res.json(exams)
|
res.json(exams)
|
||||||
} catch (err) {
|
})
|
||||||
console.error('List exams error:', err)
|
)
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Get Full Exam ───────────────────────────────────────
|
|
||||||
router.get('/:id', requireAuth, (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = getDb()
|
|
||||||
const exam = db.prepare(`
|
|
||||||
SELECT * FROM saved_exams WHERE id = ? AND user_id = ?
|
|
||||||
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
|
// GET /api/exams/:id
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
validateParams(examIdParamSchema),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||||
if (!exam) {
|
if (!exam) {
|
||||||
res.status(404).json({ error: 'Exam not found' })
|
throw new AppError(404, 'Exam not found')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
const parsedExam = JSON.parse(exam.exam_json)
|
||||||
|
res.json({ ...parsedExam, id: exam.id })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
res.json({
|
// POST /api/exams
|
||||||
id: exam['id'],
|
router.post(
|
||||||
title: exam['title'],
|
'/',
|
||||||
subtitle: exam['subtitle'],
|
validateBody(saveExamSchema),
|
||||||
exam: JSON.parse(exam['exam_json'] as string),
|
asyncHandler(async (req: Request, res) => {
|
||||||
createdAt: exam['created_at'],
|
const exam = req.body.exam
|
||||||
updatedAt: exam['updated_at'],
|
const { id } = examRepo.createExam(
|
||||||
})
|
req.user!.userId,
|
||||||
} catch (err) {
|
exam.title,
|
||||||
console.error('Get exam error:', err)
|
exam.subtitle ?? null,
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
JSON.stringify(exam)
|
||||||
}
|
)
|
||||||
})
|
res.status(201).json({ id, title: exam.title })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// ─── Save Exam ───────────────────────────────────────────
|
// PUT /api/exams/:id
|
||||||
router.post('/', requireAuth, (req, res) => {
|
router.put(
|
||||||
try {
|
'/:id',
|
||||||
const { exam } = req.body
|
validateParams(examIdParamSchema),
|
||||||
|
validateBody(updateExamSchema),
|
||||||
if (!exam || !exam.title || !exam.questions?.length) {
|
asyncHandler(async (req: Request, res) => {
|
||||||
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
|
const id = Number(req.params.id)
|
||||||
return
|
const exam = req.body.exam
|
||||||
|
const updated = examRepo.updateExam(
|
||||||
|
id,
|
||||||
|
req.user!.userId,
|
||||||
|
exam.title,
|
||||||
|
exam.subtitle ?? null,
|
||||||
|
JSON.stringify(exam)
|
||||||
|
)
|
||||||
|
if (!updated) {
|
||||||
|
throw new AppError(404, 'Exam not found')
|
||||||
}
|
}
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const db = getDb()
|
// DELETE /api/exams/:id
|
||||||
const result = db.prepare(`
|
router.delete(
|
||||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
'/:id',
|
||||||
VALUES (?, ?, ?, ?)
|
validateParams(examIdParamSchema),
|
||||||
`).run(req.user!.userId, exam.title, exam.subtitle ?? null, JSON.stringify(exam))
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const id = Number(req.params.id)
|
||||||
res.status(201).json({
|
const deleted = examRepo.deleteExam(id, req.user!.userId)
|
||||||
id: result.lastInsertRowid,
|
if (!deleted) {
|
||||||
message: 'Exam saved successfully',
|
throw new AppError(404, 'Exam not found')
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save exam error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Delete Exam ─────────────────────────────────────────
|
|
||||||
router.delete('/:id', requireAuth, (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = getDb()
|
|
||||||
const result = db.prepare(`
|
|
||||||
DELETE FROM saved_exams WHERE id = ? AND user_id = ?
|
|
||||||
`).run(Number(req.params['id']), req.user!.userId)
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
res.status(404).json({ error: 'Exam not found' })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
res.json({ success: true })
|
||||||
res.json({ message: 'Exam deleted' })
|
})
|
||||||
} catch (err) {
|
)
|
||||||
console.error('Delete exam error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
+64
-122
@@ -1,139 +1,81 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { getDb } from '../db/database.js'
|
import { validateBody, validateParams } from '../middleware/validate.js'
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import { requireAuth } from '../middleware/auth.js'
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import * as resultRepo from '../repositories/resultRepo.js'
|
||||||
|
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
|
||||||
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
// ─── List User's Results ─────────────────────────────────
|
router.use(requireAuth)
|
||||||
router.get('/', requireAuth, (req, res) => {
|
|
||||||
try {
|
|
||||||
const limit = Math.min(Number(req.query['limit']) || 30, 100)
|
|
||||||
const offset = Number(req.query['offset']) || 0
|
|
||||||
|
|
||||||
const db = getDb()
|
// GET /api/results
|
||||||
const results = db.prepare(`
|
router.get(
|
||||||
SELECT id, title, score_points, total_points, correct_count, graded_count,
|
'/',
|
||||||
passing_score, elapsed_ms, completed_at, exam_id
|
asyncHandler(async (req: Request, res) => {
|
||||||
FROM exam_results
|
const { results, total } = resultRepo.listResultsByUser(req.user!.userId)
|
||||||
WHERE user_id = ?
|
res.json({ results, total })
|
||||||
ORDER BY completed_at DESC
|
})
|
||||||
LIMIT ? OFFSET ?
|
)
|
||||||
`).all(req.user!.userId, limit, offset)
|
|
||||||
|
|
||||||
const countRow = db.prepare(
|
|
||||||
'SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?'
|
|
||||||
).get(req.user!.userId) as { total: number }
|
|
||||||
|
|
||||||
res.json({ results, total: countRow.total })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('List results error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Get Single Result ───────────────────────────────────
|
|
||||||
router.get('/:id', requireAuth, (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = getDb()
|
|
||||||
const result = db.prepare(`
|
|
||||||
SELECT * FROM exam_results WHERE id = ? AND user_id = ?
|
|
||||||
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
|
// GET /api/results/:id
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
validateParams(resultIdParamSchema),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
const result = resultRepo.getResultById(id, req.user!.userId)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(404).json({ error: 'Result not found' })
|
throw new AppError(404, 'Result not found')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: result['id'],
|
...result,
|
||||||
title: result['title'],
|
state: JSON.parse(result.state_json),
|
||||||
scorePoints: result['score_points'],
|
exam: JSON.parse(result.exam_json),
|
||||||
totalPoints: result['total_points'],
|
state_json: undefined,
|
||||||
correctCount: result['correct_count'],
|
exam_json: undefined,
|
||||||
gradedCount: result['graded_count'],
|
|
||||||
passingScore: result['passing_score'],
|
|
||||||
elapsedMs: result['elapsed_ms'],
|
|
||||||
completedAt: result['completed_at'],
|
|
||||||
examId: result['exam_id'],
|
|
||||||
state: JSON.parse(result['state_json'] as string),
|
|
||||||
exam: JSON.parse(result['exam_json'] as string),
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
})
|
||||||
console.error('Get result error:', err)
|
)
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Save Result ─────────────────────────────────────────
|
// POST /api/results
|
||||||
router.post('/', requireAuth, (req, res) => {
|
router.post(
|
||||||
try {
|
'/',
|
||||||
const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, notes, state, exam, examId } = req.body
|
validateBody(submitResultSchema),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
if (
|
const data = req.body
|
||||||
!title || scorePoints == null || totalPoints == null ||
|
const { id } = resultRepo.createResult({
|
||||||
correctCount == null || gradedCount == null || !state || !exam
|
user_id: req.user!.userId,
|
||||||
) {
|
exam_id: data.exam_id ?? null,
|
||||||
res.status(400).json({ error: 'Missing required result fields' })
|
title: data.title,
|
||||||
return
|
score_points: data.score_points,
|
||||||
}
|
total_points: data.total_points,
|
||||||
|
correct_count: data.correct_count,
|
||||||
let noteSource = ''
|
graded_count: data.graded_count,
|
||||||
if (typeof notes === 'string') {
|
passing_score: data.passing_score ?? 60,
|
||||||
noteSource = notes
|
elapsed_ms: data.elapsed_ms,
|
||||||
} else if (typeof state.notes === 'string') {
|
state_json: JSON.stringify(data.state),
|
||||||
noteSource = state.notes
|
exam_json: JSON.stringify(data.exam),
|
||||||
}
|
completed_at: Date.now(),
|
||||||
const normalizedState = {
|
|
||||||
...state,
|
|
||||||
notes: noteSource.slice(0, 1000),
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb()
|
|
||||||
const result = db.prepare(`
|
|
||||||
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
req.user!.userId,
|
|
||||||
examId ?? null,
|
|
||||||
title,
|
|
||||||
scorePoints,
|
|
||||||
totalPoints,
|
|
||||||
correctCount,
|
|
||||||
gradedCount,
|
|
||||||
passingScore ?? 60,
|
|
||||||
elapsedMs ?? 0,
|
|
||||||
JSON.stringify(normalizedState),
|
|
||||||
JSON.stringify(exam),
|
|
||||||
)
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
id: result.lastInsertRowid,
|
|
||||||
message: 'Result saved successfully',
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
res.status(201).json({ id })
|
||||||
console.error('Save result error:', err)
|
})
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── Delete Result ───────────────────────────────────────
|
// DELETE /api/results/:id
|
||||||
router.delete('/:id', requireAuth, (req, res) => {
|
router.delete(
|
||||||
try {
|
'/:id',
|
||||||
const db = getDb()
|
validateParams(resultIdParamSchema),
|
||||||
const result = db.prepare(`
|
asyncHandler(async (req: Request, res) => {
|
||||||
DELETE FROM exam_results WHERE id = ? AND user_id = ?
|
const id = Number(req.params.id)
|
||||||
`).run(Number(req.params['id']), req.user!.userId)
|
const deleted = resultRepo.deleteResult(id, req.user!.userId)
|
||||||
|
if (!deleted) {
|
||||||
if (result.changes === 0) {
|
throw new AppError(404, 'Result not found')
|
||||||
res.status(404).json({ error: 'Result not found' })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
res.json({ success: true })
|
||||||
res.json({ message: 'Result deleted' })
|
})
|
||||||
} catch (err) {
|
)
|
||||||
console.error('Delete result error:', err)
|
|
||||||
res.status(500).json({ error: 'Internal server error' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { validateBody } from '../middleware/validate.js'
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
|
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||||
|
import { setupSchema } from '../validators/setupSchemas.js'
|
||||||
|
import { signToken } from '../utils/tokens.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
|
function getSiteTitle(): string | null {
|
||||||
|
return settingsRepo.getSetting('site_title') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/setup/status
|
||||||
|
router.get(
|
||||||
|
'/status',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const title = getSiteTitle()
|
||||||
|
res.json({
|
||||||
|
completed: !!title,
|
||||||
|
siteTitle: title ?? undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/setup
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
validateBody(setupSchema),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
if (getSiteTitle()) {
|
||||||
|
throw new AppError(400, 'Setup already completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRepo.countUsers() > 0) {
|
||||||
|
throw new AppError(400, 'Users already exist')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
siteTitle,
|
||||||
|
language,
|
||||||
|
smtpHost,
|
||||||
|
smtpPort,
|
||||||
|
smtpUser,
|
||||||
|
smtpPass,
|
||||||
|
smtpFrom,
|
||||||
|
smtpSecure,
|
||||||
|
appUrl,
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||||
|
|
||||||
|
const { id } = userRepo.createUser({
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
displayName,
|
||||||
|
role: 'admin',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const settings: Array<[string, string]> = [
|
||||||
|
['site_title', siteTitle?.trim() || 'BrainWave'],
|
||||||
|
['site_language', language || 'en'],
|
||||||
|
['maintenance_mode', 'false'],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
|
||||||
|
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
|
||||||
|
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
|
||||||
|
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
|
||||||
|
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
|
||||||
|
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
|
||||||
|
if (appUrl !== undefined) settings.push(['app_url', appUrl])
|
||||||
|
|
||||||
|
settingsRepo.setSettingsBatch(settings)
|
||||||
|
|
||||||
|
const token = signToken({
|
||||||
|
userId: id,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
role: 'admin',
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Setup completed successfully',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
|
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||||
|
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||||
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10
|
||||||
|
const VERIFY_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
token: string
|
||||||
|
user: { id: number; email: string; displayName: string; role: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(input: RegisterInput, isFirstUser: boolean): Promise<AuthResult> {
|
||||||
|
const existing = userRepo.getUserByEmail(input.email)
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError(400, 'Email already registered')
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
||||||
|
const role = isFirstUser ? 'admin' : 'user'
|
||||||
|
const verifyToken = generateVerificationToken()
|
||||||
|
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||||
|
|
||||||
|
const { id } = userRepo.createUser({
|
||||||
|
email: input.email,
|
||||||
|
passwordHash,
|
||||||
|
displayName: input.displayName,
|
||||||
|
role,
|
||||||
|
emailVerified: 0,
|
||||||
|
verifyToken,
|
||||||
|
verifyExpires,
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = userRepo.getUserById(id)!
|
||||||
|
|
||||||
|
// Don't await — fire and forget so registration isn't blocked by email failures
|
||||||
|
sendVerificationEmail(user.email, verifyToken).catch(() => {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: signToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
role: user.role as 'user' | 'admin',
|
||||||
|
}),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||||
|
const user = userRepo.getUserByEmail(input.email)
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(input.password, user.password_hash)
|
||||||
|
if (!valid) {
|
||||||
|
throw new AppError(401, 'Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: signToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
role: user.role as 'user' | 'admin',
|
||||||
|
}),
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMe(userId: number) {
|
||||||
|
const user = userRepo.getUserById(userId)
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(404, 'User not found')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.display_name,
|
||||||
|
role: user.role,
|
||||||
|
emailVerified: !!user.email_verified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(userId: number, input: UpdateProfileInput) {
|
||||||
|
userRepo.updateUser(userId, { displayName: input.displayName })
|
||||||
|
return getMe(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(userId: number, input: ChangePasswordInput) {
|
||||||
|
const user = userRepo.getUserById(userId)
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(404, 'User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(input.currentPassword, user.password_hash)
|
||||||
|
if (!valid) {
|
||||||
|
throw new AppError(400, 'Current password is incorrect')
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
||||||
|
userRepo.updateUser(userId, { passwordHash })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmail(input: VerifyEmailInput) {
|
||||||
|
const user = userRepo.getUserByVerifyToken(input.token)
|
||||||
|
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
||||||
|
throw new AppError(400, 'Invalid or expired verification token')
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo.updateUser(user.id, {
|
||||||
|
emailVerified: 1,
|
||||||
|
verifyToken: null,
|
||||||
|
verifyExpires: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPassword(email?: string) {
|
||||||
|
if (!email) {
|
||||||
|
throw new AppError(400, 'Email is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userRepo.getUserByEmail(email)
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal whether email exists
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToken = generateVerificationToken()
|
||||||
|
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
||||||
|
|
||||||
|
userRepo.updateUser(user.id, { resetToken, resetExpires })
|
||||||
|
|
||||||
|
// Fire and forget
|
||||||
|
sendPasswordResetEmail(user.email, resetToken).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(input: ResetPasswordInput) {
|
||||||
|
const user = userRepo.getUserByResetToken(input.token)
|
||||||
|
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
||||||
|
throw new AppError(400, 'Invalid or expired reset token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
||||||
|
userRepo.updateUser(user.id, {
|
||||||
|
passwordHash,
|
||||||
|
resetToken: null,
|
||||||
|
resetExpires: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUserAccount(userId: number) {
|
||||||
|
const success = userRepo.deleteUser(userId)
|
||||||
|
if (!success) {
|
||||||
|
throw new AppError(404, 'User not found')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { asyncHandler } from './asyncHandler'
|
||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
const mockReq = {} as Request
|
||||||
|
const mockRes = {} as Response
|
||||||
|
let mockNext: NextFunction
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNext = vi.fn() as unknown as NextFunction
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('asyncHandler', () => {
|
||||||
|
it('calls next with error when async handler throws', async () => {
|
||||||
|
const error = new Error('boom')
|
||||||
|
const handler = asyncHandler(async () => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
await handler(mockReq, mockRes, mockNext)
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call next when handler resolves', async () => {
|
||||||
|
const handler = asyncHandler(async () => {
|
||||||
|
// success
|
||||||
|
})
|
||||||
|
await handler(mockReq, mockRes, mockNext)
|
||||||
|
expect(mockNext).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls next with error when handler rejects', async () => {
|
||||||
|
const error = new Error('rejected')
|
||||||
|
const handler = asyncHandler(() => Promise.reject(error))
|
||||||
|
await handler(mockReq, mockRes, mockNext)
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Request, Response, NextFunction, RequestHandler } from 'express'
|
||||||
|
|
||||||
|
export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>): RequestHandler {
|
||||||
|
return (req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-41
@@ -1,46 +1,120 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from "nodemailer";
|
||||||
|
import { getDb } from "../db/database.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
const APP_URL = process.env['APP_URL'] ?? 'http://localhost:5173'
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SMTP_HOST = process.env['SMTP_HOST']
|
export interface SmtpConfig {
|
||||||
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587')
|
host: string;
|
||||||
const SMTP_USER = process.env['SMTP_USER']
|
port: number;
|
||||||
const SMTP_PASS = process.env['SMTP_PASS']
|
user: string;
|
||||||
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>'
|
pass: string;
|
||||||
|
from: string;
|
||||||
const isDev = !SMTP_HOST
|
secure: boolean;
|
||||||
|
|
||||||
let transporter: nodemailer.Transporter | null = null
|
|
||||||
|
|
||||||
function getTransporter(): nodemailer.Transporter | null {
|
|
||||||
if (isDev) return null
|
|
||||||
if (!transporter) {
|
|
||||||
transporter = nodemailer.createTransport({
|
|
||||||
host: SMTP_HOST,
|
|
||||||
port: SMTP_PORT,
|
|
||||||
secure: SMTP_PORT === 465,
|
|
||||||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return transporter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMail(to: string, subject: string, html: string): Promise<void> {
|
// ─── App URL ──────────────────────────────────────────────────────────────────
|
||||||
const t = getTransporter()
|
|
||||||
if (!t) {
|
export function getAppUrl(): string {
|
||||||
console.log('\n══════════════════════════════════════════════════')
|
try {
|
||||||
console.log(`📧 EMAIL (dev mode) → ${to}`)
|
const db = getDb();
|
||||||
console.log(` Subject: ${subject}`)
|
const row = db
|
||||||
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`)
|
.prepare("SELECT value FROM site_settings WHERE key = ?")
|
||||||
console.log('══════════════════════════════════════════════════\n')
|
.get("app_url") as { value: string } | undefined;
|
||||||
return
|
return (
|
||||||
|
(row?.value?.trim() || config.appUrl) ?? "http://localhost:5173"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return config.appUrl;
|
||||||
}
|
}
|
||||||
await t.sendMail({ from: SMTP_FROM, to, subject, html })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendVerificationEmail(to: string, token: string): Promise<void> {
|
// ─── SMTP Config ──────────────────────────────────────────────────────────────
|
||||||
const link = `${APP_URL}/verify?token=${token}`
|
|
||||||
await sendMail(to, 'Verify your BrainWave account', `
|
function getSetting(key: string): string {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT value FROM site_settings WHERE key = ?")
|
||||||
|
.get(key) as { value: string } | undefined;
|
||||||
|
return row?.value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmailConfigured(): boolean {
|
||||||
|
return !!getSetting("smtp_host").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSmtpConfig(): SmtpConfig | null {
|
||||||
|
const host = getSetting("smtp_host").trim();
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
const portStr = getSetting("smtp_port").trim() || "587";
|
||||||
|
const port = Number(portStr);
|
||||||
|
const user = getSetting("smtp_user").trim();
|
||||||
|
const pass = getSetting("smtp_pass").trim();
|
||||||
|
const from =
|
||||||
|
getSetting("smtp_from").trim() || "BrainWave <noreply@brainwave.local>";
|
||||||
|
const secureStr = getSetting("smtp_secure").trim() || "auto";
|
||||||
|
|
||||||
|
let secure: boolean;
|
||||||
|
if (secureStr === "true") {
|
||||||
|
secure = true;
|
||||||
|
} else if (secureStr === "false") {
|
||||||
|
secure = false;
|
||||||
|
} else {
|
||||||
|
// 'auto': derive from port
|
||||||
|
secure = port === 465;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { host, port, user, pass, from, secure };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transporter ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildTransporter(cfg: SmtpConfig): nodemailer.Transporter {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: cfg.host,
|
||||||
|
port: cfg.port,
|
||||||
|
secure: cfg.secure,
|
||||||
|
auth: cfg.user ? { user: cfg.user, pass: cfg.pass } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core Send ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function sendMail(
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
html: string,
|
||||||
|
overrideConfig?: SmtpConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = overrideConfig ?? getSmtpConfig();
|
||||||
|
|
||||||
|
if (!cfg) {
|
||||||
|
// Dev mode — no SMTP configured, log to console
|
||||||
|
console.log("\n══════════════════════════════════════════════════");
|
||||||
|
console.log(`📧 EMAIL (dev mode) → ${to}`);
|
||||||
|
console.log(` Subject: ${subject}`);
|
||||||
|
console.log(` Body:\n${html.replace(/<[^>]*>/g, "")}`);
|
||||||
|
console.log("══════════════════════════════════════════════════\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = buildTransporter(cfg);
|
||||||
|
await transporter.sendMail({ from: cfg.from, to, subject, html });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Exported Email Functions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function sendVerificationEmail(
|
||||||
|
to: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const appUrl = getAppUrl();
|
||||||
|
const link = `${appUrl}/verify?token=${token}`;
|
||||||
|
await sendMail(
|
||||||
|
to,
|
||||||
|
"Verify your BrainWave account",
|
||||||
|
`
|
||||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
|
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
|
||||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||||
@@ -56,12 +130,20 @@ export async function sendVerificationEmail(to: string, token: string): Promise<
|
|||||||
This link expires in 24 hours. If you didn't create an account, you can ignore this email.
|
This link expires in 24 hours. If you didn't create an account, you can ignore this email.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`)
|
`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> {
|
export async function sendPasswordResetEmail(
|
||||||
const link = `${APP_URL}/reset-password?token=${token}`
|
to: string,
|
||||||
await sendMail(to, 'Reset your BrainWave password', `
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const appUrl = getAppUrl();
|
||||||
|
const link = `${appUrl}/reset-password?token=${token}`;
|
||||||
|
await sendMail(
|
||||||
|
to,
|
||||||
|
"Reset your BrainWave password",
|
||||||
|
`
|
||||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
|
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
|
||||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||||
@@ -77,5 +159,28 @@ export async function sendPasswordResetEmail(to: string, token: string): Promise
|
|||||||
This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
|
This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`)
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestEmail(
|
||||||
|
to: string,
|
||||||
|
overrideConfig?: SmtpConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
await sendMail(
|
||||||
|
to,
|
||||||
|
"Test email from BrainWave",
|
||||||
|
`
|
||||||
|
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||||
|
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">This is a test email from BrainWave</h2>
|
||||||
|
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||||
|
If you're reading this, your SMTP configuration is working correctly.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 13px; color: #888; margin-top: 32px;">
|
||||||
|
Sent at: ${new Date().toUTCString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
overrideConfig,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('tokens', () => {
|
||||||
|
it('round-trips JWT payload', async () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
|
||||||
|
const { signToken, verifyToken } = await import('./tokens')
|
||||||
|
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
|
||||||
|
const token = signToken(payload)
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
expect(decoded.userId).toBe(1)
|
||||||
|
expect(decoded.email).toBe('a@b.com')
|
||||||
|
expect(decoded.displayName).toBe('Test')
|
||||||
|
expect(decoded.role).toBe('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on invalid token', async () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
|
||||||
|
const { verifyToken } = await import('./tokens')
|
||||||
|
expect(() => verifyToken('invalid.token.here')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on tampered token', async () => {
|
||||||
|
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
|
||||||
|
const { signToken, verifyToken } = await import('./tokens')
|
||||||
|
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
|
||||||
|
const token = signToken(payload)
|
||||||
|
const tampered = token.slice(0, -5) + 'xxxxx'
|
||||||
|
expect(() => verifyToken(tampered)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates hex verification token of expected length', async () => {
|
||||||
|
const { generateVerificationToken } = await import('./tokens')
|
||||||
|
const token = generateVerificationToken()
|
||||||
|
expect(token).toMatch(/^[a-f0-9]+$/)
|
||||||
|
expect(token.length).toBe(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates unique verification tokens', async () => {
|
||||||
|
const { generateVerificationToken } = await import('./tokens')
|
||||||
|
const t1 = generateVerificationToken()
|
||||||
|
const t2 = generateVerificationToken()
|
||||||
|
expect(t1).not.toBe(t2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { config } from '../config.js'
|
||||||
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
|
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
userId: number
|
userId: number
|
||||||
@@ -11,11 +10,11 @@ export interface JwtPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signToken(payload: JwtPayload): string {
|
export function signToken(payload: JwtPayload): string {
|
||||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' })
|
return jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyToken(token: string): JwtPayload {
|
export function verifyToken(token: string): JwtPayload {
|
||||||
return jwt.verify(token, JWT_SECRET) as JwtPayload
|
return jwt.verify(token, config.jwtSecret) as JwtPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||||
|
|
||||||
|
describe('updateUserRoleSchema', () => {
|
||||||
|
it('accepts valid role change', () => {
|
||||||
|
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid role', () => {
|
||||||
|
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'superadmin' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects zero user_id', () => {
|
||||||
|
const result = updateUserRoleSchema.safeParse({ user_id: 0, role: 'admin' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateSettingSchema', () => {
|
||||||
|
it('accepts valid setting', () => {
|
||||||
|
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty key', () => {
|
||||||
|
const result = updateSettingSchema.safeParse({ key: '', value: 'BrainWave' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty value', () => {
|
||||||
|
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listUsersQuerySchema', () => {
|
||||||
|
it('accepts empty query', () => {
|
||||||
|
const result = listUsersQuerySchema.safeParse({})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect((result as any).data.page).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts page string', () => {
|
||||||
|
const result = listUsersQuerySchema.safeParse({ page: '3' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect((result as any).data.page).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-numeric page', () => {
|
||||||
|
const result = listUsersQuerySchema.safeParse({ page: 'abc' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts search', () => {
|
||||||
|
const result = listUsersQuerySchema.safeParse({ search: 'john' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('userIdParamSchema', () => {
|
||||||
|
it('accepts numeric id', () => {
|
||||||
|
const result = userIdParamSchema.safeParse({ id: '7' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-numeric id', () => {
|
||||||
|
const result = userIdParamSchema.safeParse({ id: 'seven' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const updateUserRoleSchema = z.object({
|
||||||
|
user_id: z.number().min(1),
|
||||||
|
role: z.enum(['user', 'admin']),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateSettingSchema = z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
value: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const listUsersQuerySchema = z.object({
|
||||||
|
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
|
||||||
|
search: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const userIdParamSchema = z.object({
|
||||||
|
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { registerSchema, loginSchema, verifyEmailSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
|
||||||
|
|
||||||
|
describe('registerSchema', () => {
|
||||||
|
it('accepts valid registration', () => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
displayName: 'Test User',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid email', () => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
email: 'not-an-email',
|
||||||
|
password: 'password123',
|
||||||
|
displayName: 'Test User',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects short password', () => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: '123',
|
||||||
|
displayName: 'Test User',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty display name', () => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
displayName: '',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loginSchema', () => {
|
||||||
|
it('accepts valid login', () => {
|
||||||
|
const result = loginSchema.safeParse({ email: 'test@example.com', password: 'pass' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing email', () => {
|
||||||
|
const result = loginSchema.safeParse({ password: 'pass' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verifyEmailSchema', () => {
|
||||||
|
it('accepts valid token', () => {
|
||||||
|
const result = verifyEmailSchema.safeParse({ token: 'abc123' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty token', () => {
|
||||||
|
const result = verifyEmailSchema.safeParse({ token: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetPasswordSchema', () => {
|
||||||
|
it('accepts valid reset', () => {
|
||||||
|
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects short password', () => {
|
||||||
|
const result = resetPasswordSchema.safeParse({ token: 'abc', password: '123' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateProfileSchema', () => {
|
||||||
|
it('accepts valid profile', () => {
|
||||||
|
const result = updateProfileSchema.safeParse({ displayName: 'New Name' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty displayName', () => {
|
||||||
|
const result = updateProfileSchema.safeParse({ displayName: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('changePasswordSchema', () => {
|
||||||
|
it('accepts valid change', () => {
|
||||||
|
const result = changePasswordSchema.safeParse({
|
||||||
|
currentPassword: 'oldpass',
|
||||||
|
newPassword: 'newpass123',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('forgotPasswordSchema', () => {
|
||||||
|
it('accepts valid email', () => {
|
||||||
|
const result = forgotPasswordSchema.safeParse({ email: 'test@example.com' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
const result = forgotPasswordSchema.safeParse({})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid email', () => {
|
||||||
|
const result = forgotPasswordSchema.safeParse({ email: 'bad' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
email: z.string().email().max(255),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const verifyEmailSchema = z.object({
|
||||||
|
token: z.string().min(1, 'Token is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resetPasswordSchema = z.object({
|
||||||
|
token: z.string().min(1, 'Token is required'),
|
||||||
|
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateProfileSchema = z.object({
|
||||||
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string(),
|
||||||
|
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const forgotPasswordSchema = z.object({
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RegisterInput = z.infer<typeof registerSchema>
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>
|
||||||
|
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
|
||||||
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
|
||||||
|
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
|
||||||
|
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { examSchema, saveExamSchema, updateExamSchema, examIdParamSchema } from './examSchemas'
|
||||||
|
|
||||||
|
describe('examSchema', () => {
|
||||||
|
it('accepts minimal valid exam', () => {
|
||||||
|
const result = examSchema.safeParse({
|
||||||
|
title: 'My Exam',
|
||||||
|
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing title', () => {
|
||||||
|
const result = examSchema.safeParse({
|
||||||
|
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty questions array', () => {
|
||||||
|
const result = examSchema.safeParse({
|
||||||
|
title: 'My Exam',
|
||||||
|
questions: [],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects question missing id', () => {
|
||||||
|
const result = examSchema.safeParse({
|
||||||
|
title: 'My Exam',
|
||||||
|
questions: [{ type: 'essay', prompt: 'Q' }],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts optional id', () => {
|
||||||
|
const result = examSchema.safeParse({
|
||||||
|
id: 'abc',
|
||||||
|
title: 'My Exam',
|
||||||
|
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveExamSchema', () => {
|
||||||
|
it('accepts valid save payload', () => {
|
||||||
|
const result = saveExamSchema.safeParse({
|
||||||
|
exam: {
|
||||||
|
title: 'My Exam',
|
||||||
|
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing exam', () => {
|
||||||
|
const result = saveExamSchema.safeParse({})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateExamSchema', () => {
|
||||||
|
it('accepts valid update payload', () => {
|
||||||
|
const result = updateExamSchema.safeParse({
|
||||||
|
exam: {
|
||||||
|
title: 'Updated',
|
||||||
|
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('examIdParamSchema', () => {
|
||||||
|
it('accepts numeric id string', () => {
|
||||||
|
const result = examIdParamSchema.safeParse({ id: '42' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-numeric id', () => {
|
||||||
|
const result = examIdParamSchema.safeParse({ id: 'abc' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const choiceSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const matchItemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionImageSchema = z.object({
|
||||||
|
src: z.string(),
|
||||||
|
alt: z.string().optional(),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionAudioSchema = z.object({
|
||||||
|
src: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionCodeSchema = z.object({
|
||||||
|
language: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionTableSchema = z.object({
|
||||||
|
headers: z.array(z.string()),
|
||||||
|
rows: z.array(z.array(z.string())),
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.enum([
|
||||||
|
'multiple-choice',
|
||||||
|
'multiple-select',
|
||||||
|
'short-answer',
|
||||||
|
'translation',
|
||||||
|
'math-input',
|
||||||
|
'essay',
|
||||||
|
'match',
|
||||||
|
'order',
|
||||||
|
]),
|
||||||
|
category: z.string().optional(),
|
||||||
|
points: z.number().optional(),
|
||||||
|
prompt: z.string(),
|
||||||
|
explanation: z.string().optional(),
|
||||||
|
image: questionImageSchema.optional(),
|
||||||
|
audio: questionAudioSchema.optional(),
|
||||||
|
code: questionCodeSchema.optional(),
|
||||||
|
table: questionTableSchema.optional(),
|
||||||
|
svg: z.string().optional(),
|
||||||
|
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(),
|
||||||
|
acceptedForms: z.array(z.string()).optional(),
|
||||||
|
caseSensitive: z.boolean().optional(),
|
||||||
|
ignorePunctuation: z.boolean().optional(),
|
||||||
|
ignoreAccents: z.boolean().optional(),
|
||||||
|
sourceLanguage: z.string().optional(),
|
||||||
|
targetLanguage: z.string().optional(),
|
||||||
|
choices: z.array(choiceSchema).optional(),
|
||||||
|
minWords: z.number().optional(),
|
||||||
|
rubric: z.array(z.string()).optional(),
|
||||||
|
left: z.array(matchItemSchema).optional(),
|
||||||
|
right: z.array(matchItemSchema).optional(),
|
||||||
|
items: z.array(matchItemSchema).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const examSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
timeLimitMinutes: z.number().nullable().optional(),
|
||||||
|
feedbackMode: z.enum(['end', 'immediate']).optional(),
|
||||||
|
passingScore: z.number().optional(),
|
||||||
|
categories: z.array(z.string()).optional(),
|
||||||
|
questions: z.array(questionSchema).min(1, 'At least one question is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const saveExamSchema = z.object({
|
||||||
|
exam: examSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateExamSchema = z.object({
|
||||||
|
exam: examSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const examIdParamSchema = z.object({
|
||||||
|
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ExamInput = z.infer<typeof examSchema>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { submitResultSchema, resultIdParamSchema } from './resultSchemas'
|
||||||
|
|
||||||
|
describe('submitResultSchema', () => {
|
||||||
|
it('accepts valid result', () => {
|
||||||
|
const result = submitResultSchema.safeParse({
|
||||||
|
title: 'My Exam',
|
||||||
|
score_points: 8,
|
||||||
|
total_points: 10,
|
||||||
|
correct_count: 8,
|
||||||
|
graded_count: 10,
|
||||||
|
elapsed_ms: 120000,
|
||||||
|
state: {
|
||||||
|
currentIdx: 0,
|
||||||
|
answers: { q1: 'a' },
|
||||||
|
flags: { q1: false },
|
||||||
|
notes: '',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
feedbackMode: 'end',
|
||||||
|
},
|
||||||
|
exam: {
|
||||||
|
title: 'My Exam',
|
||||||
|
questions: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing title', () => {
|
||||||
|
const result = submitResultSchema.safeParse({
|
||||||
|
score_points: 8,
|
||||||
|
total_points: 10,
|
||||||
|
correct_count: 8,
|
||||||
|
graded_count: 10,
|
||||||
|
elapsed_ms: 120000,
|
||||||
|
state: {
|
||||||
|
currentIdx: 0,
|
||||||
|
answers: {},
|
||||||
|
flags: {},
|
||||||
|
notes: '',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
feedbackMode: 'end',
|
||||||
|
},
|
||||||
|
exam: { title: 'My Exam', questions: [] },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts optional exam_id', () => {
|
||||||
|
const result = submitResultSchema.safeParse({
|
||||||
|
exam_id: 1,
|
||||||
|
title: 'My Exam',
|
||||||
|
score_points: 8,
|
||||||
|
total_points: 10,
|
||||||
|
correct_count: 8,
|
||||||
|
graded_count: 10,
|
||||||
|
elapsed_ms: 120000,
|
||||||
|
state: {
|
||||||
|
currentIdx: 0,
|
||||||
|
answers: {},
|
||||||
|
flags: {},
|
||||||
|
notes: '',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
feedbackMode: 'end',
|
||||||
|
},
|
||||||
|
exam: { title: 'My Exam', questions: [] },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts negative score_points because schema allows any number', () => {
|
||||||
|
const result = submitResultSchema.safeParse({
|
||||||
|
title: 'My Exam',
|
||||||
|
score_points: -1,
|
||||||
|
total_points: 10,
|
||||||
|
correct_count: 8,
|
||||||
|
graded_count: 10,
|
||||||
|
elapsed_ms: 120000,
|
||||||
|
state: {
|
||||||
|
currentIdx: 0,
|
||||||
|
answers: {},
|
||||||
|
flags: {},
|
||||||
|
notes: '',
|
||||||
|
startedAt: Date.now(),
|
||||||
|
feedbackMode: 'end',
|
||||||
|
},
|
||||||
|
exam: { title: 'My Exam', questions: [] },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts immediate feedback mode', () => {
|
||||||
|
const result = submitResultSchema.safeParse({
|
||||||
|
title: 'My Exam',
|
||||||
|
score_points: 8,
|
||||||
|
total_points: 10,
|
||||||
|
correct_count: 8,
|
||||||
|
graded_count: 10,
|
||||||
|
elapsed_ms: 120000,
|
||||||
|
state: {
|
||||||
|
currentIdx: 2,
|
||||||
|
answers: { q1: 'a', q2: 'b' },
|
||||||
|
flags: { q1: true },
|
||||||
|
notes: 'some notes',
|
||||||
|
revealed: { q1: true },
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedAt: Date.now(),
|
||||||
|
feedbackMode: 'immediate',
|
||||||
|
},
|
||||||
|
exam: { title: 'My Exam', questions: [] },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resultIdParamSchema', () => {
|
||||||
|
it('accepts numeric id string', () => {
|
||||||
|
const result = resultIdParamSchema.safeParse({ id: '42' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-numeric id', () => {
|
||||||
|
const result = resultIdParamSchema.safeParse({ id: 'abc' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const submitResultSchema = z.object({
|
||||||
|
exam_id: z.number().nullable().optional(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
score_points: z.number(),
|
||||||
|
total_points: z.number(),
|
||||||
|
correct_count: z.number(),
|
||||||
|
graded_count: z.number(),
|
||||||
|
passing_score: z.number().optional(),
|
||||||
|
elapsed_ms: z.number(),
|
||||||
|
state: z.record(z.unknown()),
|
||||||
|
exam: z.record(z.unknown()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resultIdParamSchema = z.object({
|
||||||
|
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { setupSchema, setupStatusSchema } from './setupSchemas'
|
||||||
|
|
||||||
|
describe('setupSchema', () => {
|
||||||
|
it('accepts valid setup', () => {
|
||||||
|
const result = setupSchema.safeParse({
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: 'adminpass123',
|
||||||
|
displayName: 'Admin',
|
||||||
|
siteTitle: 'BrainWave',
|
||||||
|
language: 'en',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid email', () => {
|
||||||
|
const result = setupSchema.safeParse({
|
||||||
|
email: 'bad',
|
||||||
|
password: 'adminpass123',
|
||||||
|
displayName: 'Admin',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects short password', () => {
|
||||||
|
const result = setupSchema.safeParse({
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: '123',
|
||||||
|
displayName: 'Admin',
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setupStatusSchema', () => {
|
||||||
|
it('accepts empty object', () => {
|
||||||
|
const result = setupStatusSchema.safeParse({})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts optional email', () => {
|
||||||
|
const result = setupStatusSchema.safeParse({ email: 'admin@example.com' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid optional email', () => {
|
||||||
|
const result = setupStatusSchema.safeParse({ email: 'bad' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const setupSchema = z.object({
|
||||||
|
email: z.string().email().max(255),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
|
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
||||||
|
language: z.enum(['en', 'nl']).optional(),
|
||||||
|
smtpHost: z.string().max(255).optional(),
|
||||||
|
smtpPort: z.string().max(10).optional(),
|
||||||
|
smtpUser: z.string().max(255).optional(),
|
||||||
|
smtpPass: z.string().max(255).optional(),
|
||||||
|
smtpFrom: z.string().max(255).optional(),
|
||||||
|
smtpSecure: z.enum(['auto', 'true', 'false']).optional(),
|
||||||
|
appUrl: z.string().max(255).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setupStatusSchema = z.object({
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
|
||||||
|
password: z.string().min(8).optional(),
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret-for-unit-tests-only')
|
||||||
|
vi.stubEnv('NODE_ENV', 'test')
|
||||||
|
vi.stubEnv('DB_PATH', ':memory:')
|
||||||
|
vi.stubEnv('CORS_ORIGIN', 'http://localhost:5173')
|
||||||
|
vi.stubEnv('APP_URL', 'http://localhost:5173')
|
||||||
Binary file not shown.
@@ -0,0 +1,446 @@
|
|||||||
|
# BrainWave Implementation Plan — Future Expansion Readiness
|
||||||
|
|
||||||
|
> **Status**: Phase 1 & Phase 2 Complete (Foundation + Consolidation). All TypeScript compilation verified.
|
||||||
|
> **Date**: 2026-04-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This plan documents all changes made and remaining work needed to make BrainWave ready for future expansion. The core principle is **layered architecture**: separating data access, business logic, API routes, and frontend concerns into independent, testable layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Completed (Phase 1 — Foundation + Phase 2 — Consolidation)
|
||||||
|
|
||||||
|
### 1. Backend: Repository Layer
|
||||||
|
|
||||||
|
**Problem**: Routes had direct SQL/DB access mixed with business logic.
|
||||||
|
|
||||||
|
**Solution**: Created pure data access modules:
|
||||||
|
|
||||||
|
| Repository | File | Responsibility |
|
||||||
|
| -------------- | ------------------------------------------ | ------------------------------- |
|
||||||
|
| `userRepo` | `backend/src/repositories/userRepo.ts` | Users CRUD, counting, searching |
|
||||||
|
| `examRepo` | `backend/src/repositories/examRepo.ts` | Exams CRUD, listing by user |
|
||||||
|
| `resultRepo` | `backend/src/repositories/resultRepo.ts` | Results CRUD, statistics |
|
||||||
|
| `settingsRepo` | `backend/src/repositories/settingsRepo.ts` | Key-value settings |
|
||||||
|
|
||||||
|
**Why**: Lets you swap SQLite for PostgreSQL later by only touching 4 files. No route or service changes needed.
|
||||||
|
|
||||||
|
### 2. Backend: Zod Validation + Middleware
|
||||||
|
|
||||||
|
**Problem**: No input validation. Manual type checking in every route.
|
||||||
|
|
||||||
|
**Solution**: Created schema-per-domain:
|
||||||
|
|
||||||
|
- `authSchemas.ts` — register, login, profile, password, email verification, reset
|
||||||
|
- `examSchemas.ts` — save, update, id param
|
||||||
|
- `resultSchemas.ts` — submit, id param
|
||||||
|
- `adminSchemas.ts` — role update, settings, user list queries
|
||||||
|
- `setupSchemas.ts` — wizard setup
|
||||||
|
|
||||||
|
**Middleware**: `validateBody()`, `validateParams()`, `validateQuery()` — generic wrappers that parse with Zod and attach `req.parsedBody`/`req.parsedQuery`.
|
||||||
|
|
||||||
|
**Why**: Defensive coding. Prevents injection, malformed data, and gives typed request bodies.
|
||||||
|
|
||||||
|
### 3. Backend: Centralized Error Handling
|
||||||
|
|
||||||
|
**Problem**: try/catch in every async route handler, inconsistent error responses.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
|
||||||
|
- `AppError` class with status codes
|
||||||
|
- `asyncHandler()` wrapper — catches async errors, forwards to error handler
|
||||||
|
- `errorHandler` middleware — consistent JSON error format
|
||||||
|
|
||||||
|
**Why**: No more try/catch boilerplate. All errors flow through one path.
|
||||||
|
|
||||||
|
### 4. Backend: Service Layer (Auth)
|
||||||
|
|
||||||
|
**Problem**: Auth business logic (registration, login, email flow) was inline in routes.
|
||||||
|
|
||||||
|
**Solution**: `backend/src/services/authService.ts` — pure business logic, no HTTP concerns.
|
||||||
|
|
||||||
|
**Why**: Business rules can be unit tested without spinning up Express. Enables sharing logic between REST and future GraphQL/WebSocket APIs.
|
||||||
|
|
||||||
|
### 5. Backend: Route Refactoring
|
||||||
|
|
||||||
|
All routes now follow the same pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
middleware → validateInput → asyncHandler(serviceCall) → errorHandler
|
||||||
|
```
|
||||||
|
|
||||||
|
| Route | Status |
|
||||||
|
| ------------ | -------------------------------------------------------- |
|
||||||
|
| `auth.ts` | ✅ Full refactor (services + validation + rate limiting) |
|
||||||
|
| `exams.ts` | ✅ Refactored (repo + validation + asyncHandler) |
|
||||||
|
| `results.ts` | ✅ Refactored (repo + validation + asyncHandler) |
|
||||||
|
| `admin.ts` | ✅ Refactored (repo + validation + asyncHandler) |
|
||||||
|
| `setup.ts` | ✅ Refactored (repo + validation + asyncHandler) |
|
||||||
|
|
||||||
|
### 6. Backend: Security Hardening
|
||||||
|
|
||||||
|
- **Rate limiting** on auth endpoints (10 req/15min window)
|
||||||
|
- **JWT secret validation** — app exits with clear error if `JWT_SECRET` missing
|
||||||
|
- **Request logging** — `METHOD PATH STATUS - DURATIONms`
|
||||||
|
|
||||||
|
### 7. Frontend: API Abstraction Layer
|
||||||
|
|
||||||
|
**Problem**: Raw `apiFetch()` calls scattered across components. Hard to change endpoints or add caching.
|
||||||
|
|
||||||
|
**Solution**: Domain-specific API modules:
|
||||||
|
|
||||||
|
| Module | File | Covers |
|
||||||
|
| ----------- | ------------------------------- | ---------------------------------- |
|
||||||
|
| `authApi` | `frontend/src/api/authApi.ts` | Login, register, profile, password |
|
||||||
|
| `examApi` | `frontend/src/api/examApi.ts` | CRUD for exams |
|
||||||
|
| `resultApi` | `frontend/src/api/resultApi.ts` | List, get, submit, delete results |
|
||||||
|
| `adminApi` | `frontend/src/api/adminApi.ts` | Users, stats, settings |
|
||||||
|
| `setupApi` | `frontend/src/api/setupApi.ts` | Setup wizard |
|
||||||
|
|
||||||
|
**Updated components**: ALL components now import from API modules:
|
||||||
|
|
||||||
|
- `AuthContext` — uses `authApi` + `setupApi`
|
||||||
|
- `MyExams` — uses `examApi`
|
||||||
|
- `MyResults` — uses `resultApi`
|
||||||
|
- `ExamEditor` — uses `examApi`
|
||||||
|
- `Settings` — uses `authApi`
|
||||||
|
- `AdminDashboard` — uses `adminApi`
|
||||||
|
- `SetupWizard` — uses `setupApi`
|
||||||
|
- `ExamRunner` — uses `resultApi`
|
||||||
|
- `Workspace` — uses `authApi`
|
||||||
|
|
||||||
|
Zero `apiFetch()` calls remain in any `.tsx` file.
|
||||||
|
|
||||||
|
**Why**: One place to add request deduplication, caching, or optimistic updates. Also makes mocking for tests trivial.
|
||||||
|
|
||||||
|
### 8. Frontend: React Error Boundaries
|
||||||
|
|
||||||
|
**Problem**: Any rendering error crashes the entire app (white screen).
|
||||||
|
|
||||||
|
**Solution**: `frontend/src/components/ErrorBoundary.tsx` — class component with fallback UI showing error message and reload button.
|
||||||
|
|
||||||
|
**Integration**: Wrapped the entire `<Routes>` tree in `ErrorBoundary` inside `App.tsx`.
|
||||||
|
|
||||||
|
**Why**: Isolates crashes to the component that failed, not the whole SPA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Completed (Phase 2 — Consolidation)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### A. Database Migration System
|
||||||
|
|
||||||
|
**Priority**: High (blocks schema evolution)
|
||||||
|
|
||||||
|
**Current**: Schema is created inline in `database.ts` on startup. No version tracking.
|
||||||
|
|
||||||
|
**Needed**:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/db/migrations/
|
||||||
|
001_initial.sql
|
||||||
|
002_add_exam_tags.sql
|
||||||
|
003_add_user_preferences.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
With a `schema_migrations` table tracking applied versions. Run on startup, ordered by filename.
|
||||||
|
|
||||||
|
**Why**: Required before any schema change. Without it, adding columns = production downtime or manual ALTER TABLE.
|
||||||
|
|
||||||
|
#### B. Database Connection Pooling / Migration to PostgreSQL
|
||||||
|
|
||||||
|
**Priority**: Medium
|
||||||
|
|
||||||
|
**Current**: SQLite with `better-sqlite3`. Single connection, file-based.
|
||||||
|
|
||||||
|
**Why SQLite is fine for now**: Single-node deployment, no concurrent writes expected.
|
||||||
|
|
||||||
|
**When to migrate**:
|
||||||
|
|
||||||
|
- Multi-instance deployments
|
||||||
|
- Concurrent writes (multiple admins)
|
||||||
|
- Need for backups, replication, or managed hosting
|
||||||
|
|
||||||
|
**Path**: Abstract `getDb()` behind an interface. Swap implementation.
|
||||||
|
|
||||||
|
#### C. Email Delivery Reliability
|
||||||
|
|
||||||
|
**Priority**: Medium
|
||||||
|
|
||||||
|
**Current**: Nodemailer with SMTP. No queue, no retry.
|
||||||
|
|
||||||
|
**Needed**:
|
||||||
|
|
||||||
|
- Abstract email behind an interface (`IEmailService`)
|
||||||
|
- Implementations: `SmtpEmailService`, `ConsoleEmailService` (dev), `SendGridEmailService`
|
||||||
|
- Add retry with exponential backoff
|
||||||
|
- Consider a job queue (Bull + Redis) for high volume
|
||||||
|
|
||||||
|
#### D. Comprehensive API Testing
|
||||||
|
|
||||||
|
**Priority**: High
|
||||||
|
|
||||||
|
**Current**: Vitest installed but no tests.
|
||||||
|
|
||||||
|
**Needed**:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/tests/
|
||||||
|
auth.test.ts — register, login, me, change password
|
||||||
|
exams.test.ts — CRUD
|
||||||
|
results.test.ts — submit, list, delete
|
||||||
|
admin.test.ts — user management, stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `supertest` for HTTP-level tests, in-memory SQLite for isolation.
|
||||||
|
|
||||||
|
#### E. Environment Configuration Management ✅
|
||||||
|
|
||||||
|
**Created**: `backend/src/config.ts`
|
||||||
|
|
||||||
|
Centralized environment variable management with:
|
||||||
|
|
||||||
|
- `env()` helper for optional vars with defaults
|
||||||
|
- `env.required()` helper for mandatory vars (throws with clear error)
|
||||||
|
- Type-safe exports: `JWT_SECRET`, `PORT`, `DB_PATH`, `SMTP_HOST`, `SMTP_PORT`, `EMAIL_FROM`, `FRONTEND_URL`, `NODE_ENV`
|
||||||
|
- `tokens.ts` updated to use `config.JWT_SECRET` instead of `process.env`
|
||||||
|
|
||||||
|
This provides fail-fast validation at startup and a single source of truth for all environment configuration.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### F. Remaining Component API Migration ✅
|
||||||
|
|
||||||
|
**Priority**: High
|
||||||
|
|
||||||
|
All components migrated to API modules. Zero raw `apiFetch()` calls remain in `.tsx` files:
|
||||||
|
|
||||||
|
| Component | API Module Used |
|
||||||
|
| -------------------- | --------------- |
|
||||||
|
| `Settings.tsx` | `authApi` |
|
||||||
|
| `AdminDashboard.tsx` | `adminApi` |
|
||||||
|
| `SetupWizard.tsx` | `setupApi` |
|
||||||
|
| `ExamRunner.tsx` | `resultApi` |
|
||||||
|
| `Workspace.tsx` | `authApi` |
|
||||||
|
|
||||||
|
#### G. Extract Exam/Result State from App.tsx
|
||||||
|
|
||||||
|
**Priority**: High — NOT completed in Phase 2
|
||||||
|
|
||||||
|
**Current**: `App.tsx` holds `exam` and `history` in local state, passed via props and `navigate(state)`.
|
||||||
|
|
||||||
|
**Needed**: Dedicated contexts:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/contexts/
|
||||||
|
ExamContext.tsx — current exam, load, unload
|
||||||
|
HistoryContext.tsx — result history, review data
|
||||||
|
```
|
||||||
|
|
||||||
|
This decouples state from routing and enables features like:
|
||||||
|
|
||||||
|
- Resume interrupted exam
|
||||||
|
- Background auto-save
|
||||||
|
- Offline mode (PWA)
|
||||||
|
|
||||||
|
#### H. React Error Boundary Integration ✅
|
||||||
|
|
||||||
|
**Priority**: Medium
|
||||||
|
|
||||||
|
**Created**: `frontend/src/components/ErrorBoundary.tsx`
|
||||||
|
|
||||||
|
**Integrated**: Wrapped the entire `<Routes>` tree in `ErrorBoundary` inside `App.tsx`.
|
||||||
|
|
||||||
|
Consider also wrapping specific high-risk sections (exam editor, admin dashboard) individually for finer-grained isolation.
|
||||||
|
|
||||||
|
#### I. Frontend Testing
|
||||||
|
|
||||||
|
**Priority**: Medium — NOT completed
|
||||||
|
|
||||||
|
**Needed**:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/tests/
|
||||||
|
api/ — mock API tests
|
||||||
|
components/ — React Testing Library
|
||||||
|
utils/ — unit tests for validation, slug, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
Start with API module tests (mock fetch) and utility function tests.
|
||||||
|
|
||||||
|
### DevOps / Infrastructure
|
||||||
|
|
||||||
|
#### J. Health Check Endpoint Enhancement
|
||||||
|
|
||||||
|
**Priority**: Medium — NOT completed
|
||||||
|
|
||||||
|
**Current**: `/api/health` returns `{ status: 'ok' }`
|
||||||
|
|
||||||
|
**Needed**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": 1234567890,
|
||||||
|
"version": "1.2.3",
|
||||||
|
"checks": {
|
||||||
|
"database": "connected",
|
||||||
|
"email": "configured",
|
||||||
|
"disk": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### K. Docker Production Optimization
|
||||||
|
|
||||||
|
**Priority**: Medium — NOT completed
|
||||||
|
|
||||||
|
**Current**: Single Dockerfile, no multi-stage build.
|
||||||
|
|
||||||
|
**Needed**: Multi-stage build:
|
||||||
|
|
||||||
|
1. `builder` — install deps, build frontend, compile backend
|
||||||
|
2. `production` — copy only `dist/`, `node_modules` (prod), runtime files
|
||||||
|
|
||||||
|
This reduces image size from ~500MB to ~150MB.
|
||||||
|
|
||||||
|
#### L. Backup Strategy
|
||||||
|
|
||||||
|
**Priority**: High for production — NOT completed
|
||||||
|
|
||||||
|
**Current**: SQLite file on disk. No automated backups.
|
||||||
|
|
||||||
|
**Needed**:
|
||||||
|
|
||||||
|
- Scheduled `sqlite3 .backup` or `VACUUM INTO`
|
||||||
|
- Upload to S3-compatible storage
|
||||||
|
- Retention policy (7 daily, 4 weekly, 12 monthly)
|
||||||
|
- Documented restore procedure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Why Repository Pattern?
|
||||||
|
|
||||||
|
| Without | With |
|
||||||
|
| ---------------------------- | -------------------------------- |
|
||||||
|
| SQL in routes | Pure JS functions |
|
||||||
|
| Can't test routes without DB | Mock repos for unit tests |
|
||||||
|
| Swapping DB = rewrite routes | Swap 4 files, zero route changes |
|
||||||
|
|
||||||
|
### Why Zod over manual validation?
|
||||||
|
|
||||||
|
| Manual | Zod |
|
||||||
|
| --------------------------- | ---------------------------------- |
|
||||||
|
| `if (!email.includes('@'))` | `email: z.string().email()` |
|
||||||
|
| Type narrowing manually | `infer<typeof schema>` gives types |
|
||||||
|
| Easy to miss edge cases | Battle-tested validators |
|
||||||
|
| No documentation | Schema IS documentation |
|
||||||
|
|
||||||
|
### Why API modules on frontend?
|
||||||
|
|
||||||
|
| Raw fetch | API modules |
|
||||||
|
| ------------------------ | -------------------------------- |
|
||||||
|
| URL strings everywhere | Single source of truth |
|
||||||
|
| Type `any` for responses | Typed interfaces |
|
||||||
|
| Can't mock easily | Export functions, mock in tests |
|
||||||
|
| No caching layer | One place to add SWR/React Query |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Path for New Features
|
||||||
|
|
||||||
|
When adding a new feature (e.g., "Exam Tags"):
|
||||||
|
|
||||||
|
1. **Schema**: Add `tags` table migration (`003_add_tags.sql`)
|
||||||
|
2. **Repository**: Add `tagRepo.ts` with `createTag()`, `listTags()`, `attachTagToExam()`
|
||||||
|
3. **Validation**: Add `tagSchema` in `validators/tagSchemas.ts`
|
||||||
|
4. **Routes**: Add `routes/tags.ts` using repo + validation + asyncHandler
|
||||||
|
5. **API Module**: Add `frontend/src/api/tagApi.ts`
|
||||||
|
6. **UI**: Build components, import from `tagApi`
|
||||||
|
|
||||||
|
Each layer is independently testable and replaceable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Created/Modified in Phase 1
|
||||||
|
|
||||||
|
### New Files (Backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/repositories/userRepo.ts
|
||||||
|
backend/src/repositories/examRepo.ts
|
||||||
|
backend/src/repositories/resultRepo.ts
|
||||||
|
backend/src/repositories/settingsRepo.ts
|
||||||
|
backend/src/validators/authSchemas.ts
|
||||||
|
backend/src/validators/examSchemas.ts
|
||||||
|
backend/src/validators/resultSchemas.ts
|
||||||
|
backend/src/validators/adminSchemas.ts
|
||||||
|
backend/src/validators/setupSchemas.ts
|
||||||
|
backend/src/middleware/validate.ts
|
||||||
|
backend/src/middleware/error.ts
|
||||||
|
backend/src/utils/asyncHandler.ts
|
||||||
|
backend/src/services/authService.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files (Backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/index.ts — env validation, request logging, routes
|
||||||
|
backend/src/routes/auth.ts — services + validation + rate limiting
|
||||||
|
backend/src/routes/exams.ts — repo + validation + asyncHandler + AppError fixes
|
||||||
|
backend/src/routes/results.ts — repo + validation + asyncHandler + AppError fixes
|
||||||
|
backend/src/routes/admin.ts — repo + validation + asyncHandler + AppError fixes
|
||||||
|
backend/src/routes/setup.ts — repo + validation + asyncHandler + AppError fixes
|
||||||
|
backend/src/middleware/auth.ts — AuthenticatedRequest interface
|
||||||
|
backend/src/utils/tokens.ts — uses config.JWT_SECRET
|
||||||
|
backend/src/services/authService.ts — AppError argument order fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Files (Frontend)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/api/index.ts
|
||||||
|
frontend/src/api/authApi.ts
|
||||||
|
frontend/src/api/examApi.ts
|
||||||
|
frontend/src/api/resultApi.ts
|
||||||
|
frontend/src/api/adminApi.ts
|
||||||
|
frontend/src/api/setupApi.ts
|
||||||
|
frontend/src/components/ErrorBoundary.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files (Frontend)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/contexts/AuthContext.tsx — uses authApi + setupApi
|
||||||
|
frontend/src/components/MyExams.tsx — uses examApi
|
||||||
|
frontend/src/components/MyResults.tsx — uses resultApi
|
||||||
|
frontend/src/components/ExamEditor.tsx — uses examApi
|
||||||
|
frontend/src/components/Settings.tsx — uses authApi
|
||||||
|
frontend/src/components/AdminDashboard.tsx — uses adminApi
|
||||||
|
frontend/src/components/SetupWizard.tsx — uses setupApi
|
||||||
|
frontend/src/components/ExamRunner.tsx — uses resultApi
|
||||||
|
frontend/src/components/Workspace.tsx — uses authApi
|
||||||
|
frontend/src/App.tsx — integrates ErrorBoundary around Routes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Phase 1 + Phase 2 establishes a solid, production-ready foundation:
|
||||||
|
|
||||||
|
- ✅ Clean separation of concerns
|
||||||
|
- ✅ Type-safe validation at boundaries
|
||||||
|
- ✅ Consistent error handling
|
||||||
|
- ✅ Frontend API abstraction (100% migrated)
|
||||||
|
- ✅ Error boundaries for resilience
|
||||||
|
- ✅ Environment configuration management
|
||||||
|
- ✅ Both frontend and backend compile with zero TypeScript errors
|
||||||
|
|
||||||
|
The remaining items (A–D, G, I–L) are well-scoped, incremental improvements that can be tackled one at a time without blocking each other. The architecture now supports horizontal scaling, database migration, and team parallelization.
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
@@ -19,5 +19,18 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "warn",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"react-hooks/set-state-in-effect": "warn",
|
||||||
|
"react-hooks/purity": "warn",
|
||||||
|
"react-refresh/only-export-components": "warn",
|
||||||
|
"no-control-regex": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"prefer-const": "warn",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
Generated
+88
@@ -9,9 +9,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
|
"i18next": "^23.16.8",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"katex": "^0.16.45",
|
"katex": "^0.16.45",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -228,6 +231,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -2287,6 +2299,47 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "23.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
|
||||||
|
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com/i18next.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3089,6 +3142,32 @@
|
|||||||
"react": "^19.2.5"
|
"react": "^19.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "15.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||||
|
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.27.6",
|
||||||
|
"html-parse-stringify": "^3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 23.4.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.14.2",
|
"version": "7.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
||||||
@@ -3612,6 +3691,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
|
"i18next": "^23.16.8",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"katex": "^0.16.45",
|
"katex": "^0.16.45",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+169
-128
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useLocation,
|
useLocation,
|
||||||
@@ -6,7 +8,7 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
Navigate,
|
Navigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
|
import type { Exam, ExamState, HistoryEntry } from "./types";
|
||||||
import { gradeQuestion } from "./utils/grading";
|
import { gradeQuestion } from "./utils/grading";
|
||||||
import {
|
import {
|
||||||
loadState,
|
loadState,
|
||||||
@@ -16,23 +18,23 @@ import {
|
|||||||
appendHistory,
|
appendHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
} from "./utils/storage";
|
} from "./utils/storage";
|
||||||
import { apiFetch, isAuthenticated } from "./utils/api";
|
import { isAuthenticated } from "./utils/api";
|
||||||
import { slugify } from "./utils/slug";
|
import * as resultApi from "./api/resultApi";
|
||||||
|
import * as examApi from "./api/examApi";
|
||||||
|
import { getExamUrlId } from "./utils/slug";
|
||||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
|
import { TweaksProvider, useTweaks } from "./contexts/TweaksContext";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import Landing from "./components/Landing";
|
import Landing from "./components/Landing";
|
||||||
import ExamRoute from "./components/ExamRoute";
|
import ExamRoute from "./components/ExamRoute";
|
||||||
import Results from "./components/Results";
|
import Results from "./components/Results";
|
||||||
import TweaksPanel from "./components/TweaksPanel";
|
|
||||||
import AuthModal from "./components/AuthModal";
|
import AuthModal from "./components/AuthModal";
|
||||||
import EmailVerify from "./components/EmailVerify";
|
import EmailVerify from "./components/EmailVerify";
|
||||||
import ResetPassword from "./components/ResetPassword";
|
import ResetPassword from "./components/ResetPassword";
|
||||||
import AdminDashboard from "./components/AdminDashboard";
|
import AdminDashboard from "./components/AdminDashboard";
|
||||||
|
import SetupWizard from "./components/SetupWizard";
|
||||||
const DEFAULT_TWEAKS: Tweaks = {
|
import Workspace from "./components/Workspace";
|
||||||
theme: "light",
|
import ExamEditor from "./components/ExamEditor";
|
||||||
fontSize: "medium",
|
|
||||||
feedbackMode: "auto",
|
|
||||||
};
|
|
||||||
|
|
||||||
const withNotes = (state: ExamState): ExamState => ({
|
const withNotes = (state: ExamState): ExamState => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -40,38 +42,26 @@ const withNotes = (state: ExamState): ExamState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { user, loading: authLoading, logout } = useAuth();
|
const { t } = useTranslation();
|
||||||
|
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
|
||||||
|
const { tweaks } = useTweaks();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [exam, setExam] = useState<Exam | null>(null);
|
const [exam, setExam] = useState<Exam | null>(null);
|
||||||
const [examState, setExamState] = useState<ExamState | null>(null);
|
const [examState, setExamState] = useState<ExamState | null>(null);
|
||||||
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
|
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
|
||||||
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS);
|
|
||||||
const [tweaksOpen, setTweaksOpen] = useState(false);
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
const savedExamIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && user && isAuthenticated()) {
|
if (!authLoading && user && isAuthenticated()) {
|
||||||
apiFetch<{
|
resultApi.listResults()
|
||||||
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) => {
|
.then((data) => {
|
||||||
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
|
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
title: r.title,
|
title: r.title,
|
||||||
completedAt: r.completed_at * 1000,
|
completedAt: r.completed_at,
|
||||||
elapsedMs: r.elapsed_ms,
|
elapsedMs: r.elapsed_ms,
|
||||||
gradedCount: r.graded_count,
|
gradedCount: r.graded_count,
|
||||||
correctCount: r.correct_count,
|
correctCount: r.correct_count,
|
||||||
@@ -97,65 +87,89 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname.startsWith("/exam/") && exam && examState)
|
if (pathname.startsWith("/exam/") && exam && examState)
|
||||||
saveState({ exam, state: examState });
|
saveState({ exam, state: examState });
|
||||||
}, [exam, examState, pathname]);
|
}, [exam, examState, pathname]);
|
||||||
|
|
||||||
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
|
||||||
setTweaks((t) => ({ ...t, [key]: value }));
|
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 setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (
|
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
|
||||||
nextState,
|
(nextState) => {
|
||||||
) => {
|
setExamState((prev) => {
|
||||||
setExamState((prev) => {
|
if (!prev) return prev;
|
||||||
if (!prev) return prev;
|
return typeof nextState === "function"
|
||||||
return typeof nextState === "function"
|
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
||||||
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
: nextState;
|
||||||
: nextState;
|
});
|
||||||
});
|
},
|
||||||
};
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const activateExam = (data: Exam, state: ExamState) => {
|
const activateExam = (data: Exam, state: ExamState) => {
|
||||||
setExam(data);
|
setExam(data);
|
||||||
setExamState(withNotes(state));
|
setExamState(withNotes(state));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadExam = (data: Exam) => {
|
const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null) => {
|
||||||
|
if (saveToWorkspace && user && isAuthenticated()) {
|
||||||
|
examApi.createExam(data)
|
||||||
|
.then((res) => {
|
||||||
|
savedExamIdRef.current = res.id;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// silent fail — exam not saved but user can still take it
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (examId != null) {
|
||||||
|
savedExamIdRef.current = examId;
|
||||||
|
}
|
||||||
const fm =
|
const fm =
|
||||||
tweaks.feedbackMode === "auto"
|
tweaks.feedbackMode === "auto"
|
||||||
? (data.feedbackMode ?? "end")
|
? (data.feedbackMode ?? "end")
|
||||||
: tweaks.feedbackMode;
|
: tweaks.feedbackMode;
|
||||||
setExam(data);
|
flushSync(() => {
|
||||||
setExamState({
|
setExam(data);
|
||||||
currentIdx: 0,
|
setExamState({
|
||||||
answers: {},
|
currentIdx: 0,
|
||||||
flags: {},
|
answers: {},
|
||||||
notes: "",
|
flags: {},
|
||||||
revealed: {},
|
notes: "",
|
||||||
startedAt: Date.now(),
|
revealed: {},
|
||||||
feedbackMode: fm,
|
startedAt: Date.now(),
|
||||||
|
feedbackMode: fm,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
navigate(`/exam/${slugify(data.title)}/q1`);
|
navigate(`/exam/${getExamUrlId(data)}/q1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resume = () => {
|
const resume = () => {
|
||||||
const saved = loadState();
|
const saved = loadState();
|
||||||
if (saved?.exam && saved?.state) {
|
if (saved?.exam && saved?.state) {
|
||||||
setExam(saved.exam);
|
flushSync(() => {
|
||||||
setExamState(withNotes(saved.state));
|
setExam(saved.exam);
|
||||||
|
setExamState(withNotes(saved.state));
|
||||||
|
});
|
||||||
navigate(
|
navigate(
|
||||||
`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`,
|
`/exam/${getExamUrlId(saved.exam)}/q${saved.state.currentIdx + 1}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -182,20 +196,19 @@ function AppInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (user && isAuthenticated()) {
|
if (user && isAuthenticated()) {
|
||||||
void apiFetch("/api/results", {
|
void resultApi.submitResult({
|
||||||
method: "POST",
|
exam_id: savedExamIdRef.current,
|
||||||
body: JSON.stringify({
|
title: entry.title,
|
||||||
title: entry.title,
|
score_points: entry.scorePoints,
|
||||||
scorePoints: entry.scorePoints,
|
total_points: entry.totalPoints,
|
||||||
totalPoints: entry.totalPoints,
|
correct_count: entry.correctCount,
|
||||||
correctCount: entry.correctCount,
|
graded_count: entry.gradedCount,
|
||||||
gradedCount: entry.gradedCount,
|
passing_score: entry.passingScore,
|
||||||
passingScore: entry.passingScore,
|
elapsed_ms: entry.elapsedMs,
|
||||||
elapsedMs: entry.elapsedMs,
|
state: entry.state as unknown,
|
||||||
notes: entry.state.notes,
|
exam: entry.exam as unknown,
|
||||||
state: entry.state,
|
}).then((data) => {
|
||||||
exam: entry.exam,
|
sessionStorage.setItem('brainwave.pendingResultId', String(data.id));
|
||||||
}),
|
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
appendHistory(entry);
|
appendHistory(entry);
|
||||||
});
|
});
|
||||||
@@ -212,6 +225,8 @@ function AppInner() {
|
|||||||
const exit = () => {
|
const exit = () => {
|
||||||
setExam(null);
|
setExam(null);
|
||||||
setExamState(null);
|
setExamState(null);
|
||||||
|
savedExamIdRef.current = null;
|
||||||
|
sessionStorage.removeItem('brainwave.pendingResultId');
|
||||||
navigate("/");
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,47 +237,71 @@ function AppInner() {
|
|||||||
const reviewAttempt = (entry: HistoryEntry) => {
|
const reviewAttempt = (entry: HistoryEntry) => {
|
||||||
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
|
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
|
||||||
if (remoteId && user && isAuthenticated()) {
|
if (remoteId && user && isAuthenticated()) {
|
||||||
void apiFetch<{
|
void resultApi.getResult(remoteId)
|
||||||
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) => {
|
.then((data) => {
|
||||||
setExam(data.exam);
|
flushSync(() => {
|
||||||
setExamState(withNotes(data.state));
|
setExam(data.exam as unknown as Exam);
|
||||||
navigate(`/review/${slugify(entry.title)}`);
|
setExamState(withNotes(data.state as unknown as ExamState));
|
||||||
|
});
|
||||||
|
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setExam(entry.exam);
|
flushSync(() => {
|
||||||
setExamState(withNotes(entry.state));
|
setExam(entry.exam);
|
||||||
navigate(`/review/${slugify(entry.title)}`);
|
setExamState(withNotes(entry.state));
|
||||||
|
});
|
||||||
|
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setExam(entry.exam);
|
flushSync(() => {
|
||||||
setExamState(withNotes(entry.state));
|
setExam(entry.exam);
|
||||||
navigate(`/review/${slugify(entry.title)}`);
|
setExamState(withNotes(entry.state));
|
||||||
|
});
|
||||||
|
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAllHistory = () => {
|
const clearAllHistory = () => {
|
||||||
if (confirm("Clear all past attempts?")) {
|
if (confirm(t('landing.clearHistoryConfirm'))) {
|
||||||
clearHistory();
|
clearHistory();
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [pendingResultId, setPendingResultId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname.startsWith('/results') && !exam && !examState && user && isAuthenticated()) {
|
||||||
|
const id = sessionStorage.getItem('brainwave.pendingResultId');
|
||||||
|
if (!id) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number(id) !== pendingResultId) {
|
||||||
|
setPendingResultId(Number(id));
|
||||||
|
resultApi.getResult(Number(id))
|
||||||
|
.then((data) => {
|
||||||
|
setExam(data.exam as unknown as Exam);
|
||||||
|
setExamState(withNotes(data.state as unknown as ExamState));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pathname, exam, examState, user, navigate, pendingResultId]);
|
||||||
|
|
||||||
const handleAuthFlowDone = () => {
|
const handleAuthFlowDone = () => {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
setAuthModalOpen(true);
|
setAuthModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
return (
|
||||||
|
<SetupWizard onComplete={() => void refreshUser()} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === "/verify") {
|
if (pathname === "/verify") {
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
@@ -299,7 +338,7 @@ function AppInner() {
|
|||||||
<button
|
<button
|
||||||
className="brand-name"
|
className="brand-name"
|
||||||
onClick={exit}
|
onClick={exit}
|
||||||
aria-label="Home"
|
aria-label={t('common.backToHome')}
|
||||||
style={{
|
style={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -313,7 +352,7 @@ function AppInner() {
|
|||||||
BrainWave
|
BrainWave
|
||||||
</button>
|
</button>
|
||||||
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
|
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
|
||||||
/ practice
|
/ {t('common.appTagline')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-meta">
|
<div className="topbar-meta">
|
||||||
@@ -323,25 +362,16 @@ function AppInner() {
|
|||||||
{exam.title}
|
{exam.title}
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={exit}>
|
<button className="btn btn-ghost btn-sm" onClick={exit}>
|
||||||
Exit
|
{t('exam.exit')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => setTweaksOpen((o) => !o)}
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
aria-label="Tweaks"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!authLoading && !user && (
|
{!authLoading && !user && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => setAuthModalOpen(true)}
|
onClick={() => setAuthModalOpen(true)}
|
||||||
>
|
>
|
||||||
Sign In
|
{t('auth.signIn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!authLoading && user && (
|
{!authLoading && user && (
|
||||||
@@ -349,7 +379,7 @@ function AppInner() {
|
|||||||
<button
|
<button
|
||||||
className="user-avatar"
|
className="user-avatar"
|
||||||
onClick={() => setUserMenuOpen((o) => !o)}
|
onClick={() => setUserMenuOpen((o) => !o)}
|
||||||
aria-label="User menu"
|
aria-label={t('auth.userMenu')}
|
||||||
>
|
>
|
||||||
{user.displayName.charAt(0).toUpperCase()}
|
{user.displayName.charAt(0).toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
@@ -360,6 +390,15 @@ function AppInner() {
|
|||||||
<div className="user-menu-email">{user.email}</div>
|
<div className="user-menu-email">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-menu-divider" />
|
<div className="user-menu-divider" />
|
||||||
|
<button
|
||||||
|
className="user-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
navigate("/workspace");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth.myWorkspace')}
|
||||||
|
</button>
|
||||||
{user.role === "admin" && (
|
{user.role === "admin" && (
|
||||||
<button
|
<button
|
||||||
className="user-menu-item"
|
className="user-menu-item"
|
||||||
@@ -368,7 +407,7 @@ function AppInner() {
|
|||||||
navigate("/admin");
|
navigate("/admin");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Admin Dashboard
|
{t('auth.adminDashboard')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -378,7 +417,7 @@ function AppInner() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign Out
|
{t('auth.signOut')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -401,7 +440,7 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/exam/:slug/q:n"
|
path="/exam/:slug/:n"
|
||||||
element={
|
element={
|
||||||
<ExamRoute
|
<ExamRoute
|
||||||
exam={exam}
|
exam={exam}
|
||||||
@@ -424,6 +463,8 @@ function AppInner() {
|
|||||||
onExit={exit}
|
onExit={exit}
|
||||||
reviewOnly={false}
|
reviewOnly={false}
|
||||||
/>
|
/>
|
||||||
|
) : user && isAuthenticated() ? (
|
||||||
|
<div className="workspace-empty">{t('common.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" replace />
|
<Navigate to="/" replace />
|
||||||
)
|
)
|
||||||
@@ -455,16 +496,12 @@ function AppInner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
|
||||||
|
<Route path="/workspace/:tab" element={<Workspace onLoadExam={loadExam} />} />
|
||||||
|
<Route path="/exam-editor/:id" element={<ExamEditor />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
{tweaksOpen && (
|
|
||||||
<TweaksPanel
|
|
||||||
tweaks={tweaks}
|
|
||||||
update={updateTweak}
|
|
||||||
onClose={() => setTweaksOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{authModalOpen && (
|
{authModalOpen && (
|
||||||
<AuthModal
|
<AuthModal
|
||||||
onClose={() => setAuthModalOpen(false)}
|
onClose={() => setAuthModalOpen(false)}
|
||||||
@@ -477,8 +514,12 @@ function AppInner() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<ErrorBoundary>
|
||||||
<AppInner />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<TweaksProvider>
|
||||||
|
<AppInner />
|
||||||
|
</TweaksProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
role: 'user' | 'admin'
|
||||||
|
email_verified: number
|
||||||
|
created_at: number
|
||||||
|
results_count: number
|
||||||
|
exams_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
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
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
smtp_host: string
|
||||||
|
smtp_port: string
|
||||||
|
smtp_user: string
|
||||||
|
smtp_pass: string
|
||||||
|
smtp_from: string
|
||||||
|
smtp_secure: string
|
||||||
|
app_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStats(): Promise<DashboardStats> {
|
||||||
|
return apiFetch('/api/admin/stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettings(): Promise<{ settings: SiteSettings }> {
|
||||||
|
return apiFetch('/api/admin/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(settings: SiteSettings): Promise<void> {
|
||||||
|
return apiFetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ settings }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUsers(search?: string): Promise<{ users: AdminUser[]; total: number }> {
|
||||||
|
const q = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||||
|
return apiFetch(`/api/admin/users${q}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeRole(userId: number, role: 'user' | 'admin'): Promise<void> {
|
||||||
|
return apiFetch(`/api/admin/users/${userId}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(userId: number): Promise<void> {
|
||||||
|
return apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpConfig {
|
||||||
|
host: string
|
||||||
|
port: string
|
||||||
|
user: string
|
||||||
|
pass: string
|
||||||
|
from: string
|
||||||
|
secure: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTestEmail(to: string | undefined, smtp: SmtpConfig): Promise<{ message: string }> {
|
||||||
|
return apiFetch('/api/admin/test-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ to, smtp }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
role: string
|
||||||
|
emailVerified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(data: { email: string; password: string; displayName: string }): Promise<AuthResponse> {
|
||||||
|
return apiFetch('/api/auth/register', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(data: { email: string; password: string }): Promise<AuthResponse> {
|
||||||
|
return apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMe(): Promise<User> {
|
||||||
|
return apiFetch('/api/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProfile(data: { displayName: string }): Promise<User> {
|
||||||
|
return apiFetch('/api/auth/me', { method: 'PUT', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(data: { currentPassword: string; newPassword: string }): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch('/api/auth/change-password', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEmailToken(token: string): Promise<{ message: string }> {
|
||||||
|
return apiFetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgotPassword(email?: string): Promise<{ message: string }> {
|
||||||
|
return apiFetch('/api/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPassword(data: { token: string; newPassword: string }): Promise<{ message: string }> {
|
||||||
|
return apiFetch('/api/auth/reset-password', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAccount(): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch('/api/auth/me', { method: 'DELETE' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
import type { Exam } from '../types'
|
||||||
|
|
||||||
|
export interface ExamListItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subtitle: string | null
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
question_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExams(): Promise<ExamListItem[]> {
|
||||||
|
return apiFetch('/api/exams')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExam(id: number): Promise<Exam> {
|
||||||
|
return apiFetch(`/api/exams/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExam(exam: Exam): Promise<{ id: number; title: string }> {
|
||||||
|
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExam(id: number, exam: Exam): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteExam(id: number): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export shared utilities
|
||||||
|
export { apiFetch, setToken, clearToken, isAuthenticated, ApiError } from '../utils/api.js'
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
|
||||||
|
export interface ResultListItem {
|
||||||
|
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 interface SubmitResultData {
|
||||||
|
exam_id?: number | null
|
||||||
|
title: string
|
||||||
|
score_points: number
|
||||||
|
total_points: number
|
||||||
|
correct_count: number
|
||||||
|
graded_count: number
|
||||||
|
passing_score?: number
|
||||||
|
elapsed_ms: number
|
||||||
|
state: unknown
|
||||||
|
exam: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listResults(): Promise<{ results: ResultListItem[]; total: number }> {
|
||||||
|
return apiFetch('/api/results')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResult(id: number): Promise<ResultListItem & { state: unknown; exam: unknown }> {
|
||||||
|
return apiFetch(`/api/results/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitResult(data: SubmitResultData): Promise<{ id: number }> {
|
||||||
|
return apiFetch('/api/results', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteResult(id: number): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch(`/api/results/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
|
||||||
|
export interface SetupStatus {
|
||||||
|
completed: boolean
|
||||||
|
siteTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetupStatus(): Promise<SetupStatus> {
|
||||||
|
return apiFetch('/api/setup/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupPayload {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
smtpHost?: string
|
||||||
|
smtpPort?: string
|
||||||
|
smtpUser?: string
|
||||||
|
smtpPass?: string
|
||||||
|
smtpFrom?: string
|
||||||
|
smtpSecure?: string
|
||||||
|
appUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupResponse {
|
||||||
|
message: string
|
||||||
|
token: string
|
||||||
|
user: {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
role: 'user' | 'admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeSetup(data: SetupPayload): Promise<SetupResponse> {
|
||||||
|
return apiFetch('/api/setup', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
@@ -1,236 +1,254 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { apiFetch } from '../utils/api'
|
import { useTranslation } from "react-i18next";
|
||||||
|
import * as adminApi from "../api/adminApi";
|
||||||
|
import type { DashboardStats, SiteSettings, AdminUser } from "../api/adminApi";
|
||||||
|
|
||||||
interface SiteSettings {
|
type AdminTab = "overview" | "settings" | "users";
|
||||||
site_name: string
|
|
||||||
max_upload_mb: string
|
|
||||||
allow_guest_mode: string
|
|
||||||
registration_enabled: string
|
|
||||||
default_theme: string
|
|
||||||
maintenance_mode: string
|
|
||||||
maintenance_message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardStats {
|
|
||||||
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
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminUser {
|
|
||||||
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'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onExit: () => void
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||||
const [tab, setTab] = useState<AdminTab>('overview')
|
const { t } = useTranslation();
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [tab, setTab] = useState<AdminTab>("overview");
|
||||||
const [settings, setSettings] = useState<SiteSettings | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null)
|
const [settings, setSettings] = useState<SiteSettings | null>(null);
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
|
||||||
const [usersTotal, setUsersTotal] = useState(0)
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [userSearch, setUserSearch] = useState('')
|
const [usersTotal, setUsersTotal] = useState(0);
|
||||||
const [saving, setSaving] = useState(false)
|
const [userSearch, setUserSearch] = useState("");
|
||||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | 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 () => {
|
const loadStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<DashboardStats>('/api/admin/stats')
|
const data = await adminApi.getStats();
|
||||||
setStats(data)
|
setStats(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings')
|
const data = await adminApi.getSettings();
|
||||||
setSettings(data.settings)
|
setSettings(data.settings);
|
||||||
setSettingsDraft(data.settings)
|
setSettingsDraft(data.settings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const loadUsers = useCallback(async (search = '') => {
|
const loadUsers = useCallback(async (search = "") => {
|
||||||
try {
|
try {
|
||||||
const q = search ? `?search=${encodeURIComponent(search)}` : ''
|
const data = await adminApi.listUsers(search);
|
||||||
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`)
|
setUsers(data.users);
|
||||||
setUsers(data.users)
|
setUsersTotal(data.total);
|
||||||
setUsersTotal(data.total)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadStats()
|
void loadStats();
|
||||||
void loadSettings()
|
void loadSettings();
|
||||||
void loadUsers()
|
void loadUsers();
|
||||||
}, [loadStats, loadSettings, loadUsers])
|
}, [loadStats, loadSettings, loadUsers]);
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
if (!settingsDraft) return
|
if (!settingsDraft) return;
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
setSaveMsg(null)
|
setSaveMsg(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/admin/settings', {
|
await adminApi.updateSettings(settingsDraft);
|
||||||
method: 'PUT',
|
setSettings(settingsDraft);
|
||||||
body: JSON.stringify({ settings: settingsDraft }),
|
setSaveMsg(t("admin.settingsSaved"));
|
||||||
})
|
setTimeout(() => setSaveMsg(null), 3000);
|
||||||
setSettings(settingsDraft)
|
|
||||||
setSaveMsg('Settings saved successfully')
|
|
||||||
setTimeout(() => setSaveMsg(null), 3000)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const changeRole = async (userId: number, newRole: 'user' | 'admin') => {
|
const handleChangeRole = async (userId: number, newRole: "user" | "admin") => {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/admin/users/${userId}/role`, {
|
await adminApi.changeRole(userId, newRole);
|
||||||
method: 'PATCH',
|
await loadUsers(userSearch);
|
||||||
body: JSON.stringify({ role: newRole }),
|
|
||||||
})
|
|
||||||
await loadUsers(userSearch)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteUser = async (userId: number) => {
|
const handleDeleteUser = async (userId: number) => {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
|
await adminApi.deleteUser(userId);
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null);
|
||||||
await loadUsers(userSearch)
|
await loadUsers(userSearch);
|
||||||
await loadStats()
|
await loadStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
if (!settingsDraft) return;
|
||||||
|
setTestEmailSending(true);
|
||||||
|
setTestEmailMsg(null);
|
||||||
|
try {
|
||||||
|
await adminApi.sendTestEmail(testEmailTo || undefined, {
|
||||||
|
host: settingsDraft.smtp_host,
|
||||||
|
port: settingsDraft.smtp_port,
|
||||||
|
user: settingsDraft.smtp_user,
|
||||||
|
pass: settingsDraft.smtp_pass,
|
||||||
|
from: settingsDraft.smtp_from,
|
||||||
|
secure: settingsDraft.smtp_secure,
|
||||||
|
});
|
||||||
|
setTestEmailMsg({
|
||||||
|
ok: true,
|
||||||
|
text: t("admin.testEmailSent"),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setTestEmailMsg({ ok: false, text: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setTestEmailSending(false);
|
||||||
|
setTimeout(() => setTestEmailMsg(null), 6000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUserSearch = (e: React.FormEvent) => {
|
const handleUserSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
void loadUsers(userSearch)
|
void loadUsers(userSearch);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateDraft = (key: keyof SiteSettings, value: string) => {
|
const updateDraft = (key: keyof SiteSettings, value: string) => {
|
||||||
setSettingsDraft((d) => d ? { ...d, [key]: value } : d)
|
setSettingsDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||||
}
|
};
|
||||||
|
|
||||||
const hasChanges = settings && settingsDraft && JSON.stringify(settings) !== JSON.stringify(settingsDraft)
|
const hasChanges =
|
||||||
|
settings &&
|
||||||
|
settingsDraft &&
|
||||||
|
JSON.stringify(settings) !== JSON.stringify(settingsDraft);
|
||||||
|
|
||||||
const formatDate = (ts: number) => {
|
const formatDate = (ts: number) => {
|
||||||
const d = new Date(ts * 1000)
|
const d = new Date(ts * 1000);
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return d.toLocaleDateString(undefined, {
|
||||||
}
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabLabel = (tName: AdminTab) => {
|
||||||
|
switch (tName) {
|
||||||
|
case "overview":
|
||||||
|
return t("admin.overview");
|
||||||
|
case "settings":
|
||||||
|
return t("admin.settings");
|
||||||
|
case "users":
|
||||||
|
return t("admin.users");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin">
|
<div className="admin">
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<div className="admin-header-left">
|
<div className="admin-header-left">
|
||||||
<div className="admin-eyebrow">Administration</div>
|
<div className="admin-eyebrow">{t("admin.administration")}</div>
|
||||||
<h1 className="admin-title">Dashboard</h1>
|
<h1 className="admin-title">{t("admin.dashboard")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
||||||
← Back to site
|
{t("admin.backToSite")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="admin-alert admin-alert-error">
|
<div className="admin-alert admin-alert-error">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
<button onClick={() => setError(null)} className="admin-alert-close">×</button>
|
<button onClick={() => setError(null)} className="admin-alert-close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-tabs">
|
<div className="admin-tabs">
|
||||||
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => (
|
{(["overview", "settings", "users"] as AdminTab[]).map((tName) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={tName}
|
||||||
className={`admin-tab${tab === t ? ' active' : ''}`}
|
className={`admin-tab${tab === tName ? " active" : ""}`}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(tName)}
|
||||||
>
|
>
|
||||||
<span className="admin-tab-icon">
|
<span className="admin-tab-icon">
|
||||||
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'}
|
{tName === "overview" ? "◈" : tName === "settings" ? "⚙" : "⊕"}
|
||||||
</span>
|
</span>
|
||||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
{tabLabel(tName)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Overview Tab ─── */}
|
{/* ─── Overview Tab ─── */}
|
||||||
{tab === 'overview' && (
|
{tab === "overview" && (
|
||||||
<div className="admin-section fade-in">
|
<div className="admin-section fade-in">
|
||||||
<div className="admin-stats-grid">
|
<div className="admin-stats-grid">
|
||||||
<div className="admin-stat-card">
|
<div className="admin-stat-card">
|
||||||
<div className="admin-stat-icon users-icon">⊕</div>
|
<div className="admin-stat-icon users-icon">⊕</div>
|
||||||
<div className="admin-stat-info">
|
<div className="admin-stat-info">
|
||||||
<div className="admin-stat-value">{stats?.totalUsers ?? '—'}</div>
|
<div className="admin-stat-value">
|
||||||
<div className="admin-stat-label">Total Users</div>
|
{stats?.totalUsers ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat-label">{t("admin.totalUsers")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-stat-card">
|
<div className="admin-stat-card">
|
||||||
<div className="admin-stat-icon exams-icon">◇</div>
|
<div className="admin-stat-icon exams-icon">◇</div>
|
||||||
<div className="admin-stat-info">
|
<div className="admin-stat-info">
|
||||||
<div className="admin-stat-value">{stats?.totalExams ?? '—'}</div>
|
<div className="admin-stat-value">
|
||||||
<div className="admin-stat-label">Saved Exams</div>
|
{stats?.totalExams ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat-label">{t("admin.savedExams")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-stat-card">
|
<div className="admin-stat-card">
|
||||||
<div className="admin-stat-icon results-icon">✦</div>
|
<div className="admin-stat-icon results-icon">✦</div>
|
||||||
<div className="admin-stat-info">
|
<div className="admin-stat-info">
|
||||||
<div className="admin-stat-value">{stats?.totalResults ?? '—'}</div>
|
<div className="admin-stat-value">
|
||||||
<div className="admin-stat-label">Exams Taken</div>
|
{stats?.totalResults ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat-label">{t("admin.examsTaken")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-stat-card">
|
<div className="admin-stat-card">
|
||||||
<div className="admin-stat-icon avg-icon">%</div>
|
<div className="admin-stat-icon avg-icon">%</div>
|
||||||
<div className="admin-stat-info">
|
<div className="admin-stat-info">
|
||||||
<div className="admin-stat-value">
|
<div className="admin-stat-value">
|
||||||
{stats ? `${stats.averageScore}%` : '—'}
|
{stats ? `${stats.averageScore}%` : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-stat-label">Avg. Score</div>
|
<div className="admin-stat-label">{t("admin.avgScore")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<div className="admin-panel-header">
|
<div className="admin-panel-header">
|
||||||
<h3>Recent Activity</h3>
|
<h3>{t("admin.recentActivity")}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-panel-body">
|
<div className="admin-panel-body">
|
||||||
{!stats?.recentResults?.length && (
|
{!stats?.recentResults?.length && (
|
||||||
<div className="admin-empty">No recent activity yet</div>
|
<div className="admin-empty">{t("admin.noRecentActivity")}</div>
|
||||||
)}
|
)}
|
||||||
{stats?.recentResults?.map((r) => {
|
{stats?.recentResults?.map((r) => {
|
||||||
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
|
const pct = r.total_points
|
||||||
|
? Math.round((r.score_points / r.total_points) * 100)
|
||||||
|
: 0;
|
||||||
return (
|
return (
|
||||||
<div key={r.id} className="admin-activity-row">
|
<div key={r.id} className="admin-activity-row">
|
||||||
<div className="admin-activity-user">
|
<div className="admin-activity-user">
|
||||||
@@ -238,15 +256,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
{r.display_name.charAt(0).toUpperCase()}
|
{r.display_name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="admin-activity-name">{r.display_name}</div>
|
<div className="admin-activity-name">
|
||||||
|
{r.display_name}
|
||||||
|
</div>
|
||||||
<div className="admin-activity-email">{r.email}</div>
|
<div className="admin-activity-email">{r.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-activity-exam">{r.title}</div>
|
<div className="admin-activity-exam">{r.title}</div>
|
||||||
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div>
|
<div
|
||||||
<div className="admin-activity-date">{formatDate(r.completed_at)}</div>
|
className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</div>
|
||||||
|
<div className="admin-activity-date">
|
||||||
|
{formatDate(r.completed_at)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +280,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Settings Tab ─── */}
|
{/* ─── Settings Tab ─── */}
|
||||||
{tab === 'settings' && settingsDraft && (
|
{tab === "settings" && settingsDraft && (
|
||||||
<div className="admin-section fade-in">
|
<div className="admin-section fade-in">
|
||||||
{saveMsg && (
|
{saveMsg && (
|
||||||
<div className="admin-alert admin-alert-success">{saveMsg}</div>
|
<div className="admin-alert admin-alert-success">{saveMsg}</div>
|
||||||
@@ -262,25 +288,37 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
|
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<div className="admin-panel-header">
|
<div className="admin-panel-header">
|
||||||
<h3>General</h3>
|
<h3>{t("admin.general")}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-panel-body admin-settings-form">
|
<div className="admin-panel-body admin-settings-form">
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label" htmlFor="setting-site-name">
|
<label
|
||||||
<span className="admin-setting-title">Site Name</span>
|
className="admin-setting-label"
|
||||||
<span className="admin-setting-desc">The name displayed throughout the application</span>
|
htmlFor="setting-site-name"
|
||||||
|
>
|
||||||
|
<span className="admin-setting-title">{t("admin.siteName")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.siteNameDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="setting-site-name"
|
id="setting-site-name"
|
||||||
className="input admin-setting-input"
|
className="input admin-setting-input"
|
||||||
value={settingsDraft.site_name}
|
value={settingsDraft.site_name}
|
||||||
onChange={(e) => updateDraft('site_name', e.target.value)}
|
onChange={(e) => updateDraft("site_name", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label" htmlFor="setting-max-upload">
|
<label
|
||||||
<span className="admin-setting-title">Max Upload Size (MB)</span>
|
className="admin-setting-label"
|
||||||
<span className="admin-setting-desc">Maximum file size for exam JSON uploads</span>
|
htmlFor="setting-max-upload"
|
||||||
|
>
|
||||||
|
<span className="admin-setting-title">
|
||||||
|
{t("admin.maxUpload")}
|
||||||
|
</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.maxUploadDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="setting-max-upload"
|
id="setting-max-upload"
|
||||||
@@ -289,23 +327,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
min="1"
|
min="1"
|
||||||
max="50"
|
max="50"
|
||||||
value={settingsDraft.max_upload_mb}
|
value={settingsDraft.max_upload_mb}
|
||||||
onChange={(e) => updateDraft('max_upload_mb', e.target.value)}
|
onChange={(e) => updateDraft("max_upload_mb", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label" htmlFor="setting-default-theme">
|
<label
|
||||||
<span className="admin-setting-title">Default Theme</span>
|
className="admin-setting-label"
|
||||||
<span className="admin-setting-desc">Default theme for new visitors</span>
|
htmlFor="setting-default-theme"
|
||||||
|
>
|
||||||
|
<span className="admin-setting-title">{t("admin.defaultTheme")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.defaultThemeDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="admin-setting-toggle-group">
|
<div className="admin-setting-toggle-group">
|
||||||
<button
|
<button
|
||||||
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`}
|
className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
|
||||||
onClick={() => updateDraft('default_theme', 'light')}
|
onClick={() => updateDraft("default_theme", "light")}
|
||||||
>☀ Light</button>
|
>
|
||||||
|
☀ {t("tweaks.light")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`}
|
className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
|
||||||
onClick={() => updateDraft('default_theme', 'dark')}
|
onClick={() => updateDraft("default_theme", "dark")}
|
||||||
>☾ Dark</button>
|
>
|
||||||
|
☾ {t("tweaks.dark")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,33 +360,53 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
|
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<div className="admin-panel-header">
|
<div className="admin-panel-header">
|
||||||
<h3>Access Control</h3>
|
<h3>{t("admin.accessControl")}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-panel-body admin-settings-form">
|
<div className="admin-panel-body admin-settings-form">
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label">
|
<label className="admin-setting-label">
|
||||||
<span className="admin-setting-title">Allow Guest Mode</span>
|
<span className="admin-setting-title">{t("admin.allowGuestMode")}</span>
|
||||||
<span className="admin-setting-desc">Let unauthenticated users take exams without signing in</span>
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.allowGuestModeDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`}
|
className={`admin-switch${settingsDraft.allow_guest_mode === "true" ? " on" : ""}`}
|
||||||
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')}
|
onClick={() =>
|
||||||
|
updateDraft(
|
||||||
|
"allow_guest_mode",
|
||||||
|
settingsDraft.allow_guest_mode === "true"
|
||||||
|
? "false"
|
||||||
|
: "true",
|
||||||
|
)
|
||||||
|
}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={settingsDraft.allow_guest_mode === 'true'}
|
aria-checked={settingsDraft.allow_guest_mode === "true"}
|
||||||
>
|
>
|
||||||
<span className="admin-switch-thumb" />
|
<span className="admin-switch-thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label">
|
<label className="admin-setting-label">
|
||||||
<span className="admin-setting-title">Registration Enabled</span>
|
<span className="admin-setting-title">
|
||||||
<span className="admin-setting-desc">Allow new users to create accounts</span>
|
{t("admin.registrationEnabled")}
|
||||||
|
</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.registrationEnabledDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`}
|
className={`admin-switch${settingsDraft.registration_enabled === "true" ? " on" : ""}`}
|
||||||
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')}
|
onClick={() =>
|
||||||
|
updateDraft(
|
||||||
|
"registration_enabled",
|
||||||
|
settingsDraft.registration_enabled === "true"
|
||||||
|
? "false"
|
||||||
|
: "true",
|
||||||
|
)
|
||||||
|
}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={settingsDraft.registration_enabled === 'true'}
|
aria-checked={settingsDraft.registration_enabled === "true"}
|
||||||
>
|
>
|
||||||
<span className="admin-switch-thumb" />
|
<span className="admin-switch-thumb" />
|
||||||
</button>
|
</button>
|
||||||
@@ -349,34 +416,229 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
|
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<div className="admin-panel-header">
|
<div className="admin-panel-header">
|
||||||
<h3>Maintenance</h3>
|
<h3>{t("admin.emailSmtp")}</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">{t("admin.appUrl")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.appUrlDesc")}
|
||||||
|
</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">{t("admin.smtpHost")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.smtpHostDesc")}
|
||||||
|
</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">{t("admin.smtpPort")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.smtpPortDesc")}
|
||||||
|
</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">{t("admin.username")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.usernameDesc")}
|
||||||
|
</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">{t("admin.password")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.passwordDesc")}
|
||||||
|
</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">{t("admin.fromAddress")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.fromAddressDesc")}
|
||||||
|
</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">{t("admin.tlsMode")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.tlsModeDesc")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="admin-setting-toggle-group">
|
||||||
|
<button
|
||||||
|
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
|
||||||
|
onClick={() => updateDraft("smtp_secure", "auto")}
|
||||||
|
>
|
||||||
|
{t("admin.auto")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
|
||||||
|
onClick={() => updateDraft("smtp_secure", "true")}
|
||||||
|
>
|
||||||
|
{t("admin.forceTls")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
|
||||||
|
onClick={() => updateDraft("smtp_secure", "false")}
|
||||||
|
>
|
||||||
|
{t("admin.off")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-setting-row admin-setting-row-stacked">
|
||||||
|
<div className="admin-setting-label">
|
||||||
|
<span className="admin-setting-title">{t("admin.sendTestEmail")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.sendTestEmailDesc")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-test-email-row">
|
||||||
|
<input
|
||||||
|
className="input admin-setting-input"
|
||||||
|
placeholder={t("admin.sendTestEmailDesc")}
|
||||||
|
value={testEmailTo}
|
||||||
|
onChange={(e) => setTestEmailTo(e.target.value)}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void sendTestEmail()}
|
||||||
|
disabled={testEmailSending || !settingsDraft?.smtp_host}
|
||||||
|
>
|
||||||
|
{testEmailSending ? t("admin.sending") : t("admin.sendTest")}
|
||||||
|
</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>{t("admin.maintenance")}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-panel-body admin-settings-form">
|
<div className="admin-panel-body admin-settings-form">
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label">
|
<label className="admin-setting-label">
|
||||||
<span className="admin-setting-title">Maintenance Mode</span>
|
<span className="admin-setting-title">{t("admin.maintenanceMode")}</span>
|
||||||
<span className="admin-setting-desc">Take the site offline for non-admin users</span>
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.maintenanceModeDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`}
|
className={`admin-switch${settingsDraft.maintenance_mode === "true" ? " on" : ""}`}
|
||||||
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')}
|
onClick={() =>
|
||||||
|
updateDraft(
|
||||||
|
"maintenance_mode",
|
||||||
|
settingsDraft.maintenance_mode === "true"
|
||||||
|
? "false"
|
||||||
|
: "true",
|
||||||
|
)
|
||||||
|
}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={settingsDraft.maintenance_mode === 'true'}
|
aria-checked={settingsDraft.maintenance_mode === "true"}
|
||||||
>
|
>
|
||||||
<span className="admin-switch-thumb" />
|
<span className="admin-switch-thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{settingsDraft.maintenance_mode === 'true' && (
|
{settingsDraft.maintenance_mode === "true" && (
|
||||||
<div className="admin-setting-row">
|
<div className="admin-setting-row">
|
||||||
<label className="admin-setting-label" htmlFor="setting-maint-msg">
|
<label
|
||||||
<span className="admin-setting-title">Maintenance Message</span>
|
className="admin-setting-label"
|
||||||
<span className="admin-setting-desc">Message displayed to visitors during maintenance</span>
|
htmlFor="setting-maint-msg"
|
||||||
|
>
|
||||||
|
<span className="admin-setting-title">
|
||||||
|
{t("admin.maintenanceMessage")}
|
||||||
|
</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.maintenanceMessageDesc")}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="setting-maint-msg"
|
id="setting-maint-msg"
|
||||||
className="textarea admin-setting-textarea"
|
className="textarea admin-setting-textarea"
|
||||||
value={settingsDraft.maintenance_message}
|
value={settingsDraft.maintenance_message}
|
||||||
onChange={(e) => updateDraft('maintenance_message', e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateDraft("maintenance_message", e.target.value)
|
||||||
|
}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,33 +652,37 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
onClick={() => setSettingsDraft(settings)}
|
onClick={() => setSettingsDraft(settings)}
|
||||||
disabled={!hasChanges}
|
disabled={!hasChanges}
|
||||||
>
|
>
|
||||||
Discard Changes
|
{t("admin.discardChanges")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => void saveSettings()}
|
onClick={() => void saveSettings()}
|
||||||
disabled={saving || !hasChanges}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save Settings'}
|
{saving ? t("admin.sending") : t("admin.saveSettings")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Users Tab ─── */}
|
{/* ─── Users Tab ─── */}
|
||||||
{tab === 'users' && (
|
{tab === "users" && (
|
||||||
<div className="admin-section fade-in">
|
<div className="admin-section fade-in">
|
||||||
<div className="admin-users-toolbar">
|
<div className="admin-users-toolbar">
|
||||||
<form onSubmit={handleUserSearch} className="admin-search-form">
|
<form onSubmit={handleUserSearch} className="admin-search-form">
|
||||||
<input
|
<input
|
||||||
className="input admin-search-input"
|
className="input admin-search-input"
|
||||||
placeholder="Search by name or email…"
|
placeholder={t("admin.searchByNameOrEmail")}
|
||||||
value={userSearch}
|
value={userSearch}
|
||||||
onChange={(e) => setUserSearch(e.target.value)}
|
onChange={(e) => setUserSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-sm" type="submit">Search</button>
|
<button className="btn btn-sm" type="submit">
|
||||||
|
{t("admin.search")}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="admin-users-count mono">{usersTotal} user{usersTotal !== 1 ? 's' : ''}</div>
|
<div className="admin-users-count mono">
|
||||||
|
{usersTotal} {usersTotal !== 1 ? t("admin.users") : t("admin.user")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
@@ -424,13 +690,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>{t("admin.user")}</th>
|
||||||
<th>Role</th>
|
<th>{t("admin.role")}</th>
|
||||||
<th>Verified</th>
|
<th>{t("admin.verified")}</th>
|
||||||
<th>Exams</th>
|
<th>{t("admin.exams")}</th>
|
||||||
<th>Results</th>
|
<th>{t("results.points")}</th>
|
||||||
<th>Joined</th>
|
<th>{t("admin.joined")}</th>
|
||||||
<th>Actions</th>
|
<th>{t("admin.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -442,7 +708,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
{u.display_name.charAt(0).toUpperCase()}
|
{u.display_name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="admin-user-name">{u.display_name}</div>
|
<div className="admin-user-name">
|
||||||
|
{u.display_name}
|
||||||
|
</div>
|
||||||
<div className="admin-user-email">{u.email}</div>
|
<div className="admin-user-email">{u.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,31 +721,50 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} />
|
<span
|
||||||
|
className={`admin-verified-dot ${u.email_verified ? "yes" : "no"}`}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">{u.exams_count}</td>
|
<td className="mono">{u.exams_count}</td>
|
||||||
<td className="mono">{u.results_count}</td>
|
<td className="mono">{u.results_count}</td>
|
||||||
<td className="admin-date-cell">{formatDate(u.created_at)}</td>
|
<td className="admin-date-cell">
|
||||||
|
{formatDate(u.created_at)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="admin-actions">
|
<div className="admin-actions">
|
||||||
<select
|
<select
|
||||||
className="admin-role-select"
|
className="admin-role-select"
|
||||||
value={u.role}
|
value={u.role}
|
||||||
onChange={(e) => void changeRole(u.id, e.target.value as 'user' | 'admin')}
|
onChange={(e) =>
|
||||||
|
void handleChangeRole(
|
||||||
|
u.id,
|
||||||
|
e.target.value as "user" | "admin",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="user">User</option>
|
<option value="user">{t("admin.user")}</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">{t("admin.role")}</option>
|
||||||
</select>
|
</select>
|
||||||
{deleteConfirm === u.id ? (
|
{deleteConfirm === u.id ? (
|
||||||
<div className="admin-delete-confirm">
|
<div className="admin-delete-confirm">
|
||||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteUser(u.id)}>Confirm</button>
|
<button
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
className="btn btn-sm admin-btn-danger"
|
||||||
|
onClick={() => void handleDeleteUser(u.id)}
|
||||||
|
>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||||
onClick={() => setDeleteConfirm(u.id)}
|
onClick={() => setDeleteConfirm(u.id)}
|
||||||
title="Delete user"
|
title={t("common.delete")}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -488,7 +775,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
))}
|
))}
|
||||||
{users.length === 0 && (
|
{users.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="admin-empty">No users found</td>
|
<td colSpan={7} className="admin-empty">
|
||||||
|
{t("admin.noUsersFound")}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -498,5 +787,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { ApiError } from '../utils/api'
|
import { ApiError } from '../utils/api'
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ interface Props {
|
|||||||
type Tab = 'login' | 'register'
|
type Tab = 'login' | 'register'
|
||||||
|
|
||||||
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
|
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { login, register } = useAuth()
|
const { login, register } = useAuth()
|
||||||
const [tab, setTab] = useState<Tab>('login')
|
const [tab, setTab] = useState<Tab>('login')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -34,7 +36,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
await login(email, password)
|
await login(email, password)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Login failed')
|
setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError(t('auth.passwordsDoNotMatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
@@ -52,7 +54,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
const message = await register(email, password, displayName)
|
const message = await register(email, password, displayName)
|
||||||
setSuccess(message)
|
setSuccess(message)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Registration failed')
|
setError(err instanceof ApiError ? err.message : t('auth.registrationFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -61,17 +63,17 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
return (
|
return (
|
||||||
<div className="auth-overlay" onClick={onClose}>
|
<div className="auth-overlay" onClick={onClose}>
|
||||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="auth-close" onClick={onClose} aria-label="Close">×</button>
|
<button className="auth-close" onClick={onClose} aria-label={t('common.close')}>×</button>
|
||||||
|
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<div className="brand-mark" />
|
<div className="brand-mark" />
|
||||||
<h2 className="auth-title">
|
<h2 className="auth-title">
|
||||||
{tab === 'login' ? 'Welcome back' : 'Create account'}
|
{tab === 'login' ? t('auth.welcomeBack') : t('auth.createAccount')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="auth-subtitle">
|
<p className="auth-subtitle">
|
||||||
{tab === 'login'
|
{tab === 'login'
|
||||||
? 'Sign in to access your saved exams and results'
|
? t('auth.signInSubtitle')
|
||||||
: 'Create an account to save your progress'}
|
: t('auth.registerSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,13 +82,13 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
|
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
|
||||||
onClick={() => switchTab('login')}
|
onClick={() => switchTab('login')}
|
||||||
>
|
>
|
||||||
Sign In
|
{t('auth.signIn')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
|
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
|
||||||
onClick={() => switchTab('register')}
|
onClick={() => switchTab('register')}
|
||||||
>
|
>
|
||||||
Register
|
{t('auth.register')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
{!success && tab === 'login' && (
|
{!success && tab === 'login' && (
|
||||||
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
|
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="login-email">Email</label>
|
<label className="auth-label" htmlFor="login-email">{t('auth.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="login-email"
|
id="login-email"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -109,7 +111,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="login-password">Password</label>
|
<label className="auth-label" htmlFor="login-password">{t('auth.password')}</label>
|
||||||
<input
|
<input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -122,14 +124,14 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||||
{submitting ? 'Signing in…' : 'Sign In'}
|
{submitting ? t('auth.signingIn') : t('auth.signIn')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="auth-forgot"
|
className="auth-forgot"
|
||||||
onClick={() => { onClose(); onForgotPassword() }}
|
onClick={() => { onClose(); onForgotPassword() }}
|
||||||
>
|
>
|
||||||
Forgot your password?
|
{t('auth.forgotPassword')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -137,7 +139,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
{!success && tab === 'register' && (
|
{!success && tab === 'register' && (
|
||||||
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
|
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="reg-name">Display Name</label>
|
<label className="auth-label" htmlFor="reg-name">{t('auth.displayName')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-name"
|
id="reg-name"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -150,7 +152,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="reg-email">Email</label>
|
<label className="auth-label" htmlFor="reg-email">{t('auth.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-email"
|
id="reg-email"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -163,7 +165,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="reg-password">Password</label>
|
<label className="auth-label" htmlFor="reg-password">{t('auth.password')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-password"
|
id="reg-password"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -177,7 +179,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="reg-confirm">Confirm Password</label>
|
<label className="auth-label" htmlFor="reg-confirm">{t('auth.confirmPassword')}</label>
|
||||||
<input
|
<input
|
||||||
id="reg-confirm"
|
id="reg-confirm"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -191,7 +193,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||||
{submitting ? 'Creating account…' : 'Create Account'}
|
{submitting ? t('auth.creatingAccount') : t('auth.register')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { apiFetch, ApiError } from '../utils/api'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ApiError } from '../utils/api'
|
||||||
|
import * as authApi from '../api/authApi'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailVerify({ onDone }: Readonly<Props>) {
|
export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const called = useRef(false)
|
const called = useRef(false)
|
||||||
@@ -19,11 +22,11 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setMessage('No verification token found.')
|
setMessage(t('verify.noToken'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
authApi.verifyEmailToken(token)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setStatus('success')
|
setStatus('success')
|
||||||
setMessage(data.message)
|
setMessage(data.message)
|
||||||
@@ -32,9 +35,9 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setMessage(err instanceof ApiError ? err.message : 'Verification failed')
|
setMessage(err instanceof ApiError ? err.message : t('verify.verificationFailed'))
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="verify-page">
|
<div className="verify-page">
|
||||||
@@ -42,27 +45,27 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
|
|||||||
{status === 'loading' && (
|
{status === 'loading' && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon">⏳</div>
|
<div className="verify-icon">⏳</div>
|
||||||
<h2 className="verify-title">Verifying your email…</h2>
|
<h2 className="verify-title">{t('verify.verifying')}</h2>
|
||||||
<p className="verify-message">Please wait while we confirm your email address.</p>
|
<p className="verify-message">{t('verify.pleaseWait')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon success">✓</div>
|
<div className="verify-icon success">✓</div>
|
||||||
<h2 className="verify-title">Email Verified</h2>
|
<h2 className="verify-title">{t('verify.emailVerified')}</h2>
|
||||||
<p className="verify-message">{message}</p>
|
<p className="verify-message">{message}</p>
|
||||||
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
||||||
Sign In →
|
{t('verify.signIn')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon error">✗</div>
|
<div className="verify-icon error">✗</div>
|
||||||
<h2 className="verify-title">Verification Failed</h2>
|
<h2 className="verify-title">{t('verify.verificationFailed')}</h2>
|
||||||
<p className="verify-message">{message}</p>
|
<p className="verify-message">{message}</p>
|
||||||
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
||||||
Back to Home
|
{t('verify.backToHome')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component, type ReactNode, type ErrorInfo } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught error:', error, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
this.props.fallback ?? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||||
|
<h2>Something went wrong</h2>
|
||||||
|
<p style={{ color: '#666' }}>
|
||||||
|
{this.state.error?.message ?? 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import * as examApi from '../api/examApi'
|
||||||
|
import type { Exam, Question, QuestionType, Choice } from '../types'
|
||||||
|
|
||||||
|
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||||
|
{ value: 'multiple-choice', labelKey: 'editor.multipleChoice' },
|
||||||
|
{ value: 'multiple-select', labelKey: 'editor.multipleSelect' },
|
||||||
|
{ value: 'short-answer', labelKey: 'editor.shortAnswer' },
|
||||||
|
{ value: 'translation', labelKey: 'editor.translation' },
|
||||||
|
{ value: 'math-input', labelKey: 'editor.mathInput' },
|
||||||
|
{ value: 'essay', labelKey: 'editor.essay' },
|
||||||
|
{ value: 'match', labelKey: 'editor.match' },
|
||||||
|
{ value: 'order', labelKey: 'editor.order' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BLANK_EXAM: Exam = {
|
||||||
|
title: '',
|
||||||
|
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 { t } = useTranslation()
|
||||||
|
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)
|
||||||
|
examApi.getExam(examId!)
|
||||||
|
.then((data) => {
|
||||||
|
setExam(data)
|
||||||
|
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(t('editor.examTitleRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (exam.questions.length === 0) {
|
||||||
|
setError(t('editor.atLeastOneQuestion'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
const data = await examApi.createExam(exam)
|
||||||
|
setSaveMsg(t('editor.examCreated'))
|
||||||
|
setHasChanges(false)
|
||||||
|
navigate(`/exam-editor/${data.id}`, { replace: true })
|
||||||
|
} else {
|
||||||
|
await examApi.updateExam(examId!, exam)
|
||||||
|
setSaveMsg(t('editor.examSaved'))
|
||||||
|
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')}>
|
||||||
|
{t('editor.back')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="exam-editor-title-input"
|
||||||
|
value={exam.title}
|
||||||
|
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
|
||||||
|
placeholder={t('editor.examTitlePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="exam-editor-header-right">
|
||||||
|
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
|
||||||
|
{hasChanges && <span className="exam-editor-unsaved">{t('editor.unsavedChanges')}</span>}
|
||||||
|
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
|
||||||
|
{saving ? t('editor.saving') : isNew ? t('editor.createExam') : t('editor.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">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="exam-editor-body">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="exam-editor-sidebar">
|
||||||
|
<div className="sidebar-section-label">{t('editor.questionsLabel')}</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={t('common.delete')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="exam-editor-add">
|
||||||
|
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
|
||||||
|
<div className="exam-editor-type-grid">
|
||||||
|
{QUESTION_TYPES.map((tType) => (
|
||||||
|
<button
|
||||||
|
key={tType.value}
|
||||||
|
className="exam-editor-type-btn"
|
||||||
|
onClick={() => addQuestion(tType.value)}
|
||||||
|
>
|
||||||
|
{t(tType.labelKey)}
|
||||||
|
</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">{t('editor.noQuestions')}</div>
|
||||||
|
<div className="workspace-empty-desc">{t('editor.noQuestionsDesc')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Question Editor ─── */
|
||||||
|
|
||||||
|
interface QEProps {
|
||||||
|
question: Question
|
||||||
|
onChange: (fn: (q: Question) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionEditor({ question, onChange }: QEProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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 }}>{t('editor.questionEditor')}</span>
|
||||||
|
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="question-form-row">
|
||||||
|
<label className="question-form-label">{t('editor.prompt')}</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(e) => update('prompt', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder={t('editor.promptPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="question-form-row-inline">
|
||||||
|
<div>
|
||||||
|
<label className="question-form-label">{t('editor.categoryOptional')}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={question.category ?? ''}
|
||||||
|
onChange={(e) => update('category', e.target.value || undefined)}
|
||||||
|
placeholder={t('editor.categoryPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 100 }}>
|
||||||
|
<label className="question-form-label">{t('editor.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">{t('editor.explanationOptional')}</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={question.explanation ?? ''}
|
||||||
|
onChange={(e) => update('explanation', e.target.value || undefined)}
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('editor.explanationPlaceholder')}
|
||||||
|
/>
|
||||||
|
</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">{t('editor.sourceLanguage')}</label>
|
||||||
|
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder={t('editor.sourceLanguagePlaceholder')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="question-form-label">{t('editor.targetLanguage')}</label>
|
||||||
|
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder={t('editor.targetLanguagePlaceholder')} />
|
||||||
|
</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 { t } = useTranslation()
|
||||||
|
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">{t('editor.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={t('editor.markAsCorrect')}
|
||||||
|
/>
|
||||||
|
<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={t('editor.choicePlaceholder', { id: c.id })}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
|
||||||
|
{t('editor.addChoice')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Multiple Select ─── */
|
||||||
|
|
||||||
|
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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">{t('editor.choicesCheckCorrect')}</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={t('editor.choicePlaceholder', { id: c.id })}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title={t('common.delete')}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
|
||||||
|
{t('editor.addChoice')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Text Answer (short, translation, math) ─── */
|
||||||
|
|
||||||
|
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const accepted = q.acceptedForms ?? []
|
||||||
|
return (
|
||||||
|
<div className="question-form-section">
|
||||||
|
<div className="question-form-row">
|
||||||
|
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(q.answer as string) ?? ''}
|
||||||
|
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
|
||||||
|
placeholder={t('editor.correctAnswerPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="question-form-row">
|
||||||
|
<label className="question-form-label">{t('editor.acceptedForms')}</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={t('editor.alternativeAnswer')}
|
||||||
|
/>
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
{t('editor.addAlternative')}
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
{t('editor.caseSensitive')}
|
||||||
|
</label>
|
||||||
|
<label className="question-form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={q.ignorePunctuation ?? false}
|
||||||
|
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('editor.ignorePunctuation')}
|
||||||
|
</label>
|
||||||
|
<label className="question-form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={q.ignoreAccents ?? false}
|
||||||
|
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('editor.ignoreAccents')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Essay ─── */
|
||||||
|
|
||||||
|
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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">{t('editor.minWords')}</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">{t('editor.rubricCriteria')}</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={t('editor.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 }}
|
||||||
|
>
|
||||||
|
{t('editor.addCriterion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Match ─── */
|
||||||
|
|
||||||
|
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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">{t('editor.leftColumn')}</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={t('editor.leftItemPlaceholder', { index: i + 1 })}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>{t('editor.addLeft')}</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="question-form-label">{t('editor.rightColumn')}</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={t('editor.rightItemPlaceholder', { index: i + 1 })}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>{t('editor.addRight')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{left.length > 0 && right.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<label className="question-form-label">{t('editor.correctPairings')}</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="">{t('editor.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 { t } = useTranslation()
|
||||||
|
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">{t('editor.items')}</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={t('editor.itemPlaceholder', { index: 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 }}>{t('editor.addItem')}</button>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<label className="question-form-label">{t('editor.correctOrder')}</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 }}>
|
||||||
|
{t('editor.correctOrderHint')}
|
||||||
|
</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) })}
|
||||||
|
>
|
||||||
|
{t('editor.setCurrentOrder')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
"use no memo";
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
import ExamRunner from './ExamRunner'
|
import ExamRunner from './ExamRunner'
|
||||||
import { loadState } from '../utils/storage'
|
import { loadState } from '../utils/storage'
|
||||||
import { slugify } from '../utils/slug'
|
import { getExamUrlId } from '../utils/slug'
|
||||||
import type { Exam, ExamState } from '../types'
|
import type { Exam, ExamState } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,13 +20,13 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
|
|||||||
const { slug, n } = useParams<{ slug: string; n: string }>()
|
const { slug, n } = useParams<{ slug: string; n: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const questionIdx = Math.max(0, (Number(n) || 1) - 1)
|
const questionIdx = Math.max(0, (Number((n ?? '').replace(/^q/, '')) || 1) - 1)
|
||||||
|
|
||||||
// Deep link: restore from localStorage if no exam is active on mount
|
// Deep link: restore from localStorage if no exam is active on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!exam) {
|
if (!exam) {
|
||||||
const saved = loadState()
|
const saved = loadState()
|
||||||
if (saved?.exam && slugify(saved.exam.title) === slug) {
|
if (saved?.exam && getExamUrlId(saved.exam) === slug) {
|
||||||
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
|
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
|
||||||
} else {
|
} else {
|
||||||
navigate('/', { replace: true })
|
navigate('/', { replace: true })
|
||||||
@@ -44,12 +45,14 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ExamRunner
|
<ExamRunner
|
||||||
|
key={n}
|
||||||
exam={exam}
|
exam={exam}
|
||||||
state={examState}
|
state={examState}
|
||||||
setState={setExamState}
|
setState={setExamState}
|
||||||
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
|
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
|
n={n}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
"use no memo";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Exam, ExamState, Question } from "../types";
|
import type { Exam, ExamState, Question } from "../types";
|
||||||
import { gradeQuestion } from "../utils/grading";
|
import { gradeQuestion } from "../utils/grading";
|
||||||
import { formatTime } from "../utils/formatting";
|
import { formatTime } from "../utils/formatting";
|
||||||
@@ -13,6 +15,7 @@ interface Props {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
onNavigate?: (i: number) => void;
|
onNavigate?: (i: number) => void;
|
||||||
|
n?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamRunner({
|
export default function ExamRunner({
|
||||||
@@ -22,8 +25,12 @@ export default function ExamRunner({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onExit,
|
onExit,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
n,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const { currentIdx, answers, flags, startedAt, feedbackMode } = state;
|
const { t } = useTranslation()
|
||||||
|
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 q = exam.questions[currentIdx];
|
||||||
const total = exam.questions.length;
|
const total = exam.questions.length;
|
||||||
const notes = state.notes ?? "";
|
const notes = state.notes ?? "";
|
||||||
@@ -146,7 +153,7 @@ export default function ExamRunner({
|
|||||||
<h2 className="sidebar-title">{exam.title}</h2>
|
<h2 className="sidebar-title">{exam.title}</h2>
|
||||||
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
|
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
|
||||||
|
|
||||||
<div className="sidebar-section-label">Questions</div>
|
<div className="sidebar-section-label">{t('exam.questionsLabel')}</div>
|
||||||
<div className="nav-list">
|
<div className="nav-list">
|
||||||
{exam.questions.map((qq, i) => {
|
{exam.questions.map((qq, i) => {
|
||||||
const st = navStatus(qq);
|
const st = navStatus(qq);
|
||||||
@@ -169,9 +176,9 @@ export default function ExamRunner({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section-label">Progress</div>
|
<div className="sidebar-section-label">{t('exam.progressLabel')}</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
|
{t('exam.progressText', { answered: answeredCount, total })}
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar">
|
<div className="progress-bar">
|
||||||
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
|
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
|
||||||
@@ -186,10 +193,10 @@ export default function ExamRunner({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="btn btn-primary" onClick={onSubmit}>
|
<button className="btn btn-primary" onClick={onSubmit}>
|
||||||
Submit exam
|
{t('exam.submitExam')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
||||||
Exit to home
|
{t('exam.exitToHome')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -197,12 +204,11 @@ export default function ExamRunner({
|
|||||||
<main className="main">
|
<main className="main">
|
||||||
<header className="question-header">
|
<header className="question-header">
|
||||||
<span className="q-index">
|
<span className="q-index">
|
||||||
Question {String(currentIdx + 1).padStart(2, "0")} /{" "}
|
{t('exam.questionHeader', { current: String(currentIdx + 1).padStart(2, "0"), total: String(total).padStart(2, "0") })}
|
||||||
{String(total).padStart(2, "0")}
|
|
||||||
</span>
|
</span>
|
||||||
{q.category && <span className="q-category">{q.category}</span>}
|
{q.category && <span className="q-category">{q.category}</span>}
|
||||||
<span className="q-points">
|
<span className="q-points">
|
||||||
{q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"}
|
{q.points ?? 1} {(q.points ?? 1) === 1 ? t('common.pt') : t('common.pts')}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -222,7 +228,7 @@ export default function ExamRunner({
|
|||||||
{graded && result && q.type !== "essay" && (
|
{graded && result && q.type !== "essay" && (
|
||||||
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
||||||
<div className="feedback-label">
|
<div className="feedback-label">
|
||||||
{result.correct ? "Correct" : "Not quite"}
|
{result.correct ? t('exam.correct') : t('exam.notQuite')}
|
||||||
</div>
|
</div>
|
||||||
<div className="feedback-body">
|
<div className="feedback-body">
|
||||||
{!result.correct &&
|
{!result.correct &&
|
||||||
@@ -230,7 +236,7 @@ export default function ExamRunner({
|
|||||||
q.type !== "match" &&
|
q.type !== "match" &&
|
||||||
q.type !== "order" && (
|
q.type !== "order" && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Answer:</strong> <Tex>{answerText}</Tex>
|
<strong>{t('exam.answerLabel')}</strong> <Tex>{answerText}</Tex>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{q.explanation && (
|
{q.explanation && (
|
||||||
@@ -248,10 +254,10 @@ export default function ExamRunner({
|
|||||||
onClick={() => goTo(currentIdx - 1)}
|
onClick={() => goTo(currentIdx - 1)}
|
||||||
disabled={currentIdx === 0}
|
disabled={currentIdx === 0}
|
||||||
>
|
>
|
||||||
← Previous
|
{t('exam.previous')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||||||
{flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"}
|
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||||||
</button>
|
</button>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{feedbackMode === "immediate" &&
|
{feedbackMode === "immediate" &&
|
||||||
@@ -259,7 +265,7 @@ export default function ExamRunner({
|
|||||||
q.type !== "essay" &&
|
q.type !== "essay" &&
|
||||||
answered && (
|
answered && (
|
||||||
<button className="btn btn-primary" onClick={checkAnswer}>
|
<button className="btn btn-primary" onClick={checkAnswer}>
|
||||||
Check answer
|
{t('exam.checkAnswer')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentIdx < total - 1 ? (
|
{currentIdx < total - 1 ? (
|
||||||
@@ -267,11 +273,11 @@ export default function ExamRunner({
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => goTo(currentIdx + 1)}
|
onClick={() => goTo(currentIdx + 1)}
|
||||||
>
|
>
|
||||||
Next →
|
{t('exam.next')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" onClick={onSubmit}>
|
<button className="btn btn-primary" onClick={onSubmit}>
|
||||||
Submit exam
|
{t('exam.submitExam')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
@@ -288,15 +294,15 @@ export default function ExamRunner({
|
|||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
|
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
|
||||||
navigate
|
{t('exam.navigateHint')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="kbd">F</span> flag
|
<span className="kbd">F</span> {t('exam.flagHint')}
|
||||||
</span>
|
</span>
|
||||||
{q.type === "multiple-choice" && q.choices && (
|
{q.type === "multiple-choice" && q.choices && (
|
||||||
<span>
|
<span>
|
||||||
<span className="kbd">1</span>–
|
<span className="kbd">1</span>–
|
||||||
<span className="kbd">{q.choices.length}</span> select
|
<span className="kbd">{q.choices.length}</span> {t('exam.selectHint')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,12 +310,12 @@ export default function ExamRunner({
|
|||||||
|
|
||||||
<aside className="meta-rail">
|
<aside className="meta-rail">
|
||||||
<div className="meta-block">
|
<div className="meta-block">
|
||||||
<div className="meta-label">Elapsed</div>
|
<div className="meta-label">{t('exam.elapsed')}</div>
|
||||||
<div className="meta-value mono">{formatTime(elapsedMs)}</div>
|
<div className="meta-value mono">{formatTime(elapsedMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
{remainingMs != null && (
|
{remainingMs != null && (
|
||||||
<div className="meta-block">
|
<div className="meta-block">
|
||||||
<div className="meta-label">Remaining</div>
|
<div className="meta-label">{t('exam.remaining')}</div>
|
||||||
<div
|
<div
|
||||||
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
|
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
|
||||||
>
|
>
|
||||||
@@ -318,35 +324,57 @@ export default function ExamRunner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="meta-block">
|
<div className="meta-block">
|
||||||
<div className="meta-label">Points possible</div>
|
<div className="meta-label">{t('exam.pointsPossible')}</div>
|
||||||
<div className="meta-value mono">{totalPossible}</div>
|
<div className="meta-value mono">{totalPossible}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-block">
|
<div className="meta-block">
|
||||||
<div className="meta-label">Mode</div>
|
<div className="meta-label">{t('exam.mode')}</div>
|
||||||
<div className="meta-value mono" style={{ fontSize: 13 }}>
|
<div className="meta-value mono" style={{ fontSize: 13 }}>
|
||||||
{feedbackMode === "immediate"
|
{feedbackMode === "immediate"
|
||||||
? "Immediate feedback"
|
? t('exam.immediateFeedback')
|
||||||
: "Grade at end"}
|
: t('exam.gradeAtEnd')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-block notes-block">
|
<div className="meta-block notes-block">
|
||||||
<div className="meta-label">Notes</div>
|
<div className="meta-label">{t('exam.notes')}</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea notes-pad"
|
className="textarea notes-pad"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => updateNotes(e.target.value)}
|
onChange={(e) => updateNotes(e.target.value)}
|
||||||
placeholder="Add short notes while you work..."
|
placeholder={t('exam.notesPlaceholder')}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
aria-label="Exam notes"
|
aria-label={t('exam.notes')}
|
||||||
/>
|
/>
|
||||||
<div className="notes-count mono">{notes.length}/1000</div>
|
<div className="notes-count mono">{notes.length}/1000</div>
|
||||||
</div>
|
</div>
|
||||||
{exam.author && (
|
{exam.author && (
|
||||||
<div className="meta-block">
|
<div className="meta-block">
|
||||||
<div className="meta-label">Author</div>
|
<div className="meta-label">{t('exam.author')}</div>
|
||||||
<div style={{ fontSize: 14 }}>{exam.author}</div>
|
<div style={{ fontSize: 14 }}>{exam.author}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="meta-block" style={{ marginTop: 'auto' }}>
|
||||||
|
<div className="meta-label">{t('tweaks.feedback')}</div>
|
||||||
|
<div className="tweak-control">
|
||||||
|
<button
|
||||||
|
className={`tweak-seg${feedbackMode === 'immediate' ? ' active' : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
setState((s) => ({ ...s, feedbackMode: 'immediate' }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('tweaks.immediate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tweak-seg${feedbackMode === 'end' ? ' active' : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
setState((s) => ({ ...s, feedbackMode: 'end' }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('tweaks.end')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Exam, HistoryEntry } from '../types'
|
import type { Exam, HistoryEntry } from '../types'
|
||||||
import { validateExam, parseExam } from '../utils/validation'
|
import { validateExam, parseExam } from '../utils/validation'
|
||||||
import { loadState } from '../utils/storage'
|
import { loadState } from '../utils/storage'
|
||||||
import { formatDate, formatTime } from '../utils/formatting'
|
import { formatDate, formatTime } from '../utils/formatting'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLoadExam: (exam: Exam) => void
|
onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void
|
||||||
history: HistoryEntry[]
|
history: HistoryEntry[]
|
||||||
onResume: () => void
|
onResume: () => void
|
||||||
onClearHistory: () => void
|
onClearHistory: () => void
|
||||||
@@ -19,6 +20,7 @@ const SAMPLES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
|
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -30,9 +32,9 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
|
|||||||
const data = JSON.parse(text) as unknown
|
const data = JSON.parse(text) as unknown
|
||||||
const msg = validateExam(data)
|
const msg = validateExam(data)
|
||||||
if (msg) throw new Error(msg)
|
if (msg) throw new Error(msg)
|
||||||
onLoadExam(parseExam(data))
|
onLoadExam(parseExam(data), true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(`Could not load: ${(e as Error).message}`)
|
setErr(t('landing.loadError', { message: (e as Error).message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
|
|||||||
if (msg) throw new Error(msg)
|
if (msg) throw new Error(msg)
|
||||||
onLoadExam(parseExam(data))
|
onLoadExam(parseExam(data))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(`Could not load sample: ${(e as Error).message}`)
|
setErr(t('landing.loadSampleError', { message: (e as Error).message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,10 +377,10 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="landing">
|
<div className="landing">
|
||||||
<div className="landing-eyebrow">BrainWave · practice exam platform</div>
|
<div className="landing-eyebrow">{t('landing.eyebrow')}</div>
|
||||||
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1>
|
<h1 className="landing-title">{t('landing.title')}<em>{t('landing.titleEmphasis')}</em></h1>
|
||||||
<p className="landing-sub">
|
<p className="landing-sub">
|
||||||
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.
|
{t('landing.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="landing-grid">
|
<div className="landing-grid">
|
||||||
@@ -395,9 +397,13 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
if (f) void handleFile(f)
|
if (f) void handleFile(f)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="dropzone-icon" />
|
<div className="dropzone-icon">
|
||||||
<h3>Drop your exam JSON here</h3>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<p>or click to browse · max 5 MB</p>
|
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>{t('landing.dropzoneTitle')}</h3>
|
||||||
|
<p>{t('landing.dropzoneSub')}</p>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -413,8 +419,8 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
onClick={downloadInstructions}
|
onClick={downloadInstructions}
|
||||||
id="download-instructions"
|
id="download-instructions"
|
||||||
>
|
>
|
||||||
<span className="download-instructions-icon">↓</span>
|
<span className="download-instructions-icon">{t('landing.downloadInstructionsIcon')}</span>
|
||||||
Download Instructions — How to create your own exam JSON
|
{t('landing.downloadInstructions')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
@@ -426,23 +432,23 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
{resumable && (
|
{resumable && (
|
||||||
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 500, fontSize: 14 }}>Resume in-progress exam</div>
|
<div style={{ fontWeight: 500, fontSize: 14 }}>{t('landing.resumeTitle')}</div>
|
||||||
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>
|
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>
|
||||||
{resumable.exam.title} · {Object.keys(resumable.state.answers ?? {}).length}/{resumable.exam.questions.length} answered
|
{resumable.exam.title} · {t('landing.resumeProgress', { answered: Object.keys(resumable.state.answers ?? {}).length, total: resumable.exam.questions.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-sm" onClick={onResume}>Resume →</button>
|
<button className="btn btn-primary btn-sm" onClick={onResume}>{t('landing.resumeButton')}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
|
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
|
||||||
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>Supported types</div>
|
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>{t('landing.supportedTypes')}</div>
|
||||||
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
|
{t('landing.supportedTypesList')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="samples">
|
<div className="samples">
|
||||||
<h4>Try a sample</h4>
|
<h4>{t('landing.trySample')}</h4>
|
||||||
{SAMPLES.map((s) => (
|
{SAMPLES.map((s) => (
|
||||||
<button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}>
|
<button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}>
|
||||||
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
|
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
|
||||||
@@ -460,9 +466,9 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
<div className="history">
|
<div className="history">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
|
||||||
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>
|
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>
|
||||||
Past attempts
|
{t('landing.pastAttempts')}
|
||||||
</h4>
|
</h4>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>Clear history</button>
|
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>{t('landing.clearHistory')}</button>
|
||||||
</div>
|
</div>
|
||||||
{history.map((h) => {
|
{history.map((h) => {
|
||||||
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0
|
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0
|
||||||
@@ -472,12 +478,12 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
<div>
|
<div>
|
||||||
<div className="history-title">{h.title}</div>
|
<div className="history-title">{h.title}</div>
|
||||||
<div className="muted" style={{ fontSize: 12 }}>
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
{formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct
|
{t('landing.dateTimeFormat', { date: formatDate(h.completedAt), correct: h.correctCount, graded: h.gradedCount })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
|
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
|
||||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>Review</button>
|
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import * as examApi from '../api/examApi'
|
||||||
|
import type { Exam } from '../types'
|
||||||
|
|
||||||
|
interface SavedExam {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subtitle: string | null
|
||||||
|
question_count: number
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyExams({ onLoadExam }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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 [startingId, setStartingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const loadExams = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await examApi.listExams()
|
||||||
|
setExams(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadExams()
|
||||||
|
}, [loadExams])
|
||||||
|
|
||||||
|
const deleteExam = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await examApi.deleteExam(id)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
void loadExams()
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewExam = () => {
|
||||||
|
navigate('/exam-editor/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
const startExam = async (id: number) => {
|
||||||
|
setStartingId(id)
|
||||||
|
try {
|
||||||
|
const exam = await examApi.getExam(id)
|
||||||
|
onLoadExam(exam, false, id)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
setStartingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">{t('workspace.myWorkspace')}</div>
|
||||||
|
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||||||
|
+ {t('workspace.newExam')}
|
||||||
|
</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">{t('common.loading')}</div>
|
||||||
|
) : exams.length === 0 ? (
|
||||||
|
<div className="workspace-empty">
|
||||||
|
<div className="workspace-empty-icon">◇</div>
|
||||||
|
<div className="workspace-empty-title">{t('workspace.noSavedExams')}</div>
|
||||||
|
<div className="workspace-empty-desc">
|
||||||
|
{t('workspace.noSavedExamsDesc')}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||||
|
{t('workspace.createFirstExam')}
|
||||||
|
</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} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-card-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => void startExam(exam.id)}
|
||||||
|
disabled={startingId === exam.id}
|
||||||
|
>
|
||||||
|
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||||||
|
disabled={startingId === exam.id}
|
||||||
|
>
|
||||||
|
{t('common.edit')}
|
||||||
|
</button>
|
||||||
|
{deleteConfirm === exam.id ? (
|
||||||
|
<div className="workspace-delete-confirm">
|
||||||
|
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||||
|
onClick={() => setDeleteConfirm(exam.id)}
|
||||||
|
title={t('common.delete')}
|
||||||
|
disabled={startingId === exam.id}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import * as resultApi from '../api/resultApi'
|
||||||
|
import { getExamUrlId } from '../utils/slug'
|
||||||
|
|
||||||
|
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 { t } = useTranslation()
|
||||||
|
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 resultApi.listResults()
|
||||||
|
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 resultApi.deleteResult(id)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
void loadResults()
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewResult = async (item: ResultItem) => {
|
||||||
|
try {
|
||||||
|
const data = await resultApi.getResult(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))
|
||||||
|
const completedAt = (data as unknown as Record<string, unknown>).completed_at
|
||||||
|
const state = data.state as Record<string, unknown> | undefined
|
||||||
|
localStorage.setItem('brainwave.review.state', JSON.stringify({
|
||||||
|
...(state || {}),
|
||||||
|
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
|
||||||
|
}))
|
||||||
|
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
|
||||||
|
} 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">{t('workspace.myWorkspace')}</div>
|
||||||
|
<h2 className="workspace-section-title">{t('workspace.results')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mono muted" style={{ fontSize: 13 }}>{total} {total === 1 ? t('workspace.attempts') : t('workspace.attemptsPlural')}</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">{t('common.loading')}</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="workspace-empty">
|
||||||
|
<div className="workspace-empty-icon">✦</div>
|
||||||
|
<div className="workspace-empty-title">{t('workspace.noAttempts')}</div>
|
||||||
|
<div className="workspace-empty-desc">
|
||||||
|
{t('workspace.noAttemptsDesc')}
|
||||||
|
</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} {t('results.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)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</button>
|
||||||
|
{deleteConfirm === r.id ? (
|
||||||
|
<div className="workspace-delete-confirm">
|
||||||
|
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||||
|
onClick={() => setDeleteConfirm(r.id)}
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { apiFetch, ApiError } from '../utils/api'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ApiError } from '../utils/api'
|
||||||
|
import * as authApi from '../api/authApi'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
@@ -8,6 +10,7 @@ interface Props {
|
|||||||
type Step = 'request' | 'reset' | 'success'
|
type Step = 'request' | 'reset' | 'success'
|
||||||
|
|
||||||
export default function ResetPassword({ onDone }: Readonly<Props>) {
|
export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [step, setStep] = useState<Step>('request')
|
const [step, setStep] = useState<Step>('request')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
@@ -19,9 +22,9 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const t = params.get('token')
|
const tk = params.get('token')
|
||||||
if (t) {
|
if (tk) {
|
||||||
setToken(t)
|
setToken(tk)
|
||||||
setStep('reset')
|
setStep('reset')
|
||||||
window.history.replaceState({}, '', '/reset-password')
|
window.history.replaceState({}, '', '/reset-password')
|
||||||
}
|
}
|
||||||
@@ -32,13 +35,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ message: string }>('/api/auth/forgot-password', {
|
const data = await authApi.forgotPassword(email)
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
})
|
|
||||||
setMessage(data.message)
|
setMessage(data.message)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Request failed')
|
setError(err instanceof ApiError ? err.message : t('resetPassword.requestFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -48,18 +48,15 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError(t('resetPassword.passwordsDoNotMatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/auth/reset-password', {
|
await authApi.resetPassword({ token: token!, newPassword })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ token, newPassword }),
|
|
||||||
})
|
|
||||||
setStep('success')
|
setStep('success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Password reset failed')
|
setError(err instanceof ApiError ? err.message : t('resetPassword.passwordResetFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -71,12 +68,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
{step === 'request' && !message && (
|
{step === 'request' && !message && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon">🔑</div>
|
<div className="verify-icon">🔑</div>
|
||||||
<h2 className="verify-title">Reset Password</h2>
|
<h2 className="verify-title">{t('auth.resetPassword')}</h2>
|
||||||
<p className="verify-message">Enter your email address and we'll send you a link to reset your password.</p>
|
<p className="verify-message">{t('auth.resetPasswordDesc')}</p>
|
||||||
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="reset-email">Email</label>
|
<label className="auth-label" htmlFor="reset-email">{t('auth.emailLabel')}</label>
|
||||||
<input
|
<input
|
||||||
id="reset-email"
|
id="reset-email"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -88,10 +85,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||||
{submitting ? 'Sending…' : 'Send Reset Link'}
|
{submitting ? t('auth.sending') : t('auth.sendResetLink')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="auth-forgot" onClick={onDone}>
|
<button type="button" className="auth-forgot" onClick={onDone}>
|
||||||
← Back to sign in
|
← {t('auth.backToSignIn')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
@@ -100,10 +97,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
{step === 'request' && message && (
|
{step === 'request' && message && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon success">📧</div>
|
<div className="verify-icon success">📧</div>
|
||||||
<h2 className="verify-title">Check Your Email</h2>
|
<h2 className="verify-title">{t('auth.checkYourEmail')}</h2>
|
||||||
<p className="verify-message">{message}</p>
|
<p className="verify-message">{message}</p>
|
||||||
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
||||||
Back to Home
|
{t('auth.backToHome')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -111,12 +108,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
{step === 'reset' && (
|
{step === 'reset' && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon">🔐</div>
|
<div className="verify-icon">🔐</div>
|
||||||
<h2 className="verify-title">Set New Password</h2>
|
<h2 className="verify-title">{t('auth.setNewPassword')}</h2>
|
||||||
<p className="verify-message">Enter your new password below.</p>
|
<p className="verify-message">{t('auth.enterNewPassword')}</p>
|
||||||
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="new-password">New Password</label>
|
<label className="auth-label" htmlFor="new-password">{t('auth.newPassword')}</label>
|
||||||
<input
|
<input
|
||||||
id="new-password"
|
id="new-password"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -129,7 +126,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label className="auth-label" htmlFor="confirm-new-password">Confirm Password</label>
|
<label className="auth-label" htmlFor="confirm-new-password">{t('auth.confirmPassword')}</label>
|
||||||
<input
|
<input
|
||||||
id="confirm-new-password"
|
id="confirm-new-password"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -142,7 +139,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||||
{submitting ? 'Resetting…' : 'Reset Password'}
|
{submitting ? t('auth.resetting') : t('auth.resetPassword')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
@@ -151,10 +148,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
|||||||
{step === 'success' && (
|
{step === 'success' && (
|
||||||
<>
|
<>
|
||||||
<div className="verify-icon success">✓</div>
|
<div className="verify-icon success">✓</div>
|
||||||
<h2 className="verify-title">Password Updated</h2>
|
<h2 className="verify-title">{t('auth.passwordUpdated')}</h2>
|
||||||
<p className="verify-message">Your password has been reset successfully. You can now sign in with your new password.</p>
|
<p className="verify-message">{t('auth.passwordUpdatedDesc')}</p>
|
||||||
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
||||||
Sign In →
|
{t('auth.signIn')} →
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Exam, ExamState } from '../types'
|
import type { Exam, ExamState } from '../types'
|
||||||
import { gradeQuestion } from '../utils/grading'
|
import { gradeQuestion } from '../utils/grading'
|
||||||
import { formatTime } from '../utils/formatting'
|
import { formatTime } from '../utils/formatting'
|
||||||
@@ -15,6 +16,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) {
|
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { answers, startedAt, completedAt } = state
|
const { answers, startedAt, completedAt } = state
|
||||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||||
|
|
||||||
@@ -47,28 +49,28 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
return (
|
return (
|
||||||
<div className="results">
|
<div className="results">
|
||||||
<div className="results-eyebrow">
|
<div className="results-eyebrow">
|
||||||
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}
|
{reviewOnly ? t('results.reviewing') : t('results.examComplete')} · {exam.title}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="results-title">
|
<h1 className="results-title">
|
||||||
You scored <em>{pct}%</em><br />
|
{t('results.scored')} <em>{pct}%</em><br />
|
||||||
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
|
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
|
||||||
{passed
|
{passed
|
||||||
? `Above the ${passing}% passing mark.`
|
? t('results.abovePassing', { passing })
|
||||||
: `Below the ${passing}% passing mark. Keep practicing.`}
|
: t('results.belowPassing', { passing })}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="score-grid">
|
<div className="score-grid">
|
||||||
<div className="score-cell">
|
<div className="score-cell">
|
||||||
<div className="score-cell-label">Points</div>
|
<div className="score-cell-label">{t('results.points')}</div>
|
||||||
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
|
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="score-cell" style={{ paddingLeft: 24 }}>
|
<div className="score-cell" style={{ paddingLeft: 24 }}>
|
||||||
<div className="score-cell-label">Correct</div>
|
<div className="score-cell-label">{t('results.correct')}</div>
|
||||||
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
|
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="score-cell" style={{ paddingLeft: 24 }}>
|
<div className="score-cell" style={{ paddingLeft: 24 }}>
|
||||||
<div className="score-cell-label">Time</div>
|
<div className="score-cell-label">{t('results.time')}</div>
|
||||||
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{formatTime(elapsedMs)}</div>
|
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{formatTime(elapsedMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +78,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
{Object.keys(byCat).length > 1 && (
|
{Object.keys(byCat).length > 1 && (
|
||||||
<div className="category-breakdown">
|
<div className="category-breakdown">
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>
|
||||||
By category
|
{t('results.byCategory')}
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(byCat).map(([cat, c]) => {
|
{Object.entries(byCat).map(([cat, c]) => {
|
||||||
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0
|
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0
|
||||||
@@ -91,7 +93,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2 className="review-heading">Review</h2>
|
<h2 className="review-heading">{t('results.review')}</h2>
|
||||||
{graded.map((g, i) => {
|
{graded.map((g, i) => {
|
||||||
const promptText = g.q.prompt
|
const promptText = g.q.prompt
|
||||||
.replace(/\$\$([^$]+)\$\$/g, '[math]')
|
.replace(/\$\$([^$]+)\$\$/g, '[math]')
|
||||||
@@ -116,13 +118,13 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
|
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
|
||||||
{g.q.explanation && (
|
{g.q.explanation && (
|
||||||
<div className="feedback" style={{ marginTop: 24 }}>
|
<div className="feedback" style={{ marginTop: 24 }}>
|
||||||
<div className="feedback-label">Explanation</div>
|
<div className="feedback-label">{t('results.explanation')}</div>
|
||||||
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
|
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{g.q.rubric && (
|
{g.q.rubric && (
|
||||||
<div style={{ marginTop: 20, fontSize: 13.5 }}>
|
<div style={{ marginTop: 20, fontSize: 13.5 }}>
|
||||||
<div className="meta-label" style={{ marginBottom: 8 }}>Rubric (self-grade)</div>
|
<div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
|
||||||
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
||||||
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -135,8 +137,8 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
|
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
|
||||||
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>Retake exam</button>}
|
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>}
|
||||||
<button className="btn" onClick={onExit}>Back to home</button>
|
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useTweaks } from '../contexts/TweaksContext'
|
||||||
|
import * as authApi from '../api/authApi'
|
||||||
|
import type { ThemeMode, FontSize, FeedbackTweak } from '../types'
|
||||||
|
|
||||||
|
function Seg<K extends string>({ value, options, onChange }: {
|
||||||
|
value: K
|
||||||
|
options: { value: K; label: string }[]
|
||||||
|
onChange: (v: K) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="tweak-control">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className={`tweak-seg${value === opt.value ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { user, refreshUser } = useAuth()
|
||||||
|
const { tweaks, updateTweak } = useTweaks()
|
||||||
|
|
||||||
|
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: t('workspace.displayNameRequired') })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSavingName(true)
|
||||||
|
setNameMsg(null)
|
||||||
|
try {
|
||||||
|
await authApi.updateProfile({ displayName: trimmed })
|
||||||
|
setNameMsg({ ok: true, text: t('workspace.displayNameUpdated') })
|
||||||
|
await refreshUser()
|
||||||
|
} catch (e) {
|
||||||
|
setNameMsg({ ok: false, text: (e as Error).message })
|
||||||
|
} finally {
|
||||||
|
setSavingName(false)
|
||||||
|
}
|
||||||
|
}, [displayName, refreshUser, t])
|
||||||
|
|
||||||
|
const changePassword = useCallback(async () => {
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setPasswordMsg({ ok: false, text: t('workspace.passwordMinLength') })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordMsg({ ok: false, text: t('workspace.passwordsDoNotMatch') })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSavingPassword(true)
|
||||||
|
setPasswordMsg(null)
|
||||||
|
try {
|
||||||
|
await authApi.changePassword({ currentPassword, newPassword })
|
||||||
|
setPasswordMsg({ ok: true, text: t('workspace.passwordChanged') })
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
} catch (e) {
|
||||||
|
setPasswordMsg({ ok: false, text: (e as Error).message })
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false)
|
||||||
|
}
|
||||||
|
}, [currentPassword, newPassword, confirmPassword, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace-section fade-in">
|
||||||
|
<div className="workspace-section-header">
|
||||||
|
<div>
|
||||||
|
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
|
||||||
|
<h2 className="workspace-section-title">{t('workspace.settings')}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.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">{t('workspace.displayNameLabel')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.displayNameDesc')}</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">{t('workspace.emailLabel')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.emailDesc')}</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 ? t('workspace.saving') : t('workspace.saveProfile')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.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">{t('workspace.currentPassword')}</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">{t('workspace.newPassword')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.newPasswordDesc')}</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">{t('workspace.confirmNewPassword')}</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 ? t('workspace.changing') : t('workspace.changePassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.language')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel-body workspace-settings-form">
|
||||||
|
<div className="workspace-setting-row">
|
||||||
|
<label className="workspace-setting-label">
|
||||||
|
<span className="workspace-setting-title">{t('workspace.languageLabel')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.languageDesc')}</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void i18n.changeLanguage('en')}
|
||||||
|
style={{ fontWeight: i18n.language === 'en' ? 700 : 400, fontSize: 13 }}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void i18n.changeLanguage('nl')}
|
||||||
|
style={{ fontWeight: i18n.language === 'nl' ? 700 : 400, fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Nederlands
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.appearance')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel-body workspace-settings-form">
|
||||||
|
<div className="workspace-setting-row">
|
||||||
|
<label className="workspace-setting-label">
|
||||||
|
<span className="workspace-setting-title">{t('workspace.themeLabel')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.themeDesc')}</span>
|
||||||
|
</label>
|
||||||
|
<Seg
|
||||||
|
value={tweaks.theme}
|
||||||
|
options={[
|
||||||
|
{ value: 'light' as ThemeMode, label: t('tweaks.light') },
|
||||||
|
{ value: 'dark' as ThemeMode, label: t('tweaks.dark') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updateTweak('theme', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-setting-row">
|
||||||
|
<label className="workspace-setting-label">
|
||||||
|
<span className="workspace-setting-title">{t('workspace.textSizeLabel')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.textSizeDesc')}</span>
|
||||||
|
</label>
|
||||||
|
<Seg
|
||||||
|
value={tweaks.fontSize}
|
||||||
|
options={[
|
||||||
|
{ value: 'small' as FontSize, label: t('tweaks.small') },
|
||||||
|
{ value: 'medium' as FontSize, label: t('tweaks.medium') },
|
||||||
|
{ value: 'large' as FontSize, label: t('tweaks.large') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updateTweak('fontSize', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.defaultFeedback')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel-body workspace-settings-form">
|
||||||
|
<div className="workspace-setting-row">
|
||||||
|
<label className="workspace-setting-label">
|
||||||
|
<span className="workspace-setting-title">{t('workspace.defaultFeedbackLabel')}</span>
|
||||||
|
<span className="workspace-setting-desc">{t('workspace.defaultFeedbackDesc')}</span>
|
||||||
|
</label>
|
||||||
|
<Seg
|
||||||
|
value={tweaks.feedbackMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'auto' as FeedbackTweak, label: t('tweaks.auto') },
|
||||||
|
{ value: 'immediate' as FeedbackTweak, label: t('tweaks.immediate') },
|
||||||
|
{ value: 'end' as FeedbackTweak, label: t('tweaks.end') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updateTweak('feedbackMode', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { setToken } from '../utils/api'
|
||||||
|
import * as setupApi from '../api/setupApi'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SetupWizard({ onComplete }: Readonly<Props>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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(t('setup.validEmailRequired'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!displayName.trim()) {
|
||||||
|
setError(t('setup.displayNameRequired'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError(t('setup.passwordMinLength'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError(t('setup.passwordsDoNotMatch'))
|
||||||
|
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 setupApi.completeSetup({
|
||||||
|
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 : t('setup.setupFailed'),
|
||||||
|
)
|
||||||
|
} 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">{t('setup.welcome')}</h1>
|
||||||
|
<p className="setup-subtitle">
|
||||||
|
{t('setup.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="setup-steps">
|
||||||
|
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
|
||||||
|
<span className="setup-step-num">1</span>
|
||||||
|
<span className="setup-step-label">{t('setup.adminAccount')}</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">{t('setup.emailSettings')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<div className="auth-field">
|
||||||
|
<label className="auth-label" htmlFor="setup-email">
|
||||||
|
{t('setup.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">
|
||||||
|
{t('setup.displayName')}
|
||||||
|
</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">
|
||||||
|
{t('setup.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">
|
||||||
|
{t('setup.confirmPassword')}
|
||||||
|
</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 }}
|
||||||
|
>
|
||||||
|
{t('setup.continue')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)}>
|
||||||
|
<p className="setup-hint">
|
||||||
|
{t('setup.setupHint1')}
|
||||||
|
</p>
|
||||||
|
<p className="setup-hint">
|
||||||
|
{t('setup.setupHint2')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="auth-field">
|
||||||
|
<label className="auth-label" htmlFor="smtp-host">
|
||||||
|
{t('setup.smtpHost')}
|
||||||
|
</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">
|
||||||
|
{t('setup.smtpPort')}
|
||||||
|
</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">
|
||||||
|
{t('setup.smtpUsername')}
|
||||||
|
</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">
|
||||||
|
{t('setup.smtpPassword')}
|
||||||
|
</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">
|
||||||
|
{t('setup.fromAddress')}
|
||||||
|
</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">
|
||||||
|
{t('setup.security')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="smtp-secure"
|
||||||
|
className="input"
|
||||||
|
value={smtpSecure}
|
||||||
|
onChange={(e) => setSmtpSecure(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="auto">{t('setup.autoSsl')}</option>
|
||||||
|
<option value="true">{t('setup.sslTls')}</option>
|
||||||
|
<option value="false">{t('setup.noneStarttls')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="auth-field">
|
||||||
|
<label className="auth-label" htmlFor="app-url">
|
||||||
|
{t('setup.appUrl')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="app-url"
|
||||||
|
className="input"
|
||||||
|
type="url"
|
||||||
|
value={appUrl}
|
||||||
|
onChange={(e) => setAppUrl(e.target.value)}
|
||||||
|
placeholder="https://brainwave.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setup-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
>
|
||||||
|
{t('setup.back')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary auth-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? t('setup.settingUp') : t('setup.completeSetup')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tweaks: Tweaks
|
|
||||||
update: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function Seg({ tkey, options, tweaks, update }: {
|
|
||||||
tkey: keyof Tweaks
|
|
||||||
options: { value: string; label: string }[]
|
|
||||||
tweaks: Tweaks
|
|
||||||
update: Props['update']
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="tweak-control">
|
|
||||||
{options.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
className={`tweak-seg${tweaks[tkey] === opt.value ? ' active' : ''}`}
|
|
||||||
onClick={() => update(tkey, opt.value as never)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TweaksPanel({ tweaks, update, onClose }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="tweaks-panel">
|
|
||||||
<div className="tweaks-header">
|
|
||||||
<span>Tweaks</span>
|
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onClose} style={{ padding: '2px 6px' }}>×</button>
|
|
||||||
</div>
|
|
||||||
<div className="tweaks-body">
|
|
||||||
<div className="tweak-row">
|
|
||||||
<div className="tweak-label">Theme</div>
|
|
||||||
<Seg tkey="theme" options={[{ value: 'light' as ThemeMode, label: 'Light' }, { value: 'dark' as ThemeMode, label: 'Dark' }]} tweaks={tweaks} update={update} />
|
|
||||||
</div>
|
|
||||||
<div className="tweak-row">
|
|
||||||
<div className="tweak-label">Text size</div>
|
|
||||||
<Seg tkey="fontSize" options={[{ value: 'small' as FontSize, label: 'S' }, { value: 'medium' as FontSize, label: 'M' }, { value: 'large' as FontSize, label: 'L' }]} tweaks={tweaks} update={update} />
|
|
||||||
</div>
|
|
||||||
<div className="tweak-row">
|
|
||||||
<div className="tweak-label">Feedback</div>
|
|
||||||
<Seg tkey="feedbackMode" options={[
|
|
||||||
{ value: 'auto' as FeedbackTweak, label: 'Auto' },
|
|
||||||
{ value: 'immediate' as FeedbackTweak, label: 'Immediate' },
|
|
||||||
{ value: 'end' as FeedbackTweak, label: 'End' },
|
|
||||||
]} tweaks={tweaks} update={update} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate, useParams, Navigate } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import * as examApi from '../api/examApi'
|
||||||
|
import * as resultApi from '../api/resultApi'
|
||||||
|
import type { Exam } from '../types'
|
||||||
|
import MyExams from './MyExams'
|
||||||
|
import MyResults from './MyResults'
|
||||||
|
import Settings from './Settings'
|
||||||
|
|
||||||
|
type TabKey = 'exams' | 'results' | 'settings'
|
||||||
|
|
||||||
|
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
|
||||||
|
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
|
||||||
|
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
|
||||||
|
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Workspace({ onLoadExam }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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 examApi.listExams()
|
||||||
|
setExamCount(exams.length)
|
||||||
|
const results = await resultApi.listResults()
|
||||||
|
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">
|
||||||
|
{TAB_KEYS.map((tabItem) => (
|
||||||
|
<button
|
||||||
|
key={tabItem.key}
|
||||||
|
className={`workspace-nav-item${activeTab === tabItem.key ? ' active' : ''}`}
|
||||||
|
onClick={() => setTab(tabItem.key)}
|
||||||
|
>
|
||||||
|
<span className="workspace-nav-icon">{tabItem.icon}</span>
|
||||||
|
<span className="workspace-nav-label">{t(tabItem.labelKey)}</span>
|
||||||
|
{tabItem.key === 'exams' && examCount > 0 && (
|
||||||
|
<span className="workspace-nav-badge">{examCount}</span>
|
||||||
|
)}
|
||||||
|
{tabItem.key === 'results' && resultCount > 0 && (
|
||||||
|
<span className="workspace-nav-badge">{resultCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="workspace-main">
|
||||||
|
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
||||||
|
{activeTab === 'results' && <MyResults />}
|
||||||
|
{activeTab === 'settings' && <Settings />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { apiFetch, setToken, clearToken, isAuthenticated } from '../utils/api'
|
import { setToken, clearToken, isAuthenticated } from '../utils/api'
|
||||||
|
import * as authApi from '../api/authApi'
|
||||||
|
import * as setupApi from '../api/setupApi'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -12,6 +14,8 @@ export interface User {
|
|||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: User | null
|
user: User | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
needsSetup: boolean
|
||||||
|
setUser: (user: User | null) => void
|
||||||
login: (email: string, password: string) => Promise<void>
|
login: (email: string, password: string) => Promise<void>
|
||||||
register: (email: string, password: string, displayName: string) => Promise<string>
|
register: (email: string, password: string, displayName: string) => Promise<string>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
@@ -23,16 +27,27 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [needsSetup, setNeedsSetup] = useState(false)
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
if (!isAuthenticated()) {
|
|
||||||
setUser(null)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
|
// Always check setup status first
|
||||||
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
|
const setupStatus = await setupApi.getSetupStatus()
|
||||||
|
if (!setupStatus.completed) {
|
||||||
|
setNeedsSetup(true)
|
||||||
|
setUser(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setNeedsSetup(false)
|
||||||
|
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
setUser(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await authApi.getMe()
|
||||||
|
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: (data.role as 'user' | 'admin') ?? 'user' })
|
||||||
} catch {
|
} catch {
|
||||||
clearToken()
|
clearToken()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
@@ -46,20 +61,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [refreshUser])
|
}, [refreshUser])
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const data = await apiFetch<{ token: string; user: User }>('/api/auth/login', {
|
const data = await authApi.login({ email, password })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
})
|
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser({ ...data.user, role: data.user.role ?? 'user' })
|
setUser({ ...data.user, role: (data.user.role as 'user' | 'admin') ?? 'user' })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
|
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
|
||||||
const data = await apiFetch<{ message: string }>('/api/auth/register', {
|
await authApi.register({ email, password, displayName })
|
||||||
method: 'POST',
|
return 'Registration successful! Please check your email to verify your account.'
|
||||||
body: JSON.stringify({ email, password, displayName }),
|
|
||||||
})
|
|
||||||
return data.message
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
@@ -68,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
<AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||||
|
import type { Tweaks, ThemeMode, FontSize, FeedbackTweak } from '../types'
|
||||||
|
|
||||||
|
const TWEAKS_KEY = 'brainwave.tweaks'
|
||||||
|
|
||||||
|
const DEFAULT_TWEAKS: Tweaks = {
|
||||||
|
theme: 'light',
|
||||||
|
fontSize: 'medium',
|
||||||
|
feedbackMode: 'auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSystemTheme = (): ThemeMode =>
|
||||||
|
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
|
||||||
|
const loadTweaks = (): Tweaks => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TWEAKS_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return { ...DEFAULT_TWEAKS, theme: getSystemTheme() }
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Tweaks>
|
||||||
|
return {
|
||||||
|
theme: (parsed.theme as ThemeMode) ?? getSystemTheme(),
|
||||||
|
fontSize: (parsed.fontSize as FontSize) ?? DEFAULT_TWEAKS.fontSize,
|
||||||
|
feedbackMode: (parsed.feedbackMode as FeedbackTweak) ?? DEFAULT_TWEAKS.feedbackMode,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_TWEAKS, theme: getSystemTheme() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTweaks = (tweaks: Tweaks): void => {
|
||||||
|
try { localStorage.setItem(TWEAKS_KEY, JSON.stringify(tweaks)) } catch { /* quota */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TweaksContextValue {
|
||||||
|
tweaks: Tweaks
|
||||||
|
updateTweak: <K extends keyof Tweaks>(key: K, value: Tweaks[K]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TweaksContext = createContext<TweaksContextValue | null>(null)
|
||||||
|
|
||||||
|
export function TweaksProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [tweaks, setTweaks] = useState<Tweaks>(loadTweaks)
|
||||||
|
|
||||||
|
const updateTweak = useCallback(<K extends keyof Tweaks>(key: K, value: Tweaks[K]) => {
|
||||||
|
setTweaks((prev) => {
|
||||||
|
const next = { ...prev, [key]: value }
|
||||||
|
saveTweaks(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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.theme, tweaks.fontSize])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweaksContext.Provider value={{ tweaks, updateTweak }}>
|
||||||
|
{children}
|
||||||
|
</TweaksContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTweaks(): TweaksContextValue {
|
||||||
|
const ctx = useContext(TweaksContext)
|
||||||
|
if (!ctx) throw new Error('useTweaks must be used within TweaksProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "BrainWave",
|
||||||
|
"appTagline": "practice exam platform",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"close": "Close",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"back": "Back",
|
||||||
|
"backToHome": "Back to home",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"pts": "pts",
|
||||||
|
"pt": "pt",
|
||||||
|
"questions": "questions",
|
||||||
|
"question": "question"
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"eyebrow": "BrainWave · practice exam platform",
|
||||||
|
"title": "Upload an exam. ",
|
||||||
|
"titleEmphasis": "Practice anything.",
|
||||||
|
"subtitle": "Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.",
|
||||||
|
"dropzoneTitle": "Drop your exam JSON here",
|
||||||
|
"dropzoneSub": "or click to browse · max 5 MB",
|
||||||
|
"downloadInstructions": "Download Instructions — How to create your own exam JSON",
|
||||||
|
"downloadInstructionsIcon": "↓",
|
||||||
|
"supportedTypes": "Supported types",
|
||||||
|
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order",
|
||||||
|
"trySample": "Try a sample",
|
||||||
|
"resumeTitle": "Resume in-progress exam",
|
||||||
|
"resumeProgress": "{{answered}}/{{total}} answered",
|
||||||
|
"resumeButton": "Resume →",
|
||||||
|
"pastAttempts": "Past attempts",
|
||||||
|
"clearHistory": "Clear history",
|
||||||
|
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
|
||||||
|
"review": "Review",
|
||||||
|
"loadError": "Could not load: {{message}}",
|
||||||
|
"loadSampleError": "Could not load sample: {{message}}",
|
||||||
|
"clearHistoryConfirm": "Clear all past attempts?"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signOut": "Sign Out",
|
||||||
|
"register": "Register",
|
||||||
|
"welcomeBack": "Welcome back",
|
||||||
|
"createAccount": "Create account",
|
||||||
|
"signInSubtitle": "Sign in to access your saved exams and results",
|
||||||
|
"registerSubtitle": "Create an account to save your progress",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"forgotPassword": "Forgot your password?",
|
||||||
|
"signingIn": "Signing in…",
|
||||||
|
"creatingAccount": "Creating account…",
|
||||||
|
"loginFailed": "Login failed",
|
||||||
|
"registrationFailed": "Registration failed",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"myWorkspace": "My Workspace",
|
||||||
|
"adminDashboard": "Admin Dashboard",
|
||||||
|
"userMenu": "User menu",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"resetPasswordDesc": "Enter your email address and we'll send you a link to reset your password.",
|
||||||
|
"checkYourEmail": "Check Your Email",
|
||||||
|
"setNewPassword": "Set New Password",
|
||||||
|
"enterNewPassword": "Enter your new password below.",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"resetting": "Resetting…",
|
||||||
|
"passwordUpdated": "Password Updated",
|
||||||
|
"passwordUpdatedDesc": "Your password has been reset successfully. You can now sign in with your new password.",
|
||||||
|
"requestFailed": "Request failed",
|
||||||
|
"passwordResetFailed": "Password reset failed",
|
||||||
|
"sendResetLink": "Send Reset Link",
|
||||||
|
"sending": "Sending…",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
},
|
||||||
|
"exam": {
|
||||||
|
"submitExam": "Submit exam",
|
||||||
|
"exitToHome": "Exit to home",
|
||||||
|
"questionsLabel": "Questions",
|
||||||
|
"progressLabel": "Progress",
|
||||||
|
"progressText": "{{answered}} of {{total}} answered",
|
||||||
|
"questionHeader": "Question {{current}} / {{total}}",
|
||||||
|
"correct": "Correct",
|
||||||
|
"notQuite": "Not quite",
|
||||||
|
"answerLabel": "Answer:",
|
||||||
|
"previous": "← Previous",
|
||||||
|
"flagged": "⚑ Flagged",
|
||||||
|
"flagForReview": "⚐ Flag for review",
|
||||||
|
"checkAnswer": "Check answer",
|
||||||
|
"next": "Next →",
|
||||||
|
"elapsed": "Elapsed",
|
||||||
|
"remaining": "Remaining",
|
||||||
|
"pointsPossible": "Points possible",
|
||||||
|
"mode": "Mode",
|
||||||
|
"immediateFeedback": "Immediate feedback",
|
||||||
|
"gradeAtEnd": "Grade at end",
|
||||||
|
"notes": "Notes",
|
||||||
|
"notesPlaceholder": "Add short notes while you work…",
|
||||||
|
"author": "Author",
|
||||||
|
"navigateHint": "navigate",
|
||||||
|
"flagHint": "flag",
|
||||||
|
"selectHint": "select",
|
||||||
|
"exit": "Exit",
|
||||||
|
"untimed": "Untimed"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"reviewing": "Reviewing past attempt",
|
||||||
|
"examComplete": "Exam complete",
|
||||||
|
"scored": "You scored ",
|
||||||
|
"abovePassing": "Above the {{passing}}% passing mark.",
|
||||||
|
"belowPassing": "Below the {{passing}}% passing mark. Keep practicing.",
|
||||||
|
"points": "Points",
|
||||||
|
"correct": "Correct",
|
||||||
|
"time": "Time",
|
||||||
|
"byCategory": "By category",
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
|
"review": "Review",
|
||||||
|
"explanation": "Explanation",
|
||||||
|
"rubric": "Rubric (self-grade)",
|
||||||
|
"retakeExam": "Retake exam",
|
||||||
|
"backToHome": "Back to home"
|
||||||
|
},
|
||||||
|
"tweaks": {
|
||||||
|
"title": "Tweaks",
|
||||||
|
"theme": "Theme",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
|
"textSize": "Text size",
|
||||||
|
"small": "S",
|
||||||
|
"medium": "M",
|
||||||
|
"large": "L",
|
||||||
|
"feedback": "Feedback",
|
||||||
|
"auto": "Auto",
|
||||||
|
"immediate": "Immediate",
|
||||||
|
"end": "End"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"myWorkspace": "My Workspace",
|
||||||
|
"myExams": "My Exams",
|
||||||
|
"results": "Results",
|
||||||
|
"settings": "Settings",
|
||||||
|
"newExam": "New Exam",
|
||||||
|
"noSavedExams": "No saved exams yet",
|
||||||
|
"noSavedExamsDesc": "Upload an exam from the home page, or create a new one to get started.",
|
||||||
|
"createFirstExam": "Create Your First Exam",
|
||||||
|
"takeExam": "Take exam",
|
||||||
|
"noAttempts": "No attempts yet",
|
||||||
|
"noAttemptsDesc": "Take an exam to see your results here.",
|
||||||
|
"attempts": "attempt",
|
||||||
|
"attemptsPlural": "attempts",
|
||||||
|
"updated": "Updated",
|
||||||
|
"profile": "Profile",
|
||||||
|
"security": "Security",
|
||||||
|
"displayNameLabel": "Display Name",
|
||||||
|
"displayNameDesc": "Your name shown throughout the app",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailDesc": "Your email address",
|
||||||
|
"saveProfile": "Save Profile",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"displayNameUpdated": "Display name updated",
|
||||||
|
"displayNameRequired": "Display name is required (max 100 characters)",
|
||||||
|
"currentPassword": "Current Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"newPasswordDesc": "Minimum 8 characters",
|
||||||
|
"confirmNewPassword": "Confirm New Password",
|
||||||
|
"changePassword": "Change Password",
|
||||||
|
"changing": "Changing…",
|
||||||
|
"passwordChanged": "Password changed successfully",
|
||||||
|
"passwordMinLength": "New password must be at least 8 characters",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"language": "Language",
|
||||||
|
"languageLabel": "Language",
|
||||||
|
"languageDesc": "Select your preferred display language",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"themeLabel": "Theme",
|
||||||
|
"themeDesc": "Choose between light and dark mode",
|
||||||
|
"textSizeLabel": "Text size",
|
||||||
|
"textSizeDesc": "Adjust the base font size for readability",
|
||||||
|
"defaultFeedback": "Default Feedback",
|
||||||
|
"defaultFeedbackLabel": "Feedback mode",
|
||||||
|
"defaultFeedbackDesc": "Choose when to see answer feedback during exams"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"administration": "Administration",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"backToSite": "← Back to site",
|
||||||
|
"overview": "Overview",
|
||||||
|
"settings": "Settings",
|
||||||
|
"users": "Users",
|
||||||
|
"totalUsers": "Total Users",
|
||||||
|
"savedExams": "Saved Exams",
|
||||||
|
"examsTaken": "Exams Taken",
|
||||||
|
"avgScore": "Avg. Score",
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"noRecentActivity": "No recent activity yet",
|
||||||
|
"general": "General",
|
||||||
|
"siteName": "Site Name",
|
||||||
|
"siteNameDesc": "The name displayed throughout the application",
|
||||||
|
"maxUpload": "Max Upload Size (MB)",
|
||||||
|
"maxUploadDesc": "Maximum file size for exam JSON uploads",
|
||||||
|
"defaultTheme": "Default Theme",
|
||||||
|
"defaultThemeDesc": "Default theme for new visitors",
|
||||||
|
"accessControl": "Access Control",
|
||||||
|
"allowGuestMode": "Allow Guest Mode",
|
||||||
|
"allowGuestModeDesc": "Let unauthenticated users take exams without signing in",
|
||||||
|
"registrationEnabled": "Registration Enabled",
|
||||||
|
"registrationEnabledDesc": "Allow new users to create accounts",
|
||||||
|
"emailSmtp": "Email / SMTP",
|
||||||
|
"appUrl": "App URL",
|
||||||
|
"appUrlDesc": "Base URL used in email links (e.g. https://brainwave.example.com)",
|
||||||
|
"smtpHost": "SMTP Host",
|
||||||
|
"smtpHostDesc": "Hostname of your SMTP server",
|
||||||
|
"smtpPort": "SMTP Port",
|
||||||
|
"smtpPortDesc": "Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)",
|
||||||
|
"username": "Username",
|
||||||
|
"usernameDesc": "SMTP authentication username",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordDesc": "SMTP authentication password",
|
||||||
|
"fromAddress": "From Address",
|
||||||
|
"fromAddressDesc": "The sender name and address shown in emails",
|
||||||
|
"tlsMode": "TLS Mode",
|
||||||
|
"tlsModeDesc": "Auto detects from port (465→SSL). Use explicit override if needed.",
|
||||||
|
"auto": "Auto",
|
||||||
|
"forceTls": "Force TLS",
|
||||||
|
"off": "Off",
|
||||||
|
"sendTestEmail": "Send Test Email",
|
||||||
|
"sendTestEmailDesc": "Sends a test email using the settings above (uses draft values, no need to save first)",
|
||||||
|
"sendTest": "Send Test",
|
||||||
|
"sending": "Sending…",
|
||||||
|
"maintenance": "Maintenance",
|
||||||
|
"maintenanceMode": "Maintenance Mode",
|
||||||
|
"maintenanceModeDesc": "Take the site offline for non-admin users",
|
||||||
|
"maintenanceMessage": "Maintenance Message",
|
||||||
|
"maintenanceMessageDesc": "Message displayed to visitors during maintenance",
|
||||||
|
"discardChanges": "Discard Changes",
|
||||||
|
"saveSettings": "Save Settings",
|
||||||
|
"settingsSaved": "Settings saved successfully",
|
||||||
|
"searchByNameOrEmail": "Search by name or email…",
|
||||||
|
"search": "Search",
|
||||||
|
"user": "User",
|
||||||
|
"role": "Role",
|
||||||
|
"verified": "Verified",
|
||||||
|
"exams": "Exams",
|
||||||
|
"joined": "Joined",
|
||||||
|
"actions": "Actions",
|
||||||
|
"noUsersFound": "No users found",
|
||||||
|
"testEmailSent": "Test email sent successfully! Check your inbox."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"back": "← Back",
|
||||||
|
"examTitlePlaceholder": "Exam title",
|
||||||
|
"unsavedChanges": "Unsaved changes",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"createExam": "Create Exam",
|
||||||
|
"save": "Save",
|
||||||
|
"examCreated": "Exam created successfully",
|
||||||
|
"examSaved": "Exam saved",
|
||||||
|
"questionsLabel": "Questions",
|
||||||
|
"addQuestion": "Add Question",
|
||||||
|
"noQuestions": "No questions yet",
|
||||||
|
"noQuestionsDesc": "Select a question type from the sidebar to add your first question.",
|
||||||
|
"questionEditor": "Question Editor",
|
||||||
|
"prompt": "Prompt",
|
||||||
|
"promptPlaceholder": "Enter the question prompt…",
|
||||||
|
"categoryOptional": "Category (optional)",
|
||||||
|
"categoryPlaceholder": "e.g. Algebra",
|
||||||
|
"points": "Points",
|
||||||
|
"explanationOptional": "Explanation (optional)",
|
||||||
|
"explanationPlaceholder": "Explanation shown after grading…",
|
||||||
|
"choices": "Choices",
|
||||||
|
"markAsCorrect": "Mark as correct",
|
||||||
|
"addChoice": "+ Add Choice",
|
||||||
|
"remove": "Remove",
|
||||||
|
"multipleChoice": "Multiple Choice",
|
||||||
|
"multipleSelect": "Multiple Select",
|
||||||
|
"shortAnswer": "Short Answer",
|
||||||
|
"translation": "Translation",
|
||||||
|
"mathInput": "Math Input",
|
||||||
|
"essay": "Essay",
|
||||||
|
"match": "Match",
|
||||||
|
"order": "Order",
|
||||||
|
"correctAnswer": "Correct Answer",
|
||||||
|
"correctAnswerPlaceholder": "Enter the correct answer",
|
||||||
|
"acceptedForms": "Accepted Forms (optional alternatives)",
|
||||||
|
"alternativeAnswer": "Alternative answer",
|
||||||
|
"addAlternative": "+ Add Alternative",
|
||||||
|
"caseSensitive": "Case sensitive",
|
||||||
|
"ignorePunctuation": "Ignore punctuation",
|
||||||
|
"ignoreAccents": "Ignore accents",
|
||||||
|
"minWords": "Min Words",
|
||||||
|
"rubricCriteria": "Rubric Criteria",
|
||||||
|
"criterion": "Criterion",
|
||||||
|
"addCriterion": "+ Add Criterion",
|
||||||
|
"leftColumn": "Left Column",
|
||||||
|
"rightColumn": "Right Column",
|
||||||
|
"addLeft": "+ Add Left",
|
||||||
|
"addRight": "+ Add Right",
|
||||||
|
"correctPairings": "Correct Pairings",
|
||||||
|
"select": "— Select —",
|
||||||
|
"items": "Items (drag not implemented, use arrows to reorder)",
|
||||||
|
"itemPlaceholder": "Item {{index}}",
|
||||||
|
"correctOrder": "Correct Order",
|
||||||
|
"correctOrderHint": "The correct order is the order shown above. Reorder items to set the correct sequence.",
|
||||||
|
"setCurrentOrder": "Set Current Order as Correct",
|
||||||
|
"examTitleRequired": "Exam title is required",
|
||||||
|
"atLeastOneQuestion": "Add at least one question",
|
||||||
|
"sourceLanguage": "Source Language",
|
||||||
|
"sourceLanguagePlaceholder": "e.g. English",
|
||||||
|
"targetLanguage": "Target Language",
|
||||||
|
"targetLanguagePlaceholder": "e.g. Spanish",
|
||||||
|
"choicesCheckCorrect": "Choices (check correct answers)",
|
||||||
|
"choicePlaceholder": "Choice {{id}}",
|
||||||
|
"addItem": "+ Add Item",
|
||||||
|
"leftItemPlaceholder": "Left item {{index}}",
|
||||||
|
"rightItemPlaceholder": "Right item {{index}}"
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"welcome": "Welcome to BrainWave",
|
||||||
|
"subtitle": "Let's get your instance set up. This only takes a minute.",
|
||||||
|
"adminAccount": "Admin Account",
|
||||||
|
"emailSettings": "Email Settings",
|
||||||
|
"email": "Email",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"continue": "Continue →",
|
||||||
|
"smtpHost": "SMTP Host",
|
||||||
|
"smtpPort": "SMTP Port",
|
||||||
|
"smtpUsername": "SMTP Username",
|
||||||
|
"smtpPassword": "SMTP Password",
|
||||||
|
"fromAddress": "From Address",
|
||||||
|
"security": "Security",
|
||||||
|
"appUrl": "App URL",
|
||||||
|
"back": "← Back",
|
||||||
|
"completeSetup": "Complete Setup",
|
||||||
|
"settingUp": "Setting up…",
|
||||||
|
"setupHint1": "Configure your outgoing email server so BrainWave can send verification and password-reset emails.",
|
||||||
|
"setupHint2": "You can leave these blank and configure them later in the admin dashboard.",
|
||||||
|
"autoSsl": "Auto (use SSL on port 465)",
|
||||||
|
"sslTls": "SSL/TLS",
|
||||||
|
"noneStarttls": "None / STARTTLS",
|
||||||
|
"validEmailRequired": "Please enter a valid email address",
|
||||||
|
"displayNameRequired": "Display name is required",
|
||||||
|
"passwordMinLength": "Password must be at least 8 characters",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"setupFailed": "Setup failed. Please try again."
|
||||||
|
},
|
||||||
|
"verify": {
|
||||||
|
"verifying": "Verifying your email…",
|
||||||
|
"pleaseWait": "Please wait while we confirm your email address.",
|
||||||
|
"emailVerified": "Email Verified",
|
||||||
|
"signIn": "Sign In →",
|
||||||
|
"verificationFailed": "Verification Failed",
|
||||||
|
"backToHome": "Back to Home",
|
||||||
|
"noToken": "No verification token found."
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"enterEmail": "Enter your email address and we'll send you a link to reset your password.",
|
||||||
|
"sendResetLink": "Send Reset Link",
|
||||||
|
"sending": "Sending…",
|
||||||
|
"backToSignIn": "← Back to sign in",
|
||||||
|
"checkEmail": "Check Your Email",
|
||||||
|
"setNewPassword": "Set New Password",
|
||||||
|
"enterNewPassword": "Enter your new password below.",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"resetting": "Resetting…",
|
||||||
|
"resetPasswordBtn": "Reset Password",
|
||||||
|
"passwordUpdated": "Password Updated",
|
||||||
|
"passwordUpdatedDesc": "Your password has been reset successfully. You can now sign in with your new password.",
|
||||||
|
"requestFailed": "Request failed",
|
||||||
|
"passwordResetFailed": "Password reset failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
|
import en from './en.json'
|
||||||
|
import nl from './nl.json'
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
en: { translation: en },
|
||||||
|
nl: { translation: nl },
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "BrainWave",
|
||||||
|
"appTagline": "oefenexamenplatform",
|
||||||
|
"cancel": "Annuleren",
|
||||||
|
"confirm": "Bevestigen",
|
||||||
|
"close": "Sluiten",
|
||||||
|
"save": "Opslaan",
|
||||||
|
"delete": "Verwijderen",
|
||||||
|
"edit": "Bewerken",
|
||||||
|
"back": "Terug",
|
||||||
|
"backToHome": "Terug naar home",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"error": "Fout",
|
||||||
|
"success": "Succes",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nee",
|
||||||
|
"pts": "pnt",
|
||||||
|
"pt": "pnt",
|
||||||
|
"questions": "vragen",
|
||||||
|
"question": "vraag"
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"eyebrow": "BrainWave · oefenexamenplatform",
|
||||||
|
"title": "Upload een examen. ",
|
||||||
|
"titleEmphasis": "Oefen alles.",
|
||||||
|
"subtitle": "Sleep een JSON-bestand naar binnen om een oefenexamen te starten — wiskunde met volledige LaTeX, taalvertaling, meerkeuze, koppelen, rangschikken, opstellen, en meer. Voortgang wordt automatisch opgeslagen.",
|
||||||
|
"dropzoneTitle": "Sleep je examen-JSON hierheen",
|
||||||
|
"dropzoneSub": "of klik om te bladeren · max 5 MB",
|
||||||
|
"downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON",
|
||||||
|
"downloadInstructionsIcon": "↓",
|
||||||
|
"supportedTypes": "Ondersteunde types",
|
||||||
|
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken",
|
||||||
|
"trySample": "Probeer een voorbeeld",
|
||||||
|
"resumeTitle": "Hervat lopend examen",
|
||||||
|
"resumeProgress": "{{answered}}/{{total}} beantwoord",
|
||||||
|
"resumeButton": "Hervat →",
|
||||||
|
"pastAttempts": "Eerdere pogingen",
|
||||||
|
"clearHistory": "Wis geschiedenis",
|
||||||
|
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
|
||||||
|
"review": "Nakijken",
|
||||||
|
"loadError": "Kon niet laden: {{message}}",
|
||||||
|
"loadSampleError": "Kon voorbeeld niet laden: {{message}}",
|
||||||
|
"clearHistoryConfirm": "Alle eerdere pogingen wissen?"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Inloggen",
|
||||||
|
"signOut": "Uitloggen",
|
||||||
|
"register": "Registreren",
|
||||||
|
"welcomeBack": "Welkom terug",
|
||||||
|
"createAccount": "Account aanmaken",
|
||||||
|
"signInSubtitle": "Log in om toegang te krijgen tot je opgeslagen examens en resultaten",
|
||||||
|
"registerSubtitle": "Maak een account aan om je voortgang op te slaan",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"confirmPassword": "Wachtwoord bevestigen",
|
||||||
|
"displayName": "Weergavenaam",
|
||||||
|
"forgotPassword": "Wachtwoord vergeten?",
|
||||||
|
"signingIn": "Inloggen…",
|
||||||
|
"creatingAccount": "Account aanmaken…",
|
||||||
|
"loginFailed": "Inloggen mislukt",
|
||||||
|
"registrationFailed": "Registratie mislukt",
|
||||||
|
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
|
||||||
|
"myWorkspace": "Mijn Werkruimte",
|
||||||
|
"adminDashboard": "Admin Dashboard",
|
||||||
|
"userMenu": "Gebruikersmenu",
|
||||||
|
"resetPassword": "Wachtwoord resetten",
|
||||||
|
"resetPasswordDesc": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
|
||||||
|
"checkYourEmail": "Controleer je e-mail",
|
||||||
|
"setNewPassword": "Nieuw wachtwoord instellen",
|
||||||
|
"enterNewPassword": "Voer hieronder je nieuwe wachtwoord in.",
|
||||||
|
"newPassword": "Nieuw wachtwoord",
|
||||||
|
"resetting": "Resetten…",
|
||||||
|
"passwordUpdated": "Wachtwoord bijgewerkt",
|
||||||
|
"passwordUpdatedDesc": "Je wachtwoord is succesvol gereset. Je kunt nu inloggen met je nieuwe wachtwoord.",
|
||||||
|
"requestFailed": "Verzoek mislukt",
|
||||||
|
"passwordResetFailed": "Wachtwoordreset mislukt",
|
||||||
|
"sendResetLink": "Resetlink versturen",
|
||||||
|
"sending": "Versturen…",
|
||||||
|
"backToSignIn": "Terug naar inloggen"
|
||||||
|
},
|
||||||
|
"exam": {
|
||||||
|
"submitExam": "Examen inleveren",
|
||||||
|
"exitToHome": "Terug naar home",
|
||||||
|
"questionsLabel": "Vragen",
|
||||||
|
"progressLabel": "Voortgang",
|
||||||
|
"progressText": "{{answered}} van {{total}} beantwoord",
|
||||||
|
"questionHeader": "Vraag {{current}} / {{total}}",
|
||||||
|
"correct": "Correct",
|
||||||
|
"notQuite": "Niet helemaal",
|
||||||
|
"answerLabel": "Antwoord:",
|
||||||
|
"previous": "← Vorige",
|
||||||
|
"flagged": "⚑ Gemarkeerd",
|
||||||
|
"flagForReview": "⚐ Markeer voor controle",
|
||||||
|
"checkAnswer": "Controleer antwoord",
|
||||||
|
"next": "Volgende →",
|
||||||
|
"elapsed": "Verstreken",
|
||||||
|
"remaining": "Resterend",
|
||||||
|
"pointsPossible": "Mogelijke punten",
|
||||||
|
"mode": "Modus",
|
||||||
|
"immediateFeedback": "Directe feedback",
|
||||||
|
"gradeAtEnd": "Beoordeling aan het eind",
|
||||||
|
"notes": "Notities",
|
||||||
|
"notesPlaceholder": "Voeg korte notities toe terwijl je werkt…",
|
||||||
|
"author": "Auteur",
|
||||||
|
"navigateHint": "navigeren",
|
||||||
|
"flagHint": "markeren",
|
||||||
|
"selectHint": "selecteren",
|
||||||
|
"exit": "Afsluiten",
|
||||||
|
"untimed": "Ongetimed"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"reviewing": "Eerdere poging bekijken",
|
||||||
|
"examComplete": "Examen voltooid",
|
||||||
|
"scored": "Je score: ",
|
||||||
|
"abovePassing": "Boven de {{passing}}% slaaggrens.",
|
||||||
|
"belowPassing": "Onder de {{passing}}% slaaggrens. Blijf oefenen.",
|
||||||
|
"points": "Punten",
|
||||||
|
"correct": "Correct",
|
||||||
|
"time": "Tijd",
|
||||||
|
"byCategory": "Per categorie",
|
||||||
|
"uncategorized": "Geen categorie",
|
||||||
|
"review": "Nakijken",
|
||||||
|
"explanation": "Uitleg",
|
||||||
|
"rubric": "Rubric (zelfbeoordeling)",
|
||||||
|
"retakeExam": "Examen opnieuw maken",
|
||||||
|
"backToHome": "Terug naar home"
|
||||||
|
},
|
||||||
|
"tweaks": {
|
||||||
|
"title": "Instellingen",
|
||||||
|
"theme": "Thema",
|
||||||
|
"light": "Licht",
|
||||||
|
"dark": "Donker",
|
||||||
|
"textSize": "Tekstgrootte",
|
||||||
|
"small": "K",
|
||||||
|
"medium": "M",
|
||||||
|
"large": "G",
|
||||||
|
"feedback": "Feedback",
|
||||||
|
"auto": "Auto",
|
||||||
|
"immediate": "Direct",
|
||||||
|
"end": "Einde"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"myWorkspace": "Mijn Werkruimte",
|
||||||
|
"myExams": "Mijn Examens",
|
||||||
|
"results": "Resultaten",
|
||||||
|
"settings": "Instellingen",
|
||||||
|
"newExam": "Nieuw Examen",
|
||||||
|
"noSavedExams": "Nog geen opgeslagen examens",
|
||||||
|
"noSavedExamsDesc": "Upload een examen vanaf de homepagina, of maak een nieuw examen aan om te beginnen.",
|
||||||
|
"createFirstExam": "Maak je eerste examen",
|
||||||
|
"takeExam": "Examen maken",
|
||||||
|
"noAttempts": "Nog geen pogingen",
|
||||||
|
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
|
||||||
|
"attempts": "poging",
|
||||||
|
"attemptsPlural": "pogingen",
|
||||||
|
"updated": "Bijgewerkt",
|
||||||
|
"profile": "Profiel",
|
||||||
|
"security": "Beveiliging",
|
||||||
|
"displayNameLabel": "Weergavenaam",
|
||||||
|
"displayNameDesc": "Je naam die door de app wordt getoond",
|
||||||
|
"emailLabel": "E-mail",
|
||||||
|
"emailDesc": "Je e-mailadres",
|
||||||
|
"saveProfile": "Profiel opslaan",
|
||||||
|
"saving": "Opslaan…",
|
||||||
|
"displayNameUpdated": "Weergavenaam bijgewerkt",
|
||||||
|
"displayNameRequired": "Weergavenaam is verplicht (max 100 tekens)",
|
||||||
|
"currentPassword": "Huidig wachtwoord",
|
||||||
|
"newPassword": "Nieuw wachtwoord",
|
||||||
|
"newPasswordDesc": "Minimaal 8 tekens",
|
||||||
|
"confirmNewPassword": "Bevestig nieuw wachtwoord",
|
||||||
|
"changePassword": "Wachtwoord wijzigen",
|
||||||
|
"changing": "Wijzigen…",
|
||||||
|
"passwordChanged": "Wachtwoord succesvol gewijzigd",
|
||||||
|
"passwordMinLength": "Nieuw wachtwoord moet minimaal 8 tekens bevatten",
|
||||||
|
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
|
||||||
|
"language": "Taal",
|
||||||
|
"languageLabel": "Taal",
|
||||||
|
"languageDesc": "Selecteer je voorkeurstaal voor de interface",
|
||||||
|
"appearance": "Weergave",
|
||||||
|
"themeLabel": "Thema",
|
||||||
|
"themeDesc": "Kies tussen licht en donker",
|
||||||
|
"textSizeLabel": "Tekstgrootte",
|
||||||
|
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
|
||||||
|
"defaultFeedback": "Standaard Feedback",
|
||||||
|
"defaultFeedbackLabel": "Feedbackmodus",
|
||||||
|
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"administration": "Beheer",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"backToSite": "← Terug naar site",
|
||||||
|
"overview": "Overzicht",
|
||||||
|
"settings": "Instellingen",
|
||||||
|
"users": "Gebruikers",
|
||||||
|
"totalUsers": "Totaal gebruikers",
|
||||||
|
"savedExams": "Opgeslagen examens",
|
||||||
|
"examsTaken": "Gemaakte examens",
|
||||||
|
"avgScore": "Gem. score",
|
||||||
|
"recentActivity": "Recente activiteit",
|
||||||
|
"noRecentActivity": "Nog geen recente activiteit",
|
||||||
|
"general": "Algemeen",
|
||||||
|
"siteName": "Sitenaam",
|
||||||
|
"siteNameDesc": "De naam die door de applicatie wordt getoond",
|
||||||
|
"maxUpload": "Max uploadgrootte (MB)",
|
||||||
|
"maxUploadDesc": "Maximale bestandsgrootte voor examen-JSON uploads",
|
||||||
|
"defaultTheme": "Standaardthema",
|
||||||
|
"defaultThemeDesc": "Standaardthema voor nieuwe bezoekers",
|
||||||
|
"accessControl": "Toegangsbeheer",
|
||||||
|
"allowGuestMode": "Gastmodus toestaan",
|
||||||
|
"allowGuestModeDesc": "Laat niet-ingelogde gebruikers examens maken zonder in te loggen",
|
||||||
|
"registrationEnabled": "Registratie ingeschakeld",
|
||||||
|
"registrationEnabledDesc": "Sta nieuwe gebruikers toe om accounts aan te maken",
|
||||||
|
"emailSmtp": "E-mail / SMTP",
|
||||||
|
"appUrl": "App-URL",
|
||||||
|
"appUrlDesc": "Basis-URL die in e-maillinks wordt gebruikt (bijv. https://brainwave.example.com)",
|
||||||
|
"smtpHost": "SMTP-host",
|
||||||
|
"smtpHostDesc": "Hostnaam van je SMTP-server",
|
||||||
|
"smtpPort": "SMTP-poort",
|
||||||
|
"smtpPortDesc": "Gebruikelijke poorten: 587 (STARTTLS), 465 (SSL), 25 (platte tekst)",
|
||||||
|
"username": "Gebruikersnaam",
|
||||||
|
"usernameDesc": "SMTP-authenticatie gebruikersnaam",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"passwordDesc": "SMTP-authenticatie wachtwoord",
|
||||||
|
"fromAddress": "Afzenderadres",
|
||||||
|
"fromAddressDesc": "De afzendernaam en -adres die in e-mails worden getoond",
|
||||||
|
"tlsMode": "TLS-modus",
|
||||||
|
"tlsModeDesc": "Auto detecteert vanaf poort (465→SSL). Gebruik expliciete overschrijving indien nodig.",
|
||||||
|
"auto": "Auto",
|
||||||
|
"forceTls": "Forceer TLS",
|
||||||
|
"off": "Uit",
|
||||||
|
"sendTestEmail": "Test-e-mail versturen",
|
||||||
|
"sendTestEmailDesc": "Verstuurt een test-e-mail met bovenstaande instellingen (gebruikt conceptwaarden, opslaan niet nodig)",
|
||||||
|
"sendTest": "Verstuur test",
|
||||||
|
"sending": "Versturen…",
|
||||||
|
"maintenance": "Onderhoud",
|
||||||
|
"maintenanceMode": "Onderhoudsmodus",
|
||||||
|
"maintenanceModeDesc": "Zet de site offline voor niet-admin gebruikers",
|
||||||
|
"maintenanceMessage": "Onderhoudsbericht",
|
||||||
|
"maintenanceMessageDesc": "Bericht dat aan bezoekers wordt getoond tijdens onderhoud",
|
||||||
|
"discardChanges": "Wijzigingen negeren",
|
||||||
|
"saveSettings": "Instellingen opslaan",
|
||||||
|
"settingsSaved": "Instellingen succesvol opgeslagen",
|
||||||
|
"searchByNameOrEmail": "Zoek op naam of e-mail…",
|
||||||
|
"search": "Zoeken",
|
||||||
|
"role": "Rol",
|
||||||
|
"verified": "Geverifieerd",
|
||||||
|
"exams": "Examens",
|
||||||
|
"joined": "Lid sinds",
|
||||||
|
"actions": "Acties",
|
||||||
|
"noUsersFound": "Geen gebruikers gevonden",
|
||||||
|
"testEmailSent": "Test-e-mail succesvol verstuurd! Controleer je inbox."
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"back": "← Terug",
|
||||||
|
"examTitlePlaceholder": "Examennaam",
|
||||||
|
"unsavedChanges": "Niet-opgeslagen wijzigingen",
|
||||||
|
"saving": "Opslaan…",
|
||||||
|
"createExam": "Examen aanmaken",
|
||||||
|
"save": "Opslaan",
|
||||||
|
"examCreated": "Examen succesvol aangemaakt",
|
||||||
|
"examSaved": "Examen opgeslagen",
|
||||||
|
"questionsLabel": "Vragen",
|
||||||
|
"addQuestion": "Vraag toevoegen",
|
||||||
|
"noQuestions": "Nog geen vragen",
|
||||||
|
"noQuestionsDesc": "Selecteer een vraagtype in de zijbalk om je eerste vraag toe te voegen.",
|
||||||
|
"questionEditor": "Vraagbewerker",
|
||||||
|
"prompt": "Vraagtekst",
|
||||||
|
"promptPlaceholder": "Voer de vraagtekst in…",
|
||||||
|
"categoryOptional": "Categorie (optioneel)",
|
||||||
|
"categoryPlaceholder": "bijv. Algebra",
|
||||||
|
"points": "Punten",
|
||||||
|
"explanationOptional": "Uitleg (optioneel)",
|
||||||
|
"explanationPlaceholder": "Uitleg die na beoordeling wordt getoond…",
|
||||||
|
"choices": "Keuzes",
|
||||||
|
"markAsCorrect": "Markeer als correct",
|
||||||
|
"addChoice": "+ Keuze toevoegen",
|
||||||
|
"remove": "Verwijderen",
|
||||||
|
"multipleChoice": "Meerkeuze",
|
||||||
|
"multipleSelect": "Meervoudige selectie",
|
||||||
|
"shortAnswer": "Kort antwoord",
|
||||||
|
"translation": "Vertaling",
|
||||||
|
"mathInput": "Wiskunde-invoer",
|
||||||
|
"essay": "Opstel",
|
||||||
|
"match": "Koppelen",
|
||||||
|
"order": "Rangschikken",
|
||||||
|
"correctAnswer": "Correct antwoord",
|
||||||
|
"correctAnswerPlaceholder": "Voer het correcte antwoord in",
|
||||||
|
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
|
||||||
|
"alternativeAnswer": "Alternatief antwoord",
|
||||||
|
"addAlternative": "+ Alternatief toevoegen",
|
||||||
|
"caseSensitive": "Hoofdlettergevoelig",
|
||||||
|
"ignorePunctuation": "Negeer leestekens",
|
||||||
|
"ignoreAccents": "Negeer accenten",
|
||||||
|
"minWords": "Min. woorden",
|
||||||
|
"rubricCriteria": "Rubric-criteria",
|
||||||
|
"criterion": "Criterium",
|
||||||
|
"addCriterion": "+ Criterium toevoegen",
|
||||||
|
"leftColumn": "Linkerkolom",
|
||||||
|
"rightColumn": "Rechterkolom",
|
||||||
|
"addLeft": "+ Links toevoegen",
|
||||||
|
"addRight": "+ Rechts toevoegen",
|
||||||
|
"correctPairings": "Correcte koppelingen",
|
||||||
|
"select": "— Selecteer —",
|
||||||
|
"items": "Items (slepen niet geïmplementeerd, gebruik pijlen om te rangschikken)",
|
||||||
|
"itemPlaceholder": "Item {{index}}",
|
||||||
|
"correctOrder": "Correcte volgorde",
|
||||||
|
"correctOrderHint": "De correcte volgorde is de volgorde hierboven. Rangschik de items om de juiste volgorde in te stellen.",
|
||||||
|
"setCurrentOrder": "Huidige volgorde instellen als correct",
|
||||||
|
"examTitleRequired": "Examennaam is verplicht",
|
||||||
|
"atLeastOneQuestion": "Voeg minimaal één vraag toe",
|
||||||
|
"sourceLanguage": "Brontaal",
|
||||||
|
"sourceLanguagePlaceholder": "bijv. Engels",
|
||||||
|
"targetLanguage": "Doeltaal",
|
||||||
|
"targetLanguagePlaceholder": "bijv. Spaans",
|
||||||
|
"choicesCheckCorrect": "Keuzes (vink correcte antwoorden aan)",
|
||||||
|
"choicePlaceholder": "Keuze {{id}}",
|
||||||
|
"addItem": "+ Item toevoegen",
|
||||||
|
"leftItemPlaceholder": "Link item {{index}}",
|
||||||
|
"rightItemPlaceholder": "Recht item {{index}}"
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"welcome": "Welkom bij BrainWave",
|
||||||
|
"subtitle": "Laten we je instantie instellen. Dit duurt maar een minuut.",
|
||||||
|
"adminAccount": "Admin-account",
|
||||||
|
"emailSettings": "E-mailinstellingen",
|
||||||
|
"email": "E-mail",
|
||||||
|
"displayName": "Weergavenaam",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"confirmPassword": "Wachtwoord bevestigen",
|
||||||
|
"continue": "Doorgaan →",
|
||||||
|
"smtpHost": "SMTP-host",
|
||||||
|
"smtpPort": "SMTP-poort",
|
||||||
|
"smtpUsername": "SMTP-gebruikersnaam",
|
||||||
|
"smtpPassword": "SMTP-wachtwoord",
|
||||||
|
"fromAddress": "Afzenderadres",
|
||||||
|
"security": "Beveiliging",
|
||||||
|
"appUrl": "App-URL",
|
||||||
|
"back": "← Terug",
|
||||||
|
"completeSetup": "Installatie voltooien",
|
||||||
|
"settingUp": "Bezig met instellen…",
|
||||||
|
"setupHint1": "Configureer je uitgaande e-mailserver zodat BrainWave verificatie- en wachtwoordreset-e-mails kan versturen.",
|
||||||
|
"setupHint2": "Je kunt deze velden leeglaten en later configureren in het admin dashboard.",
|
||||||
|
"autoSsl": "Auto (gebruik SSL op poort 465)",
|
||||||
|
"sslTls": "SSL/TLS",
|
||||||
|
"noneStarttls": "Geen / STARTTLS",
|
||||||
|
"validEmailRequired": "Voer een geldig e-mailadres in",
|
||||||
|
"displayNameRequired": "Weergavenaam is verplicht",
|
||||||
|
"passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten",
|
||||||
|
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
|
||||||
|
"setupFailed": "Installatie mislukt. Probeer het opnieuw."
|
||||||
|
},
|
||||||
|
"verify": {
|
||||||
|
"verifying": "E-mail verifiëren…",
|
||||||
|
"pleaseWait": "Een moment geduld terwijl we je e-mailadres bevestigen.",
|
||||||
|
"emailVerified": "E-mail geverifieerd",
|
||||||
|
"signIn": "Inloggen →",
|
||||||
|
"verificationFailed": "Verificatie mislukt",
|
||||||
|
"backToHome": "Terug naar home",
|
||||||
|
"noToken": "Geen verificatietoken gevonden."
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"resetPassword": "Wachtwoord resetten",
|
||||||
|
"enterEmail": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
|
||||||
|
"sendResetLink": "Resetlink versturen",
|
||||||
|
"sending": "Versturen…",
|
||||||
|
"backToSignIn": "← Terug naar inloggen",
|
||||||
|
"checkEmail": "Controleer je e-mail",
|
||||||
|
"setNewPassword": "Nieuw wachtwoord instellen",
|
||||||
|
"enterNewPassword": "Voer hieronder je nieuwe wachtwoord in.",
|
||||||
|
"newPassword": "Nieuw wachtwoord",
|
||||||
|
"confirmPassword": "Wachtwoord bevestigen",
|
||||||
|
"resetting": "Resetten…",
|
||||||
|
"resetPasswordBtn": "Wachtwoord resetten",
|
||||||
|
"passwordUpdated": "Wachtwoord bijgewerkt",
|
||||||
|
"passwordUpdatedDesc": "Je wachtwoord is succesvol gereset. Je kunt nu inloggen met je nieuwe wachtwoord.",
|
||||||
|
"requestFailed": "Verzoek mislukt",
|
||||||
|
"passwordResetFailed": "Wachtwoordreset mislukt"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2474
-1276
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./i18n";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface Question {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Exam {
|
export interface Exam {
|
||||||
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ApiError } from './api'
|
||||||
|
|
||||||
|
describe('ApiError', () => {
|
||||||
|
it('extends Error', () => {
|
||||||
|
const err = new ApiError(404, 'Not found')
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets status and message', () => {
|
||||||
|
const err = new ApiError(500, 'Server error')
|
||||||
|
expect(err.status).toBe(500)
|
||||||
|
expect(err.message).toBe('Server error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets name to ApiError', () => {
|
||||||
|
const err = new ApiError(401, 'Unauthorized')
|
||||||
|
expect(err.name).toBe('ApiError')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -47,9 +47,9 @@ export const highlightCode = (code: string, _lang: string): string => {
|
|||||||
|
|
||||||
// Extract comments and strings into placeholders before applying keyword/number/fn regexes
|
// Extract comments and strings into placeholders before applying keyword/number/fn regexes
|
||||||
// so later passes don't corrupt previously-injected span tag attributes.
|
// so later passes don't corrupt previously-injected span tag attributes.
|
||||||
// is a Unicode private-use-area char that never appears in source code.
|
// \uE000 is a Unicode private-use-area char that never appears in source code.
|
||||||
// Word chars around the index (e.g. _HL0_) prevent \b(\d+)\b from matching the index digits.
|
// Word chars around the index (e.g. _HL0_) prevent \b(\d+)\b from matching the index digits.
|
||||||
let result = escaped
|
const result = escaped
|
||||||
.replaceAll(/(\/\/[^\n]*|#[^\n]*)/g, (m) => {
|
.replaceAll(/(\/\/[^\n]*|#[^\n]*)/g, (m) => {
|
||||||
const i = spans.length
|
const i = spans.length
|
||||||
spans.push(`<span class="cm-comment">${m}</span>`)
|
spans.push(`<span class="cm-comment">${m}</span>`)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { slugify } from './slug'
|
import { slugify, getExamUrlId, extractExamSlug } from './slug'
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
it('lowercases', () => {
|
it('lowercases', () => {
|
||||||
@@ -24,3 +24,31 @@ describe('slugify', () => {
|
|||||||
expect(slugify('')).toBe('')
|
expect(slugify('')).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getExamUrlId', () => {
|
||||||
|
it('returns slug with id when id exists', () => {
|
||||||
|
expect(getExamUrlId({ id: 'abc12345', title: 'My Exam' })).toBe('my-exam-abc12345')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns slug only when id is missing', () => {
|
||||||
|
expect(getExamUrlId({ title: 'My Exam' })).toBe('my-exam')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractExamSlug', () => {
|
||||||
|
it('extracts slug from slug-id format', () => {
|
||||||
|
expect(extractExamSlug('my-exam-abc12345')).toBe('my-exam')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns full string when no dash', () => {
|
||||||
|
expect(extractExamSlug('myexam')).toBe('myexam')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns full string when suffix is too short to be an id', () => {
|
||||||
|
expect(extractExamSlug('my-exam-abc')).toBe('my-exam-abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles slug with multiple dashes', () => {
|
||||||
|
expect(extractExamSlug('my-cool-exam-abc12345678')).toBe('my-cool-exam')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,3 +5,18 @@ export function slugify(title: string): string {
|
|||||||
.replace(/-+/g, '-')
|
.replace(/-+/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
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './storage'
|
||||||
|
|
||||||
|
const storage: Record<string, string> = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.keys(storage).forEach((k) => delete storage[k])
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: (key: string) => storage[key] ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage[key] = value },
|
||||||
|
removeItem: (key: string) => { delete storage[key] },
|
||||||
|
length: 0,
|
||||||
|
key: () => null,
|
||||||
|
clear: () => { Object.keys(storage).forEach((k) => delete storage[k]) },
|
||||||
|
} as Storage
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadState / saveState / clearState', () => {
|
||||||
|
it('returns null when nothing saved', () => {
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns saved state', () => {
|
||||||
|
const data = { exam: { id: '1', title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } }
|
||||||
|
saveState(data)
|
||||||
|
expect(loadState()).toEqual(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears saved state', () => {
|
||||||
|
saveState({ exam: { id: '1', title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
|
||||||
|
clearState()
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for corrupted json', () => {
|
||||||
|
localStorage.setItem('brainwave.exam.state', 'not-json')
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadHistory / appendHistory / clearHistory', () => {
|
||||||
|
it('returns empty array when nothing saved', () => {
|
||||||
|
expect(loadHistory()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends entries and limits to 30', () => {
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
appendHistory({ id: String(i), title: `Exam ${i}`, completedAt: Date.now(), elapsedMs: 0, gradedCount: 10, correctCount: i, totalPoints: 10, scorePoints: i, passingScore: 5, exam: { title: `Exam ${i}`, questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
|
||||||
|
}
|
||||||
|
const hist = loadHistory()
|
||||||
|
expect(hist.length).toBe(30)
|
||||||
|
expect(hist[0].id).toBe('34')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears history', () => {
|
||||||
|
appendHistory({ id: '1', title: 'E', completedAt: Date.now(), elapsedMs: 0, gradedCount: 1, correctCount: 1, totalPoints: 1, scorePoints: 1, passingScore: 1, exam: { title: 'E', questions: [] }, state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' as const } })
|
||||||
|
clearHistory()
|
||||||
|
expect(loadHistory()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for corrupted json', () => {
|
||||||
|
localStorage.setItem('brainwave.exam.history', 'not-json')
|
||||||
|
expect(loadHistory()).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { validateExam, parseExam } from './validation'
|
||||||
|
|
||||||
|
describe('validateExam', () => {
|
||||||
|
it('returns error for non-object', () => {
|
||||||
|
expect(validateExam(null)).toBe('Not a JSON object')
|
||||||
|
expect(validateExam('string')).toBe('Not a JSON object')
|
||||||
|
expect(validateExam(123)).toBe('Not a JSON object')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for missing title', () => {
|
||||||
|
expect(validateExam({ questions: [] })).toBe('Missing "title"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for missing questions array', () => {
|
||||||
|
expect(validateExam({ title: 'Exam' })).toBe('Missing "questions" array')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty questions array', () => {
|
||||||
|
expect(validateExam({ title: 'Exam', questions: [] })).toBe('Missing "questions" array')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for question missing id', () => {
|
||||||
|
expect(validateExam({ title: 'Exam', questions: [{ type: 'essay', prompt: 'Q' }] })).toBe('Question missing id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for question missing type', () => {
|
||||||
|
expect(validateExam({ title: 'Exam', questions: [{ id: '1', prompt: 'Q' }] })).toBe('Question "1" missing type')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for question missing prompt', () => {
|
||||||
|
expect(validateExam({ title: 'Exam', questions: [{ id: '1', type: 'essay' }] })).toBe('Question "1" missing prompt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for valid exam', () => {
|
||||||
|
expect(validateExam({ title: 'Exam', questions: [{ id: '1', type: 'essay', prompt: 'Q' }] })).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseExam', () => {
|
||||||
|
it('generates id when missing', () => {
|
||||||
|
const exam = parseExam({ title: 'T', questions: [] })
|
||||||
|
expect(exam.id).toBeTruthy()
|
||||||
|
expect(typeof exam.id).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves existing id', () => {
|
||||||
|
const exam = parseExam({ id: 'abc123', title: 'T', questions: [] })
|
||||||
|
expect(exam.id).toBe('abc123')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,4 +13,14 @@ export const validateExam = (data: unknown): string | null => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseExam = (data: unknown): Exam => data as Exam
|
function generateId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseExam = (data: unknown): Exam => {
|
||||||
|
const exam = data as Exam
|
||||||
|
if (!exam.id) {
|
||||||
|
exam.id = generateId()
|
||||||
|
}
|
||||||
|
return exam
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import babel from "@rolldown/plugin-babel";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "brainwave",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]}
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "brainwave",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user