Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f794dacd2d | ||
|
|
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 | ||
|
|
4eeacc38c5 | ||
|
|
e97d90e316 | ||
|
|
1c13f4849a | ||
|
|
40fe1479f9 | ||
|
|
7b4288fe86 | ||
|
|
9539e3d740 | ||
|
|
10ce58fdfe | ||
|
|
aeed804460 | ||
|
|
e21610880b | ||
|
|
13bebba16e | ||
|
|
98b9d51d83 | ||
|
|
5b7d31cb34 |
No files matched your search
@@ -23,7 +23,17 @@
|
||||
"Bash(xargs git *)",
|
||||
"Bash(awk '{print $2}')",
|
||||
"Bash(git tag *)",
|
||||
"Bash(git push *)"
|
||||
"Bash(git push *)",
|
||||
"Bash(xargs grep *)",
|
||||
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/**)",
|
||||
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/**)",
|
||||
"Read(//c/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/**)",
|
||||
"Bash(bash \"C:/Users/laure/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh\" --project-dir \"c:/Users/laure/Documents/Gitea/brainwave\")",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" status --short)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,19 @@ jobs:
|
||||
- name: Install Ansible
|
||||
run: pip3 install --quiet --break-system-packages "ansible>=9,<11"
|
||||
|
||||
- name: Write SSH Key
|
||||
- name: Setup SSH Agent
|
||||
run: |
|
||||
printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" > /tmp/ansible_key
|
||||
chmod 600 /tmp/ansible_key
|
||||
eval "$(ssh-agent -s)"
|
||||
printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" | ssh-add -
|
||||
ssh-add -l
|
||||
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
|
||||
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Write Inventory
|
||||
# StrictHostKeyChecking=no is intentional for a known home-network host.
|
||||
# To harden, add the host fingerprint as a secret and use a known_hosts file.
|
||||
run: |
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_private_key_file=/tmp/ansible_key ansible_ssh_common_args="-o StrictHostKeyChecking=no"\n' \
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
|
||||
"${{ secrets.ANSIBLE_HOST }}" \
|
||||
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
||||
|
||||
@@ -52,10 +55,11 @@ jobs:
|
||||
run: |
|
||||
ansible-playbook ansible/deploy.yml \
|
||||
-i /tmp/inventory.ini \
|
||||
-e "artifact_path=${WORKSPACE}"
|
||||
-e "artifact_path=${WORKSPACE}" \
|
||||
-e "jwt_secret=${{ secrets.JWT_SECRET }}"
|
||||
|
||||
- name: Cleanup Secrets
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
rm -f /tmp/ansible_key
|
||||
ssh-agent -k || true
|
||||
rm -f /tmp/inventory.ini
|
||||
@@ -3,11 +3,13 @@ name: Release Pipeline
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
@@ -15,11 +17,11 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: "22"
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Run Frontend Tests
|
||||
working-directory: ./frontend
|
||||
@@ -61,6 +63,10 @@ jobs:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- brainwave-db:/app/backend/db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
brainwave-db:
|
||||
@@ -75,18 +81,52 @@ jobs:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- brainwave-db:/app/backend/db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
brainwave-db:
|
||||
EOF
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
brainwave-amd64.tar.gz
|
||||
brainwave-arm64.tar.gz
|
||||
docker-compose.amd64.yml
|
||||
docker-compose.arm64.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
# 1. Create the release
|
||||
echo "Creating release for tag: ${TAG}"
|
||||
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\", \"name\":\"Release ${TAG}\", \"body\":\"Automated release for version ${TAG}\", \"draft\":false, \"prerelease\":false}" \
|
||||
"${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('id', data.get('message', 'ERROR')))")
|
||||
|
||||
if [ "$RELEASE_ID" = "ERROR" ] || [ "$RELEASE_ID" = "release already exists for this tag" ]; then
|
||||
echo "Release might already exist, attempting to find it..."
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases" \
|
||||
| python3 -c "import sys, json; releases=json.load(sys.stdin); print(next((r['id'] for r in releases if r['tag_name'] == '${TAG}'), 'ERROR'))")
|
||||
fi
|
||||
|
||||
if [ "$RELEASE_ID" = "ERROR" ]; then
|
||||
echo "Failed to create or find release: $RELEASE_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading assets to release ID: ${RELEASE_ID}"
|
||||
for FILE in brainwave-amd64.tar.gz brainwave-arm64.tar.gz docker-compose.amd64.yml docker-compose.arm64.yml; do
|
||||
echo "Uploading ${FILE}..."
|
||||
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${FILE}" \
|
||||
"${SERVER_URL}/api/v1/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILE}")
|
||||
echo "${UPLOAD_RESPONSE}" | python3 -c "import sys, json; data=json.load(sys.stdin); exit(0 if 'id' in data else 1)" \
|
||||
|| { echo "Failed to upload ${FILE}"; echo "Response: ${UPLOAD_RESPONSE}"; exit 1; }
|
||||
done
|
||||
echo "Release completed successfully!"
|
||||
@@ -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
|
||||
@@ -1,3 +1,9 @@
|
||||
backend/brainwave.db-shm
|
||||
backend/brainwave.db-wal
|
||||
.claude/settings.local.json
|
||||
.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
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build the backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
FROM node:22-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci
|
||||
@@ -15,7 +15,7 @@ COPY backend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:20-alpine AS production
|
||||
FROM node:22-alpine AS production
|
||||
# Install build tools for native modules like better-sqlite3
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /app
|
||||
|
||||
+9
-2
@@ -12,7 +12,7 @@
|
||||
ansible.builtin.file:
|
||||
path: "{{ deploy_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
mode: "0755"
|
||||
|
||||
- name: Copy ARM tarball
|
||||
ansible.builtin.copy:
|
||||
@@ -24,6 +24,12 @@
|
||||
src: "{{ artifact_path }}/docker-compose.arm64.yml"
|
||||
dest: "{{ deploy_dir }}/docker-compose.yml"
|
||||
|
||||
- name: Create .env file
|
||||
ansible.builtin.copy:
|
||||
content: "JWT_SECRET={{ jwt_secret }}\n"
|
||||
dest: "{{ deploy_dir }}/.env"
|
||||
mode: "0600"
|
||||
|
||||
- name: Load Docker image
|
||||
ansible.builtin.command: docker load -i {{ deploy_dir }}/brainwave-arm64.tar.gz
|
||||
register: docker_load_result
|
||||
@@ -33,4 +39,5 @@
|
||||
ansible.builtin.command: docker compose up -d
|
||||
args:
|
||||
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",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3"
|
||||
"nodemailer": "^7.0.3",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
@@ -1434,6 +1437,18 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1647,6 +1662,21 @@
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"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",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3"
|
||||
"nodemailer": "^7.0.3",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { config } from '../config.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db')
|
||||
const DB_PATH = path.isAbsolute(config.dbPath)
|
||||
? config.dbPath
|
||||
: path.join(process.cwd(), config.dbPath)
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
@@ -51,35 +51,49 @@ export function runMigrations(db: Database.Database): void {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||
`)
|
||||
`);
|
||||
|
||||
// ── Add role column if missing (existing databases) ──
|
||||
const cols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
|
||||
if (!cols.some(c => c.name === 'role')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
|
||||
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (!cols.some((c) => c.name === "role")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
||||
}
|
||||
|
||||
// ── Promote first user to admin if no admins exist ──
|
||||
const adminCount = db.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`).get() as { cnt: number }
|
||||
const adminCount = db
|
||||
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
|
||||
.get() as { cnt: number };
|
||||
if (adminCount.cnt === 0) {
|
||||
db.prepare(`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`).run()
|
||||
db.prepare(
|
||||
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
|
||||
).run();
|
||||
}
|
||||
|
||||
// ── Seed default settings ──
|
||||
const defaults: Record<string, string> = {
|
||||
site_name: 'BrainWave',
|
||||
max_upload_mb: '5',
|
||||
allow_guest_mode: 'true',
|
||||
registration_enabled: 'true',
|
||||
default_theme: 'light',
|
||||
maintenance_mode: 'false',
|
||||
maintenance_message: 'We are currently performing maintenance. Please check back soon.',
|
||||
}
|
||||
site_name: "BrainWave",
|
||||
max_upload_mb: "5",
|
||||
allow_guest_mode: "true",
|
||||
registration_enabled: "true",
|
||||
default_theme: "light",
|
||||
maintenance_mode: "false",
|
||||
maintenance_message:
|
||||
"We are currently performing maintenance. Please check back soon.",
|
||||
smtp_host: "",
|
||||
smtp_port: "587",
|
||||
smtp_user: "",
|
||||
smtp_pass: "",
|
||||
smtp_from: "BrainWave <noreply@brainwave.local>",
|
||||
smtp_secure: "auto",
|
||||
app_url: "http://localhost:5173",
|
||||
};
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
`)
|
||||
`);
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
upsert.run(key, value)
|
||||
upsert.run(key, value);
|
||||
}
|
||||
}
|
||||
+43
-6
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { getDb, closeDb } from './db/database.js'
|
||||
@@ -5,6 +6,9 @@ import authRoutes from './routes/auth.js'
|
||||
import examRoutes from './routes/exams.js'
|
||||
import resultRoutes from './routes/results.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import setupRoutes from './routes/setup.js'
|
||||
import { errorHandler } from './middleware/error.js'
|
||||
import { config } from './config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -12,34 +16,67 @@ import { fileURLToPath } from 'url'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const PORT = Number(process.env['PORT'] ?? '3001')
|
||||
const PORT = config.port
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' }))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(cors({ origin: config.corsOrigin }))
|
||||
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
|
||||
getDb()
|
||||
console.log('✓ Database initialized')
|
||||
|
||||
// Routes
|
||||
app.use('/api/setup', setupRoutes)
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/exams', examRoutes)
|
||||
app.use('/api/results', resultRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
// Health check
|
||||
// Health check with subsystem status
|
||||
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
|
||||
const frontendDistPath = path.join(__dirname, '../../frontend/dist')
|
||||
if (fs.existsSync(frontendDistPath)) {
|
||||
console.log(`Serving frontend from: ${frontendDistPath}`)
|
||||
app.use(express.static(frontendDistPath))
|
||||
app.get('*', (req, res) => {
|
||||
app.get('*path', (req, res) => {
|
||||
if (!req.path.startsWith('/api')) {
|
||||
res.sendFile(path.join(frontendDistPath, 'index.html'))
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: JwtPayload
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization
|
||||
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 { 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 { 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()
|
||||
|
||||
// All admin routes require authentication + admin role
|
||||
router.use(requireAuth, requireAdmin)
|
||||
|
||||
// ─── Dashboard Stats ─────────────────────────────────────
|
||||
router.get('/stats', (_req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const users = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
|
||||
const exams = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
|
||||
const results = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number }
|
||||
const avgScore = db.prepare(
|
||||
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results'
|
||||
).get() as { avg: number }
|
||||
const recentResults = db.prepare(`
|
||||
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
|
||||
u.display_name, u.email
|
||||
FROM exam_results er
|
||||
JOIN users u ON u.id = er.user_id
|
||||
ORDER BY er.completed_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
// GET /api/admin/users
|
||||
router.get(
|
||||
'/users',
|
||||
validateQuery(listUsersQuerySchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const parsed = (req as unknown as Record<string, unknown>).parsedQuery as { page: number; search?: string }
|
||||
const page = parsed.page ?? 1
|
||||
const limit = 20
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const { users, total } = userRepo.listUsers({
|
||||
limit,
|
||||
offset,
|
||||
search: parsed.search,
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
res.json({
|
||||
totalUsers: users.cnt,
|
||||
totalExams: exams.cnt,
|
||||
totalResults: results.cnt,
|
||||
averageScore: Math.round(avgScore.avg * 10) / 10,
|
||||
recentResults,
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
limit,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Admin stats error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Get All Settings ────────────────────────────────────
|
||||
router.get('/settings', (_req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[]
|
||||
const settings: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
settings[row.key] = row.value
|
||||
}
|
||||
res.json({ settings })
|
||||
} catch (err) {
|
||||
console.error('Admin get settings error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// PUT /api/admin/users/:id/role
|
||||
router.put(
|
||||
'/users/:id/role',
|
||||
validateParams(userIdParamSchema),
|
||||
validateBody(updateUserRoleSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const { role } = req.body
|
||||
|
||||
// ─── Update Settings ─────────────────────────────────────
|
||||
router.put('/settings', (req, res) => {
|
||||
try {
|
||||
const { settings } = req.body as { settings: Record<string, string> }
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
res.status(400).json({ error: 'Settings object is required' })
|
||||
return
|
||||
const user = userRepo.getUserById(id)
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found')
|
||||
}
|
||||
|
||||
const validKeys = [
|
||||
'site_name', 'max_upload_mb', 'allow_guest_mode',
|
||||
'registration_enabled', 'default_theme', 'maintenance_mode',
|
||||
'maintenance_message',
|
||||
]
|
||||
if (user.id === req.user!.userId) {
|
||||
throw new AppError(400, 'Cannot change your own role')
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const update = db.prepare(`
|
||||
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
|
||||
`)
|
||||
|
||||
const updateMany = db.transaction((entries: [string, string][]) => {
|
||||
for (const [key, value] of entries) {
|
||||
if (!validKeys.includes(key)) continue
|
||||
update.run(String(value), key)
|
||||
if (role === 'user' && user.role === 'admin') {
|
||||
const adminCount = userRepo.countAdmins()
|
||||
if (adminCount <= 1) {
|
||||
throw new AppError(400, 'Cannot remove the last admin')
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
res.json({ message: 'Settings updated successfully' })
|
||||
} catch (err) {
|
||||
console.error('Admin update settings error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// GET /api/admin/recent-results
|
||||
router.get(
|
||||
'/recent-results',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const results = resultRepo.getRecentResults(10)
|
||||
res.json(results)
|
||||
})
|
||||
)
|
||||
|
||||
// ─── List All Users ──────────────────────────────────────
|
||||
router.get('/users', (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query['limit']) || 50, 200)
|
||||
const offset = Number(req.query['offset']) || 0
|
||||
const search = (req.query['search'] as string) || ''
|
||||
// GET /api/admin/settings
|
||||
router.get(
|
||||
'/settings',
|
||||
asyncHandler(async (_req, res) => {
|
||||
res.json(settingsRepo.getAllSettings())
|
||||
})
|
||||
)
|
||||
|
||||
const db = getDb()
|
||||
let query = `
|
||||
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at,
|
||||
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count,
|
||||
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
|
||||
FROM users u
|
||||
`
|
||||
let countQuery = 'SELECT COUNT(*) AS total FROM users'
|
||||
const params: unknown[] = []
|
||||
const countParams: unknown[] = []
|
||||
|
||||
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' })
|
||||
}
|
||||
})
|
||||
// PUT /api/admin/settings
|
||||
router.put(
|
||||
'/settings',
|
||||
validateBody(updateSettingSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { key, value } = req.body
|
||||
settingsRepo.setSetting(key, value)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+116
-238
@@ -1,248 +1,126 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { validateBody } from '../middleware/validate.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 BCRYPT_ROUNDS = 12
|
||||
const VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
// ─── Register ────────────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, displayName } = req.body
|
||||
|
||||
if (!email || !password || !displayName) {
|
||||
res.status(400).json({ error: 'Email, password, and display name are required' })
|
||||
return
|
||||
}
|
||||
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
|
||||
res.status(400).json({ error: 'Invalid email address' })
|
||||
return
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
|
||||
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
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' })
|
||||
}
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
message: { error: 'Too many attempts, please try again later' },
|
||||
standardHeaders: true,
|
||||
skipSuccessfulRequests: false,
|
||||
})
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password are required' })
|
||||
return
|
||||
}
|
||||
|
||||
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/register
|
||||
router.post(
|
||||
'/register',
|
||||
authLimiter,
|
||||
validateBody(registerSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const isFirstUser = userRepo.countUsers() === 0
|
||||
const result = await authService.register(req.body, isFirstUser)
|
||||
res.json(result)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// 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
|
||||
+74
-82
@@ -1,99 +1,91 @@
|
||||
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 { 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()
|
||||
|
||||
// ─── List User's Saved Exams ─────────────────────────────
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
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)
|
||||
// All exam routes require authentication
|
||||
router.use(requireAuth)
|
||||
|
||||
// GET /api/exams
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exams = examRepo.listExamsByUser(req.user!.userId)
|
||||
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) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const parsedExam = JSON.parse(exam.exam_json)
|
||||
res.json({ ...parsedExam, id: exam.id })
|
||||
})
|
||||
)
|
||||
|
||||
res.json({
|
||||
id: exam['id'],
|
||||
title: exam['title'],
|
||||
subtitle: exam['subtitle'],
|
||||
exam: JSON.parse(exam['exam_json'] as string),
|
||||
createdAt: exam['created_at'],
|
||||
updatedAt: exam['updated_at'],
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Get exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// POST /api/exams
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(saveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exam = req.body.exam
|
||||
const { id } = examRepo.createExam(
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam)
|
||||
)
|
||||
res.status(201).json({ id, title: exam.title })
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Save Exam ───────────────────────────────────────────
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { exam } = req.body
|
||||
|
||||
if (!exam || !exam.title || !exam.questions?.length) {
|
||||
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
|
||||
return
|
||||
// PUT /api/exams/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(updateExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
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()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(req.user!.userId, exam.title, exam.subtitle ?? null, JSON.stringify(exam))
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Exam saved successfully',
|
||||
})
|
||||
} 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
|
||||
// DELETE /api/exams/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = examRepo.deleteExam(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
|
||||
res.json({ message: 'Exam deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+64
-122
@@ -1,139 +1,81 @@
|
||||
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 { 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()
|
||||
|
||||
// ─── List User's Results ─────────────────────────────────
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query['limit']) || 30, 100)
|
||||
const offset = Number(req.query['offset']) || 0
|
||||
router.use(requireAuth)
|
||||
|
||||
const db = getDb()
|
||||
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(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
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { results, total } = resultRepo.listResultsByUser(req.user!.userId)
|
||||
res.json({ results, total })
|
||||
})
|
||||
)
|
||||
|
||||
// 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) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
throw new AppError(404, 'Result not found')
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: result['id'],
|
||||
title: result['title'],
|
||||
scorePoints: result['score_points'],
|
||||
totalPoints: result['total_points'],
|
||||
correctCount: result['correct_count'],
|
||||
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),
|
||||
...result,
|
||||
state: JSON.parse(result.state_json),
|
||||
exam: JSON.parse(result.exam_json),
|
||||
state_json: undefined,
|
||||
exam_json: undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Get result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Save Result ─────────────────────────────────────────
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, notes, state, exam, examId } = req.body
|
||||
|
||||
if (
|
||||
!title || scorePoints == null || totalPoints == null ||
|
||||
correctCount == null || gradedCount == null || !state || !exam
|
||||
) {
|
||||
res.status(400).json({ error: 'Missing required result fields' })
|
||||
return
|
||||
}
|
||||
|
||||
let noteSource = ''
|
||||
if (typeof notes === 'string') {
|
||||
noteSource = notes
|
||||
} else if (typeof state.notes === 'string') {
|
||||
noteSource = state.notes
|
||||
}
|
||||
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',
|
||||
// POST /api/results
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(submitResultSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const data = req.body
|
||||
const { id } = resultRepo.createResult({
|
||||
user_id: req.user!.userId,
|
||||
exam_id: data.exam_id ?? null,
|
||||
title: data.title,
|
||||
score_points: data.score_points,
|
||||
total_points: data.total_points,
|
||||
correct_count: data.correct_count,
|
||||
graded_count: data.graded_count,
|
||||
passing_score: data.passing_score ?? 60,
|
||||
elapsed_ms: data.elapsed_ms,
|
||||
state_json: JSON.stringify(data.state),
|
||||
exam_json: JSON.stringify(data.exam),
|
||||
completed_at: Date.now(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Save result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.status(201).json({ id })
|
||||
})
|
||||
)
|
||||
|
||||
// ─── Delete Result ───────────────────────────────────────
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM exam_results WHERE id = ? AND user_id = ?
|
||||
`).run(Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
// DELETE /api/results/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(resultIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = resultRepo.deleteResult(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Result not found')
|
||||
}
|
||||
|
||||
res.json({ message: 'Result deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
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']
|
||||
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587')
|
||||
const SMTP_USER = process.env['SMTP_USER']
|
||||
const SMTP_PASS = process.env['SMTP_PASS']
|
||||
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>'
|
||||
|
||||
const isDev = !SMTP_HOST
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null
|
||||
|
||||
function getTransporter(): nodemailer.Transporter | null {
|
||||
if (isDev) return null
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||||
})
|
||||
}
|
||||
return transporter
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
async function sendMail(to: string, subject: string, html: string): Promise<void> {
|
||||
const t = getTransporter()
|
||||
if (!t) {
|
||||
console.log('\n══════════════════════════════════════════════════')
|
||||
console.log(`📧 EMAIL (dev mode) → ${to}`)
|
||||
console.log(` Subject: ${subject}`)
|
||||
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`)
|
||||
console.log('══════════════════════════════════════════════════\n')
|
||||
return
|
||||
// ─── App URL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getAppUrl(): string {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare("SELECT value FROM site_settings WHERE key = ?")
|
||||
.get("app_url") as { value: string } | undefined;
|
||||
return (
|
||||
(row?.value?.trim() || 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> {
|
||||
const link = `${APP_URL}/verify?token=${token}`
|
||||
await sendMail(to, 'Verify your BrainWave account', `
|
||||
// ─── SMTP Config ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getSetting(key: string): string {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare("SELECT value FROM site_settings WHERE key = ?")
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? "";
|
||||
}
|
||||
|
||||
export function isEmailConfigured(): boolean {
|
||||
return !!getSetting("smtp_host").trim();
|
||||
}
|
||||
|
||||
function getSmtpConfig(): SmtpConfig | null {
|
||||
const host = getSetting("smtp_host").trim();
|
||||
if (!host) return null;
|
||||
|
||||
const portStr = getSetting("smtp_port").trim() || "587";
|
||||
const port = Number(portStr);
|
||||
const user = getSetting("smtp_user").trim();
|
||||
const pass = getSetting("smtp_pass").trim();
|
||||
const from =
|
||||
getSetting("smtp_from").trim() || "BrainWave <noreply@brainwave.local>";
|
||||
const secureStr = getSetting("smtp_secure").trim() || "auto";
|
||||
|
||||
let secure: boolean;
|
||||
if (secureStr === "true") {
|
||||
secure = true;
|
||||
} else if (secureStr === "false") {
|
||||
secure = false;
|
||||
} else {
|
||||
// 'auto': derive from port
|
||||
secure = port === 465;
|
||||
}
|
||||
|
||||
return { host, port, user, pass, from, secure };
|
||||
}
|
||||
|
||||
// ─── Transporter ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildTransporter(cfg: SmtpConfig): nodemailer.Transporter {
|
||||
return nodemailer.createTransport({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
secure: cfg.secure,
|
||||
auth: cfg.user ? { user: cfg.user, pass: cfg.pass } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Core Send ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
overrideConfig?: SmtpConfig,
|
||||
): Promise<void> {
|
||||
const cfg = overrideConfig ?? getSmtpConfig();
|
||||
|
||||
if (!cfg) {
|
||||
// Dev mode — no SMTP configured, log to console
|
||||
console.log("\n══════════════════════════════════════════════════");
|
||||
console.log(`📧 EMAIL (dev mode) → ${to}`);
|
||||
console.log(` Subject: ${subject}`);
|
||||
console.log(` Body:\n${html.replace(/<[^>]*>/g, "")}`);
|
||||
console.log("══════════════════════════════════════════════════\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const transporter = buildTransporter(cfg);
|
||||
await transporter.sendMail({ from: cfg.from, to, subject, html });
|
||||
}
|
||||
|
||||
// ─── Exported Email Functions ─────────────────────────────────────────────────
|
||||
|
||||
export async function sendVerificationEmail(
|
||||
to: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const appUrl = getAppUrl();
|
||||
const link = `${appUrl}/verify?token=${token}`;
|
||||
await sendMail(
|
||||
to,
|
||||
"Verify your BrainWave account",
|
||||
`
|
||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
|
||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||
@@ -56,12 +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.
|
||||
</p>
|
||||
</div>
|
||||
`)
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> {
|
||||
const link = `${APP_URL}/reset-password?token=${token}`
|
||||
await sendMail(to, 'Reset your BrainWave password', `
|
||||
export async function sendPasswordResetEmail(
|
||||
to: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const appUrl = getAppUrl();
|
||||
const link = `${appUrl}/reset-password?token=${token}`;
|
||||
await sendMail(
|
||||
to,
|
||||
"Reset your BrainWave password",
|
||||
`
|
||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
|
||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||
@@ -77,5 +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.
|
||||
</p>
|
||||
</div>
|
||||
`)
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendTestEmail(
|
||||
to: string,
|
||||
overrideConfig?: SmtpConfig,
|
||||
): Promise<void> {
|
||||
await sendMail(
|
||||
to,
|
||||
"Test email from BrainWave",
|
||||
`
|
||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">This is a test email from BrainWave</h2>
|
||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||
If you're reading this, your SMTP configuration is working correctly.
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #888; margin-top: 32px;">
|
||||
Sent at: ${new Date().toUTCString()}
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
overrideConfig,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 crypto from 'crypto'
|
||||
|
||||
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
|
||||
import { config } from '../config.js'
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: number
|
||||
@@ -11,11 +10,11 @@ export interface JwtPayload {
|
||||
}
|
||||
|
||||
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 {
|
||||
return jwt.verify(token, JWT_SECRET) as JwtPayload
|
||||
return jwt.verify(token, config.jwtSecret) as JwtPayload
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,818 @@
|
||||
# React Router URL Routing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the custom screen-state machine in App.tsx with React Router v7 so the URL reflects the active exam and question number (e.g. `/exam/my-exam/q3`).
|
||||
|
||||
**Architecture:** Install `react-router-dom`, wrap the app in `BrowserRouter`, replace the `screen` useState with `<Routes>` / `<Route>`, and replace all `setScreen()` calls with `useNavigate()`. A new `ExamRoute` component handles deep-link restoration from localStorage and keeps `currentIdx` in sync with the `:n` URL param (enabling browser back/forward).
|
||||
|
||||
**Tech Stack:** React 19, react-router-dom v7, Vite, TypeScript, Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File |
|
||||
| ------ | ---- |
|
||||
| Create | `frontend/src/utils/slug.ts` |
|
||||
| Create | `frontend/src/utils/slug.test.ts` |
|
||||
| Create | `frontend/src/components/ExamRoute.tsx` |
|
||||
| Modify | `frontend/package.json` |
|
||||
| Modify | `frontend/vite.config.ts` |
|
||||
| Modify | `frontend/src/main.tsx` |
|
||||
| Modify | `frontend/src/components/ExamRunner.tsx` |
|
||||
| Modify | `frontend/src/App.tsx` |
|
||||
| Modify | `frontend/src/types.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install react-router-dom
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
|
||||
- [ ] **Step 1: Install the package**
|
||||
|
||||
Run from the `frontend/` directory:
|
||||
|
||||
```bash
|
||||
npm install react-router-dom
|
||||
```
|
||||
|
||||
Expected: `react-router-dom` appears under `"dependencies"` in `package.json`. No type errors.
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript types are available**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build 2>&1 | head -5
|
||||
```
|
||||
|
||||
Expected: build succeeds (or fails only on pre-existing errors — not on missing `react-router-dom` types).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "feat: install react-router-dom"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add slugify utility (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/utils/slug.test.ts`
|
||||
- Create: `frontend/src/utils/slug.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `frontend/src/utils/slug.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { slugify } from './slug'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('lowercases', () => {
|
||||
expect(slugify('My Exam')).toBe('my-exam')
|
||||
})
|
||||
it('replaces spaces with hyphens', () => {
|
||||
expect(slugify('hello world')).toBe('hello-world')
|
||||
})
|
||||
it('replaces special chars with hyphens', () => {
|
||||
expect(slugify('Exam: Advanced!')).toBe('exam-advanced')
|
||||
})
|
||||
it('collapses consecutive hyphens', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world')
|
||||
})
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugify('!hello!')).toBe('hello')
|
||||
})
|
||||
it('preserves numbers', () => {
|
||||
expect(slugify('Exam 2024')).toBe('exam-2024')
|
||||
})
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run test:run -- src/utils/slug.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL — `Cannot find module './slug'`
|
||||
|
||||
- [ ] **Step 3: Implement slugify**
|
||||
|
||||
Create `frontend/src/utils/slug.ts`:
|
||||
|
||||
```typescript
|
||||
export function slugify(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run test:run -- src/utils/slug.test.ts
|
||||
```
|
||||
|
||||
Expected: 7 passing tests.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/utils/slug.ts frontend/src/utils/slug.test.ts
|
||||
git commit -m "feat: add slugify utility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Vite config and wrap app in BrowserRouter
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/vite.config.ts`
|
||||
- Modify: `frontend/src/main.tsx`
|
||||
|
||||
- [ ] **Step 1: Add historyApiFallback to Vite config**
|
||||
|
||||
In `frontend/vite.config.ts`, add `historyApiFallback: true` to the `server` block:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
server: {
|
||||
historyApiFallback: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wrap the app in BrowserRouter**
|
||||
|
||||
Replace the contents of `frontend/src/main.tsx`:
|
||||
|
||||
```tsx
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build still passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: build succeeds — `App` does not yet use any router hooks so wrapping it is safe.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/vite.config.ts frontend/src/main.tsx
|
||||
git commit -m "feat: wrap app in BrowserRouter and enable history fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update ExamRunner with onNavigate prop
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ExamRunner.tsx`
|
||||
|
||||
The only change is adding an optional `onNavigate` prop and calling it from `goTo`. Everything else stays identical.
|
||||
|
||||
- [ ] **Step 1: Add the prop and update goTo**
|
||||
|
||||
In `frontend/src/components/ExamRunner.tsx`, update the `Props` interface and `goTo` callback:
|
||||
|
||||
```typescript
|
||||
// Change the Props interface (lines 9-15) to:
|
||||
interface Props {
|
||||
exam: Exam
|
||||
state: ExamState
|
||||
setState: React.Dispatch<React.SetStateAction<ExamState>>
|
||||
onSubmit: () => void
|
||||
onExit: () => void
|
||||
onNavigate?: (i: number) => void
|
||||
}
|
||||
|
||||
// Change the function signature (line 17) to:
|
||||
export default function ExamRunner({ exam, state, setState, onSubmit, onExit, onNavigate }: Readonly<Props>) {
|
||||
|
||||
// Replace the goTo callback (lines 29-30) with:
|
||||
const goTo = useCallback((i: number) => {
|
||||
const next = Math.max(0, Math.min(total - 1, i))
|
||||
setState((s) => ({ ...s, currentIdx: next }))
|
||||
onNavigate?.(next)
|
||||
}, [total, onNavigate])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: build succeeds — `onNavigate` is optional so existing callers are unaffected.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ExamRunner.tsx
|
||||
git commit -m "feat: add optional onNavigate prop to ExamRunner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create ExamRoute component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ExamRoute.tsx`
|
||||
|
||||
This component owns three responsibilities:
|
||||
1. On mount, if no exam is active, attempt to restore from localStorage using the `:slug` param; redirect to `/` if no match.
|
||||
2. On every render, sync the `:n` URL param to `examState.currentIdx` (enables browser back/forward).
|
||||
3. Render `ExamRunner` with an `onNavigate` handler that updates the URL.
|
||||
|
||||
- [ ] **Step 1: Create the file**
|
||||
|
||||
Create `frontend/src/components/ExamRoute.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import ExamRunner from './ExamRunner'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { slugify } from '../utils/slug'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
|
||||
interface Props {
|
||||
exam: Exam | null
|
||||
examState: ExamState | null
|
||||
onActivate: (exam: Exam, state: ExamState) => void
|
||||
setExamState: Dispatch<SetStateAction<ExamState>>
|
||||
onSubmit: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
|
||||
const { slug, n } = useParams<{ slug: string; n: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const questionIdx = Math.max(0, (Number(n) || 1) - 1)
|
||||
|
||||
// Deep link: restore from localStorage if no exam is active on mount
|
||||
useEffect(() => {
|
||||
if (!exam) {
|
||||
const saved = loadState()
|
||||
if (saved?.exam && slugify(saved.exam.title) === slug) {
|
||||
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
|
||||
} else {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}
|
||||
// Intentionally runs only on mount — dep changes after mount are handled by the sync effect below
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Sync :n URL param to currentIdx (handles browser back/forward navigation)
|
||||
useEffect(() => {
|
||||
setExamState((s) => s ? { ...s, currentIdx: questionIdx } : s)
|
||||
}, [questionIdx, setExamState])
|
||||
|
||||
if (!exam || !examState) return null
|
||||
|
||||
return (
|
||||
<ExamRunner
|
||||
exam={exam}
|
||||
state={examState}
|
||||
setState={setExamState}
|
||||
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
|
||||
onSubmit={onSubmit}
|
||||
onExit={onExit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: build succeeds. `ExamRoute` is not yet imported by anything so no usages to break.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ExamRoute.tsx
|
||||
git commit -m "feat: add ExamRoute component with deep-link restore and back/forward sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Migrate App.tsx to React Router
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.tsx`
|
||||
|
||||
This is the main migration. Replace the `screen` state machine with `<Routes>`, replace all `setScreen()` calls with `navigate()`, add `activateExam` for deep-link activation, and use `useLocation` for the verify/reset-password special layouts.
|
||||
|
||||
- [ ] **Step 1: Replace App.tsx with the migrated version**
|
||||
|
||||
Replace the entire contents of `frontend/src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import type { Exam, ExamState, HistoryEntry, Tweaks } from './types'
|
||||
import { gradeQuestion } from './utils/grading'
|
||||
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage'
|
||||
import { apiFetch, isAuthenticated } from './utils/api'
|
||||
import { slugify } from './utils/slug'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Landing from './components/Landing'
|
||||
import ExamRoute from './components/ExamRoute'
|
||||
import Results from './components/Results'
|
||||
import TweaksPanel from './components/TweaksPanel'
|
||||
import AuthModal from './components/AuthModal'
|
||||
import EmailVerify from './components/EmailVerify'
|
||||
import ResetPassword from './components/ResetPassword'
|
||||
import AdminDashboard from './components/AdminDashboard'
|
||||
|
||||
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
|
||||
|
||||
const withNotes = (state: ExamState): ExamState => ({
|
||||
...state,
|
||||
notes: state.notes ?? '',
|
||||
})
|
||||
|
||||
function AppInner() {
|
||||
const { user, loading: authLoading, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const [exam, setExam] = useState<Exam | null>(null)
|
||||
const [examState, setExamState] = useState<ExamState | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
|
||||
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS)
|
||||
const [tweaksOpen, setTweaksOpen] = useState(false)
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user && isAuthenticated()) {
|
||||
apiFetch<{ results: Array<{
|
||||
id: number; title: string; score_points: number; total_points: number;
|
||||
correct_count: number; graded_count: number; passing_score: number;
|
||||
elapsed_ms: number; completed_at: number; exam_id: number | null;
|
||||
}> }>('/api/results')
|
||||
.then((data) => {
|
||||
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
|
||||
id: String(r.id),
|
||||
title: r.title,
|
||||
completedAt: r.completed_at * 1000,
|
||||
elapsedMs: r.elapsed_ms,
|
||||
gradedCount: r.graded_count,
|
||||
correctCount: r.correct_count,
|
||||
totalPoints: r.total_points,
|
||||
scorePoints: r.score_points,
|
||||
passingScore: r.passing_score,
|
||||
exam: { title: r.title, questions: [] } as unknown as Exam,
|
||||
state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState,
|
||||
_remoteId: r.id,
|
||||
}))
|
||||
setHistory(apiHistory)
|
||||
})
|
||||
.catch(() => {
|
||||
setHistory(loadHistory())
|
||||
})
|
||||
}
|
||||
}, [user, authLoading])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset['theme'] = tweaks.theme
|
||||
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' }
|
||||
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
|
||||
}, [tweaks])
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith('/exam/') && exam && examState) saveState({ exam, state: examState })
|
||||
}, [exam, examState, pathname])
|
||||
|
||||
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
||||
setTweaks((t) => ({ ...t, [key]: value }))
|
||||
|
||||
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => {
|
||||
setExamState((prev) => {
|
||||
if (!prev) return prev
|
||||
return typeof nextState === 'function'
|
||||
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
||||
: nextState
|
||||
})
|
||||
}
|
||||
|
||||
const activateExam = (data: Exam, state: ExamState) => {
|
||||
setExam(data)
|
||||
setExamState(withNotes(state))
|
||||
}
|
||||
|
||||
const loadExam = (data: Exam) => {
|
||||
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
|
||||
setExam(data)
|
||||
setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm })
|
||||
navigate(`/exam/${slugify(data.title)}/q1`)
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
const saved = loadState()
|
||||
if (saved?.exam && saved?.state) {
|
||||
setExam(saved.exam)
|
||||
setExamState(withNotes(saved.state))
|
||||
navigate(`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!exam || !examState) return
|
||||
const completedAt = Date.now()
|
||||
const elapsedMs = completedAt - examState.startedAt
|
||||
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id]))
|
||||
const entry: HistoryEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: exam.title,
|
||||
completedAt,
|
||||
elapsedMs,
|
||||
gradedCount: graded.filter((r) => r.status !== 'ungraded').length,
|
||||
correctCount: graded.filter((r) => r.correct).length,
|
||||
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
|
||||
scorePoints: graded.reduce((a, r) => a + r.points, 0),
|
||||
passingScore: exam.passingScore ?? 60,
|
||||
exam,
|
||||
state: { ...examState, completedAt },
|
||||
}
|
||||
|
||||
if (user && isAuthenticated()) {
|
||||
void apiFetch('/api/results', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: entry.title,
|
||||
scorePoints: entry.scorePoints,
|
||||
totalPoints: entry.totalPoints,
|
||||
correctCount: entry.correctCount,
|
||||
gradedCount: entry.gradedCount,
|
||||
passingScore: entry.passingScore,
|
||||
elapsedMs: entry.elapsedMs,
|
||||
notes: entry.state.notes,
|
||||
state: entry.state,
|
||||
exam: entry.exam,
|
||||
}),
|
||||
}).catch(() => {
|
||||
appendHistory(entry)
|
||||
})
|
||||
} else {
|
||||
appendHistory(entry)
|
||||
}
|
||||
|
||||
setHistory((prev) => [entry, ...prev])
|
||||
clearState()
|
||||
setExamState((s) => s ? { ...s, completedAt } : s)
|
||||
navigate('/results')
|
||||
}, [exam, examState, user, navigate])
|
||||
|
||||
const exit = () => {
|
||||
setExam(null)
|
||||
setExamState(null)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const restart = () => { if (exam) loadExam(exam) }
|
||||
|
||||
const reviewAttempt = (entry: HistoryEntry) => {
|
||||
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId
|
||||
if (remoteId && user && isAuthenticated()) {
|
||||
void apiFetch<{
|
||||
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number;
|
||||
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number;
|
||||
}>(`/api/results/${remoteId}`)
|
||||
.then((data) => {
|
||||
setExam(data.exam)
|
||||
setExamState(withNotes(data.state))
|
||||
navigate(`/review/${slugify(entry.title)}`)
|
||||
})
|
||||
.catch(() => {
|
||||
setExam(entry.exam)
|
||||
setExamState(withNotes(entry.state))
|
||||
navigate(`/review/${slugify(entry.title)}`)
|
||||
})
|
||||
} else {
|
||||
setExam(entry.exam)
|
||||
setExamState(withNotes(entry.state))
|
||||
navigate(`/review/${slugify(entry.title)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const clearAllHistory = () => {
|
||||
if (confirm('Clear all past attempts?')) {
|
||||
clearHistory()
|
||||
setHistory([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthFlowDone = () => {
|
||||
navigate('/')
|
||||
setAuthModalOpen(true)
|
||||
}
|
||||
|
||||
if (pathname === '/verify') {
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark" />
|
||||
<span className="brand-name">BrainWave</span>
|
||||
</div>
|
||||
</header>
|
||||
<EmailVerify onDone={handleAuthFlowDone} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pathname === '/reset-password') {
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark" />
|
||||
<span className="brand-name">BrainWave</span>
|
||||
</div>
|
||||
</header>
|
||||
<ResetPassword onDone={handleAuthFlowDone} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark" />
|
||||
<button
|
||||
className="brand-name"
|
||||
onClick={exit}
|
||||
aria-label="Home"
|
||||
style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }}
|
||||
>
|
||||
BrainWave
|
||||
</button>
|
||||
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
{pathname.startsWith('/exam/') && exam && (
|
||||
<>
|
||||
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
|
||||
|
||||
{!authLoading && !user && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
{!authLoading && user && (
|
||||
<div className="user-menu-wrap">
|
||||
<button
|
||||
className="user-avatar"
|
||||
onClick={() => setUserMenuOpen((o) => !o)}
|
||||
aria-label="User menu"
|
||||
>
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<div className="user-menu-name">{user.displayName}</div>
|
||||
<div className="user-menu-email">{user.email}</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
{user.role === 'admin' && (
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); navigate('/admin') }}>
|
||||
Admin Dashboard
|
||||
</button>
|
||||
)}
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
||||
} />
|
||||
<Route path="/exam/:slug/q:n" element={
|
||||
<ExamRoute
|
||||
exam={exam}
|
||||
examState={examState}
|
||||
onActivate={activateExam}
|
||||
setExamState={setExamStateStrict}
|
||||
onSubmit={submit}
|
||||
onExit={exit}
|
||||
/>
|
||||
} />
|
||||
<Route path="/results" element={
|
||||
exam && examState
|
||||
? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={false} />
|
||||
: <Navigate to="/" replace />
|
||||
} />
|
||||
<Route path="/review/:slug" element={
|
||||
exam && examState
|
||||
? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={true} />
|
||||
: <Navigate to="/" replace />
|
||||
} />
|
||||
<Route path="/admin" element={
|
||||
user?.role === 'admin' ? <AdminDashboard onExit={exit} /> : <Navigate to="/" replace />
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
|
||||
{authModalOpen && (
|
||||
<AuthModal
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onForgotPassword={() => navigate('/reset-password')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppInner />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: build succeeds with no TypeScript errors.
|
||||
|
||||
- [ ] **Step 3: Run all tests**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run test:run
|
||||
```
|
||||
|
||||
Expected: all tests pass (slugify + pre-existing utils).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.tsx
|
||||
git commit -m "feat: migrate App.tsx to React Router — replace screen state with declarative routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Remove AppScreen type from types.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types.ts`
|
||||
|
||||
`AppScreen` is no longer referenced anywhere after the App.tsx migration.
|
||||
|
||||
- [ ] **Step 1: Remove the type**
|
||||
|
||||
In `frontend/src/types.ts`, delete line 116:
|
||||
|
||||
```typescript
|
||||
// Delete this line:
|
||||
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build still passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: build succeeds. If there are errors referencing `AppScreen`, search for usages:
|
||||
|
||||
```bash
|
||||
grep -r "AppScreen" frontend/src
|
||||
```
|
||||
|
||||
Remove any remaining import of `AppScreen` from those files.
|
||||
|
||||
- [ ] **Step 3: Run all tests**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run test:run
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types.ts
|
||||
git commit -m "chore: remove unused AppScreen type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Manual smoke test
|
||||
|
||||
No automated tests cover the React Router integration (the test environment is `node`, not jsdom). Verify the feature manually.
|
||||
|
||||
- [ ] **Step 1: Start the dev server**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify landing page**
|
||||
|
||||
Open `http://localhost:5173`. URL should be `/`. Landing page should render.
|
||||
|
||||
- [ ] **Step 3: Start an exam and verify URL updates**
|
||||
|
||||
Load any exam. The URL should change to `/exam/<slug>/q1`.
|
||||
Navigate to question 3 (sidebar or Next button). URL should change to `/exam/<slug>/q3`.
|
||||
|
||||
- [ ] **Step 4: Verify browser back/forward**
|
||||
|
||||
Press the browser Back button. URL should go to `/exam/<slug>/q2` and question 2 should display.
|
||||
|
||||
- [ ] **Step 5: Verify deep link**
|
||||
|
||||
While on question 5, copy the URL. Open a new tab and paste it. The exam should resume at question 5 (restored from localStorage).
|
||||
|
||||
- [ ] **Step 6: Verify submit → results URL**
|
||||
|
||||
Submit the exam. URL should change to `/results`.
|
||||
|
||||
- [ ] **Step 7: Verify review URL**
|
||||
|
||||
From the landing history, click a past attempt to review. URL should change to `/review/<slug>`.
|
||||
|
||||
- [ ] **Step 8: Verify auth flow routes**
|
||||
|
||||
Navigate to `http://localhost:5173/verify`. The email verify screen should render (simplified header, no nav).
|
||||
Navigate to `http://localhost:5173/reset-password`. The reset password screen should render.
|
||||
|
||||
- [ ] **Step 9: Verify unknown route redirect**
|
||||
|
||||
Navigate to `http://localhost:5173/does-not-exist`. Should redirect to `/`.
|
||||
@@ -0,0 +1,112 @@
|
||||
# React Router URL Routing — Design Spec
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add React Router v7 to the Brainwave frontend so the URL reflects the active exam and current question number. A user on question 3 of "My Exam" will see `/exam/my-exam/q3` in the address bar. Back/forward browser navigation works naturally, and deep links to a specific question are supported.
|
||||
|
||||
## Route Structure
|
||||
|
||||
| URL | Screen |
|
||||
| --- | ------ |
|
||||
| `/` | Landing |
|
||||
| `/exam/:slug/q:n` | ExamRunner — question `n` (1-indexed) of exam identified by `slug` |
|
||||
| `/results` | Results (post-submission) |
|
||||
| `/review/:slug` | Results in review-only mode |
|
||||
| `/verify` | Email verification |
|
||||
| `/reset-password` | Password reset |
|
||||
| `/admin` | Admin dashboard (admin users only) |
|
||||
|
||||
`:slug` is the exam title converted to lowercase kebab-case (e.g. `"My Exam 2024"` → `"my-exam-2024"`). `:n` is the 1-indexed question number.
|
||||
|
||||
## Architecture
|
||||
|
||||
### New dependency
|
||||
|
||||
`react-router-dom` v7 added to `frontend/package.json` dependencies.
|
||||
|
||||
### New utility — `frontend/src/utils/slug.ts`
|
||||
|
||||
Pure function `slugify(title: string): string`:
|
||||
|
||||
- Lowercase
|
||||
- Replace non-alphanumeric characters with hyphens
|
||||
- Collapse consecutive hyphens
|
||||
- Trim leading/trailing hyphens
|
||||
|
||||
### `main.tsx`
|
||||
|
||||
Wrap `<App>` in `<BrowserRouter>`.
|
||||
|
||||
### `App.tsx`
|
||||
|
||||
- Remove `screen` state and `AppScreen` type usage.
|
||||
- Replace the URL-on-mount `useEffect` with React Router's declarative routing.
|
||||
- Use `<Routes>` / `<Route>` to render screen components.
|
||||
- Replace all `setScreen()` calls with `useNavigate()`:
|
||||
- `loadExam()` → `navigate('/exam/${slug}/q1')`
|
||||
- `submit()` → `navigate('/results')`
|
||||
- `exit()` → `navigate('/')`
|
||||
- `reviewAttempt()` → `navigate('/review/${slug}')`
|
||||
- Admin menu item → `navigate('/admin')`
|
||||
- `handleAuthFlowDone()` → `navigate('/')`
|
||||
- `exam` and `examState` remain in React state — no change to storage, grading, or submission logic.
|
||||
|
||||
### `ExamRunner.tsx`
|
||||
|
||||
- Accept an `onNavigate: (i: number) => void` prop, called from `goTo()` after updating `examState`.
|
||||
- `App.tsx` passes a handler that calls `navigate('/exam/${slug}/q${i+1}')`.
|
||||
- ExamRunner has no direct knowledge of React Router — it remains router-agnostic.
|
||||
|
||||
### Exam route handler in `App.tsx`
|
||||
|
||||
- Reads `:n` and `:slug` from `useParams()`.
|
||||
- On mount and whenever `:n` changes (back/forward), calls `setExamState` to set `currentIdx = n - 1`.
|
||||
- Handles the deep-link case: if no active exam in state, checks localStorage for a matching slug; resumes or redirects to `/`.
|
||||
|
||||
### Deep linking
|
||||
|
||||
When the app loads on `/exam/:slug/q:n`:
|
||||
|
||||
- Check localStorage for a saved exam state (`brainwave.exam.state`).
|
||||
- If the saved exam's slugified title matches `:slug`, resume with `currentIdx = n - 1`.
|
||||
- If no match, redirect to `/`.
|
||||
|
||||
### Vite config
|
||||
|
||||
Add `server.historyApiFallback: true` to `vite.config.ts` so the dev server serves `index.html` for all non-asset paths. The production backend already handles this (it serves `index.html` for non-API routes).
|
||||
|
||||
## Data Flow
|
||||
|
||||
```text
|
||||
User clicks question 5 in sidebar
|
||||
→ goTo(4) called in ExamRunner
|
||||
→ setState updates examState.currentIdx = 4
|
||||
→ onNavigate(4) called
|
||||
→ navigate('/exam/my-exam/q5') in App
|
||||
→ URL bar updates
|
||||
→ :n param changes
|
||||
→ useEffect in App exam route calls setExamState [no-op, already 4]
|
||||
|
||||
User presses browser Back
|
||||
→ URL changes to /exam/my-exam/q4
|
||||
→ :n param changes to '4'
|
||||
→ useEffect in App exam route calls setExamState({ currentIdx: 3 })
|
||||
→ ExamRunner re-renders at question 4
|
||||
```
|
||||
|
||||
## What Does Not Change
|
||||
|
||||
- `Results.tsx`, `Landing.tsx`, all question components
|
||||
- `AuthContext`, storage utils, grading utils, API utils
|
||||
- Backend — no changes required
|
||||
- `ExamState` shape and localStorage persistence
|
||||
- `AppScreen` type removed from `types.ts`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Animated route transitions
|
||||
- Scroll restoration between questions
|
||||
- Sharing/bookmarking exam links to others (exam data is client-side only)
|
||||
@@ -1,14 +1,14 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
@@ -19,5 +19,18 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
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
+147
-1
@@ -9,9 +9,13 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"katex": "^0.16.45",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
@@ -227,6 +231,15 @@
|
||||
"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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -1791,6 +1804,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2273,6 +2299,47 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3075,6 +3142,70 @@
|
||||
"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": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
||||
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
|
||||
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -3142,6 +3273,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -3554,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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -13,9 +13,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"katex": "^0.16.45",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
|
||||
+388
-182
@@ -1,62 +1,67 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types'
|
||||
import { gradeQuestion } from './utils/grading'
|
||||
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage'
|
||||
import { apiFetch, isAuthenticated } from './utils/api'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Landing from './components/Landing'
|
||||
import ExamRunner from './components/ExamRunner'
|
||||
import Results from './components/Results'
|
||||
import TweaksPanel from './components/TweaksPanel'
|
||||
import AuthModal from './components/AuthModal'
|
||||
import EmailVerify from './components/EmailVerify'
|
||||
import ResetPassword from './components/ResetPassword'
|
||||
import AdminDashboard from './components/AdminDashboard'
|
||||
|
||||
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useNavigate,
|
||||
useLocation,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import type { Exam, ExamState, HistoryEntry } from "./types";
|
||||
import { gradeQuestion } from "./utils/grading";
|
||||
import {
|
||||
loadState,
|
||||
saveState,
|
||||
clearState,
|
||||
loadHistory,
|
||||
appendHistory,
|
||||
clearHistory,
|
||||
} from "./utils/storage";
|
||||
import { isAuthenticated } from "./utils/api";
|
||||
import * as resultApi from "./api/resultApi";
|
||||
import * as examApi from "./api/examApi";
|
||||
import { getExamUrlId } from "./utils/slug";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { TweaksProvider, useTweaks } from "./contexts/TweaksContext";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import Landing from "./components/Landing";
|
||||
import ExamRoute from "./components/ExamRoute";
|
||||
import Results from "./components/Results";
|
||||
import AuthModal from "./components/AuthModal";
|
||||
import EmailVerify from "./components/EmailVerify";
|
||||
import ResetPassword from "./components/ResetPassword";
|
||||
import AdminDashboard from "./components/AdminDashboard";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Workspace from "./components/Workspace";
|
||||
import ExamEditor from "./components/ExamEditor";
|
||||
|
||||
const withNotes = (state: ExamState): ExamState => ({
|
||||
...state,
|
||||
notes: state.notes ?? '',
|
||||
})
|
||||
notes: state.notes ?? "",
|
||||
});
|
||||
|
||||
function AppInner() {
|
||||
const { user, loading: authLoading, logout } = useAuth()
|
||||
const [screen, setScreen] = useState<AppScreen>('landing')
|
||||
const [exam, setExam] = useState<Exam | null>(null)
|
||||
const [examState, setExamState] = useState<ExamState | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
|
||||
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS)
|
||||
const [tweaksOpen, setTweaksOpen] = useState(false)
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const { t } = useTranslation();
|
||||
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
|
||||
const { tweaks } = useTweaks();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [exam, setExam] = useState<Exam | null>(null);
|
||||
const [examState, setExamState] = useState<ExamState | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const savedExamIdRef = useRef<number | null>(null);
|
||||
|
||||
// Check URL for verify/reset-password routes on mount
|
||||
useEffect(() => {
|
||||
const path = globalThis.location.pathname
|
||||
const search = globalThis.location.search
|
||||
if (path === '/verify' || search.includes('token=') && path === '/verify') {
|
||||
setScreen('verify')
|
||||
} else if (path === '/reset-password') {
|
||||
setScreen('reset-password')
|
||||
} else if (path === '/admin') {
|
||||
setScreen('admin')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load history from API when authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && user && isAuthenticated()) {
|
||||
apiFetch<{ results: Array<{
|
||||
id: number; title: string; score_points: number; total_points: number;
|
||||
correct_count: number; graded_count: number; passing_score: number;
|
||||
elapsed_ms: number; completed_at: number; exam_id: number | null;
|
||||
}> }>('/api/results')
|
||||
resultApi.listResults()
|
||||
.then((data) => {
|
||||
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
|
||||
id: String(r.id),
|
||||
title: r.title,
|
||||
completedAt: r.completed_at * 1000,
|
||||
completedAt: r.completed_at,
|
||||
elapsedMs: r.elapsed_ms,
|
||||
gradedCount: r.graded_count,
|
||||
correctCount: r.correct_count,
|
||||
@@ -64,141 +69,240 @@ function AppInner() {
|
||||
scorePoints: r.score_points,
|
||||
passingScore: r.passing_score,
|
||||
exam: { title: r.title, questions: [] } as unknown as Exam,
|
||||
state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState,
|
||||
state: {
|
||||
currentIdx: 0,
|
||||
answers: {},
|
||||
flags: {},
|
||||
notes: "",
|
||||
startedAt: 0,
|
||||
feedbackMode: "end" as const,
|
||||
} as ExamState,
|
||||
_remoteId: r.id,
|
||||
}))
|
||||
setHistory(apiHistory)
|
||||
}));
|
||||
setHistory(apiHistory);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to localStorage
|
||||
setHistory(loadHistory())
|
||||
})
|
||||
setHistory(loadHistory());
|
||||
});
|
||||
}
|
||||
}, [user, authLoading])
|
||||
}, [user, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset['theme'] = tweaks.theme
|
||||
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' }
|
||||
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
|
||||
}, [tweaks])
|
||||
if (pathname.startsWith("/exam/") && exam && examState)
|
||||
saveState({ exam, state: examState });
|
||||
}, [exam, examState, pathname]);
|
||||
|
||||
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
|
||||
useEffect(() => {
|
||||
if (screen === 'exam' && exam && examState) saveState({ exam, state: examState })
|
||||
}, [exam, examState, screen])
|
||||
if (pathname.startsWith("/review/") && !exam) {
|
||||
const reviewExam = localStorage.getItem('brainwave.review.exam')
|
||||
const reviewState = localStorage.getItem('brainwave.review.state')
|
||||
if (reviewExam && reviewState) {
|
||||
try {
|
||||
const parsedExam = JSON.parse(reviewExam) as Exam
|
||||
const parsedState = JSON.parse(reviewState) as ExamState
|
||||
setExam(parsedExam)
|
||||
setExamState(withNotes(parsedState))
|
||||
localStorage.removeItem('brainwave.review.exam')
|
||||
localStorage.removeItem('brainwave.review.state')
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [pathname, exam]);
|
||||
|
||||
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
||||
setTweaks((t) => ({ ...t, [key]: value }))
|
||||
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
|
||||
(nextState) => {
|
||||
setExamState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return typeof nextState === "function"
|
||||
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
||||
: nextState;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => {
|
||||
setExamState((prev) => {
|
||||
if (!prev) return prev
|
||||
return typeof nextState === 'function'
|
||||
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
||||
: nextState
|
||||
})
|
||||
}
|
||||
const activateExam = (data: Exam, state: ExamState) => {
|
||||
setExam(data);
|
||||
setExamState(withNotes(state));
|
||||
};
|
||||
|
||||
const loadExam = (data: Exam) => {
|
||||
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
|
||||
setExam(data)
|
||||
setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm })
|
||||
setScreen('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 =
|
||||
tweaks.feedbackMode === "auto"
|
||||
? (data.feedbackMode ?? "end")
|
||||
: tweaks.feedbackMode;
|
||||
flushSync(() => {
|
||||
setExam(data);
|
||||
setExamState({
|
||||
currentIdx: 0,
|
||||
answers: {},
|
||||
flags: {},
|
||||
notes: "",
|
||||
revealed: {},
|
||||
startedAt: Date.now(),
|
||||
feedbackMode: fm,
|
||||
});
|
||||
});
|
||||
navigate(`/exam/${getExamUrlId(data)}/q1`);
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
const saved = loadState()
|
||||
if (saved?.exam && saved?.state) { setExam(saved.exam); setExamState(withNotes(saved.state)); setScreen('exam') }
|
||||
}
|
||||
const saved = loadState();
|
||||
if (saved?.exam && saved?.state) {
|
||||
flushSync(() => {
|
||||
setExam(saved.exam);
|
||||
setExamState(withNotes(saved.state));
|
||||
});
|
||||
navigate(
|
||||
`/exam/${getExamUrlId(saved.exam)}/q${saved.state.currentIdx + 1}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!exam || !examState) return
|
||||
const completedAt = Date.now()
|
||||
const elapsedMs = completedAt - examState.startedAt
|
||||
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id]))
|
||||
if (!exam || !examState) return;
|
||||
const completedAt = Date.now();
|
||||
const elapsedMs = completedAt - examState.startedAt;
|
||||
const graded = exam.questions.map((q) =>
|
||||
gradeQuestion(q, examState.answers[q.id]),
|
||||
);
|
||||
const entry: HistoryEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: exam.title,
|
||||
completedAt,
|
||||
elapsedMs,
|
||||
gradedCount: graded.filter((r) => r.status !== 'ungraded').length,
|
||||
gradedCount: graded.filter((r) => r.status !== "ungraded").length,
|
||||
correctCount: graded.filter((r) => r.correct).length,
|
||||
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
|
||||
scorePoints: graded.reduce((a, r) => a + r.points, 0),
|
||||
passingScore: exam.passingScore ?? 60,
|
||||
exam,
|
||||
state: { ...examState, completedAt },
|
||||
}
|
||||
};
|
||||
|
||||
// Save to API if authenticated
|
||||
if (user && isAuthenticated()) {
|
||||
void apiFetch('/api/results', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: entry.title,
|
||||
scorePoints: entry.scorePoints,
|
||||
totalPoints: entry.totalPoints,
|
||||
correctCount: entry.correctCount,
|
||||
gradedCount: entry.gradedCount,
|
||||
passingScore: entry.passingScore,
|
||||
elapsedMs: entry.elapsedMs,
|
||||
notes: entry.state.notes,
|
||||
state: entry.state,
|
||||
exam: entry.exam,
|
||||
}),
|
||||
void resultApi.submitResult({
|
||||
exam_id: savedExamIdRef.current,
|
||||
title: entry.title,
|
||||
score_points: entry.scorePoints,
|
||||
total_points: entry.totalPoints,
|
||||
correct_count: entry.correctCount,
|
||||
graded_count: entry.gradedCount,
|
||||
passing_score: entry.passingScore,
|
||||
elapsed_ms: entry.elapsedMs,
|
||||
state: entry.state as unknown,
|
||||
exam: entry.exam as unknown,
|
||||
}).then((data) => {
|
||||
sessionStorage.setItem('brainwave.pendingResultId', String(data.id));
|
||||
}).catch(() => {
|
||||
// Fallback: save locally
|
||||
appendHistory(entry)
|
||||
})
|
||||
appendHistory(entry);
|
||||
});
|
||||
} else {
|
||||
appendHistory(entry)
|
||||
appendHistory(entry);
|
||||
}
|
||||
|
||||
// Always update local state for immediate display
|
||||
setHistory((prev) => [entry, ...prev])
|
||||
clearState()
|
||||
setExamState((s) => s ? { ...s, completedAt } : s)
|
||||
setScreen('results')
|
||||
}, [exam, examState, user])
|
||||
setHistory((prev) => [entry, ...prev]);
|
||||
clearState();
|
||||
setExamState((s) => (s ? { ...s, completedAt } : s));
|
||||
navigate("/results");
|
||||
}, [exam, examState, user, navigate]);
|
||||
|
||||
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) }
|
||||
const restart = () => { if (exam) loadExam(exam) }
|
||||
const exit = () => {
|
||||
setExam(null);
|
||||
setExamState(null);
|
||||
savedExamIdRef.current = null;
|
||||
sessionStorage.removeItem('brainwave.pendingResultId');
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
if (exam) loadExam(exam);
|
||||
};
|
||||
|
||||
const reviewAttempt = (entry: HistoryEntry) => {
|
||||
// If this is a remote result, fetch full data
|
||||
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId
|
||||
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
|
||||
if (remoteId && user && isAuthenticated()) {
|
||||
void apiFetch<{
|
||||
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number;
|
||||
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number;
|
||||
}>(`/api/results/${remoteId}`)
|
||||
void resultApi.getResult(remoteId)
|
||||
.then((data) => {
|
||||
setExam(data.exam)
|
||||
setExamState(withNotes(data.state))
|
||||
setScreen('review')
|
||||
flushSync(() => {
|
||||
setExam(data.exam as unknown as Exam);
|
||||
setExamState(withNotes(data.state as unknown as ExamState));
|
||||
});
|
||||
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to local data
|
||||
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review')
|
||||
})
|
||||
flushSync(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
});
|
||||
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||
});
|
||||
} else {
|
||||
setExam(entry.exam); setExamState(withNotes(entry.state)); setScreen('review')
|
||||
flushSync(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
});
|
||||
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllHistory = () => {
|
||||
if (confirm('Clear all past attempts?')) {
|
||||
clearHistory()
|
||||
setHistory([])
|
||||
if (confirm(t('landing.clearHistoryConfirm'))) {
|
||||
clearHistory();
|
||||
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 = () => {
|
||||
globalThis.history.replaceState({}, '', '/')
|
||||
setScreen('landing')
|
||||
setAuthModalOpen(true)
|
||||
navigate("/");
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
if (needsSetup) {
|
||||
return (
|
||||
<SetupWizard onComplete={() => void refreshUser()} />
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === 'verify') {
|
||||
if (pathname === "/verify") {
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
@@ -209,10 +313,10 @@ function AppInner() {
|
||||
</header>
|
||||
<EmailVerify onDone={handleAuthFlowDone} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === 'reset-password') {
|
||||
if (pathname === "/reset-password") {
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
@@ -223,7 +327,7 @@ function AppInner() {
|
||||
</header>
|
||||
<ResetPassword onDone={handleAuthFlowDone} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -234,26 +338,40 @@ function AppInner() {
|
||||
<button
|
||||
className="brand-name"
|
||||
onClick={exit}
|
||||
aria-label="Home"
|
||||
style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }}
|
||||
aria-label={t('common.backToHome')}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
BrainWave
|
||||
</button>
|
||||
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
|
||||
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
|
||||
/ {t('common.appTagline')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
{screen === 'exam' && exam && (
|
||||
{pathname.startsWith("/exam/") && exam && (
|
||||
<>
|
||||
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
|
||||
<span className="mono" style={{ fontSize: 12 }}>
|
||||
{exam.title}
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={exit}>
|
||||
{t('exam.exit')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
|
||||
|
||||
{/* Auth buttons */}
|
||||
{!authLoading && !user && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
|
||||
Sign In
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setAuthModalOpen(true)}
|
||||
>
|
||||
{t('auth.signIn')}
|
||||
</button>
|
||||
)}
|
||||
{!authLoading && user && (
|
||||
@@ -261,59 +379,147 @@ function AppInner() {
|
||||
<button
|
||||
className="user-avatar"
|
||||
onClick={() => setUserMenuOpen((o) => !o)}
|
||||
aria-label="User menu"
|
||||
aria-label={t('auth.userMenu')}
|
||||
>
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<div className="user-menu-name">{user.displayName}</div>
|
||||
<div className="user-menu-email">{user.email}</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
{user.role === 'admin' && (
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); setScreen('admin'); globalThis.history.pushState({}, '', '/admin') }}>
|
||||
Admin Dashboard
|
||||
</button>
|
||||
)}
|
||||
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
|
||||
Sign Out
|
||||
</button>
|
||||
<div className="user-menu">
|
||||
<div className="user-menu-header">
|
||||
<div className="user-menu-name">{user.displayName}</div>
|
||||
<div className="user-menu-email">{user.email}</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
navigate("/workspace");
|
||||
}}
|
||||
>
|
||||
{t('auth.myWorkspace')}
|
||||
</button>
|
||||
{user.role === "admin" && (
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
navigate("/admin");
|
||||
}}
|
||||
>
|
||||
{t('auth.adminDashboard')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
{t('auth.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{screen === 'landing' && (
|
||||
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
||||
)}
|
||||
{screen === 'admin' && user?.role === 'admin' && (
|
||||
<AdminDashboard onExit={exit} />
|
||||
)}
|
||||
{screen === 'exam' && exam && examState && (
|
||||
<ExamRunner exam={exam} state={examState} setState={setExamStateStrict} onSubmit={submit} onExit={exit} />
|
||||
)}
|
||||
{(screen === 'results' || screen === 'review') && exam && examState && (
|
||||
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
|
||||
)}
|
||||
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Landing
|
||||
onLoadExam={loadExam}
|
||||
history={history}
|
||||
onResume={resume}
|
||||
onClearHistory={clearAllHistory}
|
||||
onReviewAttempt={reviewAttempt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/exam/:slug/:n"
|
||||
element={
|
||||
<ExamRoute
|
||||
exam={exam}
|
||||
examState={examState}
|
||||
onActivate={activateExam}
|
||||
setExamState={setExamStateStrict}
|
||||
onSubmit={submit}
|
||||
onExit={exit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/results"
|
||||
element={
|
||||
exam && examState ? (
|
||||
<Results
|
||||
exam={exam}
|
||||
state={examState}
|
||||
onRestart={restart}
|
||||
onExit={exit}
|
||||
reviewOnly={false}
|
||||
/>
|
||||
) : user && isAuthenticated() ? (
|
||||
<div className="workspace-empty">{t('common.loading')}</div>
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/review/:slug"
|
||||
element={
|
||||
exam && examState ? (
|
||||
<Results
|
||||
exam={exam}
|
||||
state={examState}
|
||||
onRestart={restart}
|
||||
onExit={exit}
|
||||
reviewOnly={true}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
user?.role === "admin" ? (
|
||||
<AdminDashboard onExit={exit} />
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
|
||||
<Route path="/workspace/:tab" element={<Workspace onLoadExam={loadExam} />} />
|
||||
<Route path="/exam-editor/:id" element={<ExamEditor />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
{authModalOpen && (
|
||||
<AuthModal
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onForgotPassword={() => setScreen('reset-password')}
|
||||
onForgotPassword={() => navigate("/reset-password")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppInner />
|
||||
</AuthProvider>
|
||||
)
|
||||
<ErrorBoundary>
|
||||
<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 { apiFetch } from '../utils/api'
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as adminApi from "../api/adminApi";
|
||||
import type { DashboardStats, SiteSettings, AdminUser } from "../api/adminApi";
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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'
|
||||
type AdminTab = "overview" | "settings" | "users";
|
||||
|
||||
interface Props {
|
||||
onExit: () => void
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
const [tab, setTab] = useState<AdminTab>('overview')
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [settings, setSettings] = useState<SiteSettings | null>(null)
|
||||
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null)
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [usersTotal, setUsersTotal] = useState(0)
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<AdminTab>("overview");
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [settings, setSettings] = useState<SiteSettings | null>(null);
|
||||
const [settingsDraft, setSettingsDraft] = useState<SiteSettings | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [usersTotal, setUsersTotal] = useState(0);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
const [testEmailTo, setTestEmailTo] = useState("");
|
||||
const [testEmailMsg, setTestEmailMsg] = useState<{
|
||||
ok: boolean;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [testEmailSending, setTestEmailSending] = useState(false);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch<DashboardStats>('/api/admin/stats')
|
||||
setStats(data)
|
||||
const data = await adminApi.getStats();
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ settings: SiteSettings }>('/api/admin/settings')
|
||||
setSettings(data.settings)
|
||||
setSettingsDraft(data.settings)
|
||||
const data = await adminApi.getSettings();
|
||||
setSettings(data.settings);
|
||||
setSettingsDraft(data.settings);
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async (search = '') => {
|
||||
const loadUsers = useCallback(async (search = "") => {
|
||||
try {
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const data = await apiFetch<{ users: AdminUser[]; total: number }>(`/api/admin/users${q}`)
|
||||
setUsers(data.users)
|
||||
setUsersTotal(data.total)
|
||||
const data = await adminApi.listUsers(search);
|
||||
setUsers(data.users);
|
||||
setUsersTotal(data.total);
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats()
|
||||
void loadSettings()
|
||||
void loadUsers()
|
||||
}, [loadStats, loadSettings, loadUsers])
|
||||
void loadStats();
|
||||
void loadSettings();
|
||||
void loadUsers();
|
||||
}, [loadStats, loadSettings, loadUsers]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settingsDraft) return
|
||||
setSaving(true)
|
||||
setSaveMsg(null)
|
||||
if (!settingsDraft) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
await apiFetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings: settingsDraft }),
|
||||
})
|
||||
setSettings(settingsDraft)
|
||||
setSaveMsg('Settings saved successfully')
|
||||
setTimeout(() => setSaveMsg(null), 3000)
|
||||
await adminApi.updateSettings(settingsDraft);
|
||||
setSettings(settingsDraft);
|
||||
setSaveMsg(t("admin.settingsSaved"));
|
||||
setTimeout(() => setSaveMsg(null), 3000);
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeRole = async (userId: number, newRole: 'user' | 'admin') => {
|
||||
const handleChangeRole = async (userId: number, newRole: "user" | "admin") => {
|
||||
try {
|
||||
await apiFetch(`/api/admin/users/${userId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
})
|
||||
await loadUsers(userSearch)
|
||||
await adminApi.changeRole(userId, newRole);
|
||||
await loadUsers(userSearch);
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (userId: number) => {
|
||||
const handleDeleteUser = async (userId: number) => {
|
||||
try {
|
||||
await apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
|
||||
setDeleteConfirm(null)
|
||||
await loadUsers(userSearch)
|
||||
await loadStats()
|
||||
await adminApi.deleteUser(userId);
|
||||
setDeleteConfirm(null);
|
||||
await loadUsers(userSearch);
|
||||
await loadStats();
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
if (!settingsDraft) return;
|
||||
setTestEmailSending(true);
|
||||
setTestEmailMsg(null);
|
||||
try {
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
void loadUsers(userSearch)
|
||||
}
|
||||
e.preventDefault();
|
||||
void loadUsers(userSearch);
|
||||
};
|
||||
|
||||
const updateDraft = (key: keyof SiteSettings, value: string) => {
|
||||
setSettingsDraft((d) => d ? { ...d, [key]: value } : d)
|
||||
}
|
||||
setSettingsDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||
};
|
||||
|
||||
const hasChanges = settings && settingsDraft && JSON.stringify(settings) !== JSON.stringify(settingsDraft)
|
||||
const hasChanges =
|
||||
settings &&
|
||||
settingsDraft &&
|
||||
JSON.stringify(settings) !== JSON.stringify(settingsDraft);
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString(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 (
|
||||
<div className="admin">
|
||||
<div className="admin-header">
|
||||
<div className="admin-header-left">
|
||||
<div className="admin-eyebrow">Administration</div>
|
||||
<h1 className="admin-title">Dashboard</h1>
|
||||
<div className="admin-eyebrow">{t("admin.administration")}</div>
|
||||
<h1 className="admin-title">{t("admin.dashboard")}</h1>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
||||
← Back to site
|
||||
{t("admin.backToSite")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="admin-alert admin-alert-error">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="admin-alert-close">×</button>
|
||||
<button onClick={() => setError(null)} className="admin-alert-close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-tabs">
|
||||
{(['overview', 'settings', 'users'] as AdminTab[]).map((t) => (
|
||||
{(["overview", "settings", "users"] as AdminTab[]).map((tName) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`admin-tab${tab === t ? ' active' : ''}`}
|
||||
onClick={() => setTab(t)}
|
||||
key={tName}
|
||||
className={`admin-tab${tab === tName ? " active" : ""}`}
|
||||
onClick={() => setTab(tName)}
|
||||
>
|
||||
<span className="admin-tab-icon">
|
||||
{t === 'overview' ? '◈' : t === 'settings' ? '⚙' : '⊕'}
|
||||
{tName === "overview" ? "◈" : tName === "settings" ? "⚙" : "⊕"}
|
||||
</span>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
{tabLabel(tName)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ─── Overview Tab ─── */}
|
||||
{tab === 'overview' && (
|
||||
{tab === "overview" && (
|
||||
<div className="admin-section fade-in">
|
||||
<div className="admin-stats-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon users-icon">⊕</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">{stats?.totalUsers ?? '—'}</div>
|
||||
<div className="admin-stat-label">Total Users</div>
|
||||
<div className="admin-stat-value">
|
||||
{stats?.totalUsers ?? "—"}
|
||||
</div>
|
||||
<div className="admin-stat-label">{t("admin.totalUsers")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon exams-icon">◇</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">{stats?.totalExams ?? '—'}</div>
|
||||
<div className="admin-stat-label">Saved Exams</div>
|
||||
<div className="admin-stat-value">
|
||||
{stats?.totalExams ?? "—"}
|
||||
</div>
|
||||
<div className="admin-stat-label">{t("admin.savedExams")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon results-icon">✦</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">{stats?.totalResults ?? '—'}</div>
|
||||
<div className="admin-stat-label">Exams Taken</div>
|
||||
<div className="admin-stat-value">
|
||||
{stats?.totalResults ?? "—"}
|
||||
</div>
|
||||
<div className="admin-stat-label">{t("admin.examsTaken")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon avg-icon">%</div>
|
||||
<div className="admin-stat-info">
|
||||
<div className="admin-stat-value">
|
||||
{stats ? `${stats.averageScore}%` : '—'}
|
||||
{stats ? `${stats.averageScore}%` : "—"}
|
||||
</div>
|
||||
<div className="admin-stat-label">Avg. Score</div>
|
||||
<div className="admin-stat-label">{t("admin.avgScore")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>Recent Activity</h3>
|
||||
<h3>{t("admin.recentActivity")}</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body">
|
||||
{!stats?.recentResults?.length && (
|
||||
<div className="admin-empty">No recent activity yet</div>
|
||||
<div className="admin-empty">{t("admin.noRecentActivity")}</div>
|
||||
)}
|
||||
{stats?.recentResults?.map((r) => {
|
||||
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
|
||||
const pct = r.total_points
|
||||
? Math.round((r.score_points / r.total_points) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div key={r.id} className="admin-activity-row">
|
||||
<div className="admin-activity-user">
|
||||
@@ -238,15 +256,23 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
{r.display_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-activity-name">{r.display_name}</div>
|
||||
<div className="admin-activity-name">
|
||||
{r.display_name}
|
||||
</div>
|
||||
<div className="admin-activity-email">{r.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-activity-exam">{r.title}</div>
|
||||
<div className={`score-pill ${pct >= 60 ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||
<div className="admin-activity-date">{formatDate(r.completed_at)}</div>
|
||||
<div
|
||||
className={`score-pill ${pct >= 60 ? "pass" : "fail"}`}
|
||||
>
|
||||
{pct}%
|
||||
</div>
|
||||
<div className="admin-activity-date">
|
||||
{formatDate(r.completed_at)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +280,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
)}
|
||||
|
||||
{/* ─── Settings Tab ─── */}
|
||||
{tab === 'settings' && settingsDraft && (
|
||||
{tab === "settings" && settingsDraft && (
|
||||
<div className="admin-section fade-in">
|
||||
{saveMsg && (
|
||||
<div className="admin-alert admin-alert-success">{saveMsg}</div>
|
||||
@@ -262,25 +288,37 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>General</h3>
|
||||
<h3>{t("admin.general")}</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-site-name">
|
||||
<span className="admin-setting-title">Site Name</span>
|
||||
<span className="admin-setting-desc">The name displayed throughout the application</span>
|
||||
<label
|
||||
className="admin-setting-label"
|
||||
htmlFor="setting-site-name"
|
||||
>
|
||||
<span className="admin-setting-title">{t("admin.siteName")}</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.siteNameDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-site-name"
|
||||
className="input admin-setting-input"
|
||||
value={settingsDraft.site_name}
|
||||
onChange={(e) => updateDraft('site_name', e.target.value)}
|
||||
onChange={(e) => updateDraft("site_name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-max-upload">
|
||||
<span className="admin-setting-title">Max Upload Size (MB)</span>
|
||||
<span className="admin-setting-desc">Maximum file size for exam JSON uploads</span>
|
||||
<label
|
||||
className="admin-setting-label"
|
||||
htmlFor="setting-max-upload"
|
||||
>
|
||||
<span className="admin-setting-title">
|
||||
{t("admin.maxUpload")}
|
||||
</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.maxUploadDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="setting-max-upload"
|
||||
@@ -289,23 +327,32 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
min="1"
|
||||
max="50"
|
||||
value={settingsDraft.max_upload_mb}
|
||||
onChange={(e) => updateDraft('max_upload_mb', e.target.value)}
|
||||
onChange={(e) => updateDraft("max_upload_mb", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-default-theme">
|
||||
<span className="admin-setting-title">Default Theme</span>
|
||||
<span className="admin-setting-desc">Default theme for new visitors</span>
|
||||
<label
|
||||
className="admin-setting-label"
|
||||
htmlFor="setting-default-theme"
|
||||
>
|
||||
<span className="admin-setting-title">{t("admin.defaultTheme")}</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.defaultThemeDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="admin-setting-toggle-group">
|
||||
<button
|
||||
className={`admin-toggle-btn${settingsDraft.default_theme === 'light' ? ' active' : ''}`}
|
||||
onClick={() => updateDraft('default_theme', 'light')}
|
||||
>☀ Light</button>
|
||||
className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
|
||||
onClick={() => updateDraft("default_theme", "light")}
|
||||
>
|
||||
☀ {t("tweaks.light")}
|
||||
</button>
|
||||
<button
|
||||
className={`admin-toggle-btn${settingsDraft.default_theme === 'dark' ? ' active' : ''}`}
|
||||
onClick={() => updateDraft('default_theme', 'dark')}
|
||||
>☾ Dark</button>
|
||||
className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
|
||||
onClick={() => updateDraft("default_theme", "dark")}
|
||||
>
|
||||
☾ {t("tweaks.dark")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,33 +360,53 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h3>Access Control</h3>
|
||||
<h3>{t("admin.accessControl")}</h3>
|
||||
</div>
|
||||
<div className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label">
|
||||
<span className="admin-setting-title">Allow Guest Mode</span>
|
||||
<span className="admin-setting-desc">Let unauthenticated users take exams without signing in</span>
|
||||
<span className="admin-setting-title">{t("admin.allowGuestMode")}</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.allowGuestModeDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={`admin-switch${settingsDraft.allow_guest_mode === 'true' ? ' on' : ''}`}
|
||||
onClick={() => updateDraft('allow_guest_mode', settingsDraft.allow_guest_mode === 'true' ? 'false' : 'true')}
|
||||
className={`admin-switch${settingsDraft.allow_guest_mode === "true" ? " on" : ""}`}
|
||||
onClick={() =>
|
||||
updateDraft(
|
||||
"allow_guest_mode",
|
||||
settingsDraft.allow_guest_mode === "true"
|
||||
? "false"
|
||||
: "true",
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
aria-checked={settingsDraft.allow_guest_mode === 'true'}
|
||||
aria-checked={settingsDraft.allow_guest_mode === "true"}
|
||||
>
|
||||
<span className="admin-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label">
|
||||
<span className="admin-setting-title">Registration Enabled</span>
|
||||
<span className="admin-setting-desc">Allow new users to create accounts</span>
|
||||
<span className="admin-setting-title">
|
||||
{t("admin.registrationEnabled")}
|
||||
</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.registrationEnabledDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={`admin-switch${settingsDraft.registration_enabled === 'true' ? ' on' : ''}`}
|
||||
onClick={() => updateDraft('registration_enabled', settingsDraft.registration_enabled === 'true' ? 'false' : 'true')}
|
||||
className={`admin-switch${settingsDraft.registration_enabled === "true" ? " on" : ""}`}
|
||||
onClick={() =>
|
||||
updateDraft(
|
||||
"registration_enabled",
|
||||
settingsDraft.registration_enabled === "true"
|
||||
? "false"
|
||||
: "true",
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
aria-checked={settingsDraft.registration_enabled === 'true'}
|
||||
aria-checked={settingsDraft.registration_enabled === "true"}
|
||||
>
|
||||
<span className="admin-switch-thumb" />
|
||||
</button>
|
||||
@@ -349,34 +416,229 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
|
||||
<div className="admin-panel">
|
||||
<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 className="admin-panel-body admin-settings-form">
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label">
|
||||
<span className="admin-setting-title">Maintenance Mode</span>
|
||||
<span className="admin-setting-desc">Take the site offline for non-admin users</span>
|
||||
<span className="admin-setting-title">{t("admin.maintenanceMode")}</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.maintenanceModeDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={`admin-switch${settingsDraft.maintenance_mode === 'true' ? ' on' : ''}`}
|
||||
onClick={() => updateDraft('maintenance_mode', settingsDraft.maintenance_mode === 'true' ? 'false' : 'true')}
|
||||
className={`admin-switch${settingsDraft.maintenance_mode === "true" ? " on" : ""}`}
|
||||
onClick={() =>
|
||||
updateDraft(
|
||||
"maintenance_mode",
|
||||
settingsDraft.maintenance_mode === "true"
|
||||
? "false"
|
||||
: "true",
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
aria-checked={settingsDraft.maintenance_mode === 'true'}
|
||||
aria-checked={settingsDraft.maintenance_mode === "true"}
|
||||
>
|
||||
<span className="admin-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
{settingsDraft.maintenance_mode === 'true' && (
|
||||
{settingsDraft.maintenance_mode === "true" && (
|
||||
<div className="admin-setting-row">
|
||||
<label className="admin-setting-label" htmlFor="setting-maint-msg">
|
||||
<span className="admin-setting-title">Maintenance Message</span>
|
||||
<span className="admin-setting-desc">Message displayed to visitors during maintenance</span>
|
||||
<label
|
||||
className="admin-setting-label"
|
||||
htmlFor="setting-maint-msg"
|
||||
>
|
||||
<span className="admin-setting-title">
|
||||
{t("admin.maintenanceMessage")}
|
||||
</span>
|
||||
<span className="admin-setting-desc">
|
||||
{t("admin.maintenanceMessageDesc")}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="setting-maint-msg"
|
||||
className="textarea admin-setting-textarea"
|
||||
value={settingsDraft.maintenance_message}
|
||||
onChange={(e) => updateDraft('maintenance_message', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateDraft("maintenance_message", e.target.value)
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -390,33 +652,37 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
onClick={() => setSettingsDraft(settings)}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Discard Changes
|
||||
{t("admin.discardChanges")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void saveSettings()}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
{saving ? t("admin.sending") : t("admin.saveSettings")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Users Tab ─── */}
|
||||
{tab === 'users' && (
|
||||
{tab === "users" && (
|
||||
<div className="admin-section fade-in">
|
||||
<div className="admin-users-toolbar">
|
||||
<form onSubmit={handleUserSearch} className="admin-search-form">
|
||||
<input
|
||||
className="input admin-search-input"
|
||||
placeholder="Search by name or email…"
|
||||
placeholder={t("admin.searchByNameOrEmail")}
|
||||
value={userSearch}
|
||||
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>
|
||||
<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 className="admin-panel">
|
||||
@@ -424,13 +690,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Verified</th>
|
||||
<th>Exams</th>
|
||||
<th>Results</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
<th>{t("admin.user")}</th>
|
||||
<th>{t("admin.role")}</th>
|
||||
<th>{t("admin.verified")}</th>
|
||||
<th>{t("admin.exams")}</th>
|
||||
<th>{t("results.points")}</th>
|
||||
<th>{t("admin.joined")}</th>
|
||||
<th>{t("admin.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -442,7 +708,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
{u.display_name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-user-name">{u.display_name}</div>
|
||||
<div className="admin-user-name">
|
||||
{u.display_name}
|
||||
</div>
|
||||
<div className="admin-user-email">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,31 +721,50 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-verified-dot ${u.email_verified ? 'yes' : 'no'}`} />
|
||||
<span
|
||||
className={`admin-verified-dot ${u.email_verified ? "yes" : "no"}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="mono">{u.exams_count}</td>
|
||||
<td className="mono">{u.results_count}</td>
|
||||
<td className="admin-date-cell">{formatDate(u.created_at)}</td>
|
||||
<td className="admin-date-cell">
|
||||
{formatDate(u.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-actions">
|
||||
<select
|
||||
className="admin-role-select"
|
||||
value={u.role}
|
||||
onChange={(e) => void changeRole(u.id, e.target.value as 'user' | 'admin')}
|
||||
onChange={(e) =>
|
||||
void handleChangeRole(
|
||||
u.id,
|
||||
e.target.value as "user" | "admin",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">{t("admin.user")}</option>
|
||||
<option value="admin">{t("admin.role")}</option>
|
||||
</select>
|
||||
{deleteConfirm === u.id ? (
|
||||
<div className="admin-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteUser(u.id)}>Confirm</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-sm admin-btn-danger"
|
||||
onClick={() => void handleDeleteUser(u.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(u.id)}
|
||||
title="Delete user"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -488,7 +775,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="admin-empty">No users found</td>
|
||||
<td colSpan={7} className="admin-empty">
|
||||
{t("admin.noUsersFound")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@@ -498,5 +787,5 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ApiError } from '../utils/api'
|
||||
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
type Tab = 'login' | 'register'
|
||||
|
||||
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const { login, register } = useAuth()
|
||||
const [tab, setTab] = useState<Tab>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -34,7 +36,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
await login(email, password)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Login failed')
|
||||
setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -44,7 +46,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('auth.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
@@ -52,7 +54,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const message = await register(email, password, displayName)
|
||||
setSuccess(message)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Registration failed')
|
||||
setError(err instanceof ApiError ? err.message : t('auth.registrationFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -61,17 +63,17 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
return (
|
||||
<div className="auth-overlay" onClick={onClose}>
|
||||
<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="brand-mark" />
|
||||
<h2 className="auth-title">
|
||||
{tab === 'login' ? 'Welcome back' : 'Create account'}
|
||||
{tab === 'login' ? t('auth.welcomeBack') : t('auth.createAccount')}
|
||||
</h2>
|
||||
<p className="auth-subtitle">
|
||||
{tab === 'login'
|
||||
? 'Sign in to access your saved exams and results'
|
||||
: 'Create an account to save your progress'}
|
||||
? t('auth.signInSubtitle')
|
||||
: t('auth.registerSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -80,13 +82,13 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
|
||||
onClick={() => switchTab('login')}
|
||||
>
|
||||
Sign In
|
||||
{t('auth.signIn')}
|
||||
</button>
|
||||
<button
|
||||
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
|
||||
onClick={() => switchTab('register')}
|
||||
>
|
||||
Register
|
||||
{t('auth.register')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +98,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
{!success && tab === 'login' && (
|
||||
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
|
||||
<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
|
||||
id="login-email"
|
||||
className="input"
|
||||
@@ -109,7 +111,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="login-password"
|
||||
className="input"
|
||||
@@ -122,14 +124,14 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
className="auth-forgot"
|
||||
onClick={() => { onClose(); onForgotPassword() }}
|
||||
>
|
||||
Forgot your password?
|
||||
{t('auth.forgotPassword')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -137,7 +139,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
{!success && tab === 'register' && (
|
||||
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
|
||||
<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
|
||||
id="reg-name"
|
||||
className="input"
|
||||
@@ -150,7 +152,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="reg-email"
|
||||
className="input"
|
||||
@@ -163,7 +165,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="reg-password"
|
||||
className="input"
|
||||
@@ -177,7 +179,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="reg-confirm"
|
||||
className="input"
|
||||
@@ -191,7 +193,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating account…' : 'Create Account'}
|
||||
{submitting ? t('auth.creatingAccount') : t('auth.register')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [message, setMessage] = useState('')
|
||||
const called = useRef(false)
|
||||
@@ -19,11 +22,11 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setMessage('No verification token found.')
|
||||
setMessage(t('verify.noToken'))
|
||||
return
|
||||
}
|
||||
|
||||
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
||||
authApi.verifyEmailToken(token)
|
||||
.then((data) => {
|
||||
setStatus('success')
|
||||
setMessage(data.message)
|
||||
@@ -32,9 +35,9 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||
})
|
||||
.catch((err) => {
|
||||
setStatus('error')
|
||||
setMessage(err instanceof ApiError ? err.message : 'Verification failed')
|
||||
setMessage(err instanceof ApiError ? err.message : t('verify.verificationFailed'))
|
||||
})
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="verify-page">
|
||||
@@ -42,27 +45,27 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<div className="verify-icon">⏳</div>
|
||||
<h2 className="verify-title">Verifying your email…</h2>
|
||||
<p className="verify-message">Please wait while we confirm your email address.</p>
|
||||
<h2 className="verify-title">{t('verify.verifying')}</h2>
|
||||
<p className="verify-message">{t('verify.pleaseWait')}</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<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>
|
||||
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Sign In →
|
||||
{t('verify.signIn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<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>
|
||||
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Back to Home
|
||||
{t('verify.backToHome')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use no memo";
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import ExamRunner from './ExamRunner'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { getExamUrlId } from '../utils/slug'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
|
||||
interface Props {
|
||||
exam: Exam | null
|
||||
examState: ExamState | null
|
||||
onActivate: (exam: Exam, state: ExamState) => void
|
||||
setExamState: Dispatch<SetStateAction<ExamState>>
|
||||
onSubmit: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
|
||||
const { slug, n } = useParams<{ slug: string; n: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const questionIdx = Math.max(0, (Number((n ?? '').replace(/^q/, '')) || 1) - 1)
|
||||
|
||||
// Deep link: restore from localStorage if no exam is active on mount
|
||||
useEffect(() => {
|
||||
if (!exam) {
|
||||
const saved = loadState()
|
||||
if (saved?.exam && getExamUrlId(saved.exam) === slug) {
|
||||
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
|
||||
} else {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}
|
||||
// Intentionally runs only on mount — dep changes after mount are handled by the sync effect below
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Sync :n URL param to currentIdx (handles browser back/forward navigation)
|
||||
useEffect(() => {
|
||||
setExamState((s) => s ? { ...s, currentIdx: questionIdx } : s)
|
||||
}, [questionIdx, setExamState])
|
||||
|
||||
if (!exam || !examState) return null
|
||||
|
||||
return (
|
||||
<ExamRunner
|
||||
key={n}
|
||||
exam={exam}
|
||||
state={examState}
|
||||
setState={setExamState}
|
||||
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
|
||||
onSubmit={onSubmit}
|
||||
onExit={onExit}
|
||||
n={n}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +1,150 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { Exam, ExamState, Question } from '../types'
|
||||
import { gradeQuestion } from '../utils/grading'
|
||||
import { formatTime } from '../utils/formatting'
|
||||
import Tex from './Tex'
|
||||
import QuestionMedia from './QuestionMedia'
|
||||
import QuestionRenderer from './questions/QuestionRenderer'
|
||||
"use no memo";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Exam, ExamState, Question } from "../types";
|
||||
import { gradeQuestion } from "../utils/grading";
|
||||
import { formatTime } from "../utils/formatting";
|
||||
import Tex from "./Tex";
|
||||
import QuestionMedia from "./QuestionMedia";
|
||||
import QuestionRenderer from "./questions/QuestionRenderer";
|
||||
|
||||
interface Props {
|
||||
exam: Exam
|
||||
state: ExamState
|
||||
setState: React.Dispatch<React.SetStateAction<ExamState>>
|
||||
onSubmit: () => void
|
||||
onExit: () => void
|
||||
exam: Exam;
|
||||
state: ExamState;
|
||||
setState: React.Dispatch<React.SetStateAction<ExamState>>;
|
||||
onSubmit: () => void;
|
||||
onExit: () => void;
|
||||
onNavigate?: (i: number) => void;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly<Props>) {
|
||||
const { currentIdx, answers, flags, startedAt, feedbackMode } = state
|
||||
const q = exam.questions[currentIdx]
|
||||
const total = exam.questions.length
|
||||
const notes = state.notes ?? ''
|
||||
export default function ExamRunner({
|
||||
exam,
|
||||
state,
|
||||
setState,
|
||||
onSubmit,
|
||||
onExit,
|
||||
onNavigate,
|
||||
n,
|
||||
}: Readonly<Props>) {
|
||||
const { 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 total = exam.questions.length;
|
||||
const notes = state.notes ?? "";
|
||||
|
||||
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id])
|
||||
useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed])
|
||||
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
|
||||
useEffect(() => {
|
||||
setRevealed(!!state.revealed?.[q.id]);
|
||||
}, [currentIdx, q.id, state.revealed]);
|
||||
|
||||
const setAnswer = (val: unknown) =>
|
||||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }))
|
||||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
|
||||
|
||||
const goTo = useCallback((i: number) =>
|
||||
setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) })), [total])
|
||||
const goTo = useCallback(
|
||||
(i: number) => {
|
||||
const next = Math.max(0, Math.min(total - 1, i));
|
||||
setState((s) => ({ ...s, currentIdx: next }));
|
||||
onNavigate?.(next);
|
||||
},
|
||||
[total, onNavigate],
|
||||
);
|
||||
|
||||
const toggleFlag = useCallback(() =>
|
||||
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id])
|
||||
const toggleFlag = useCallback(
|
||||
() =>
|
||||
setState((s) => ({
|
||||
...s,
|
||||
flags: { ...s.flags, [q.id]: !s.flags[q.id] },
|
||||
})),
|
||||
[q.id],
|
||||
);
|
||||
|
||||
const updateNotes = (value: string) =>
|
||||
setState((s) => ({ ...s, notes: value.slice(0, 1000) }))
|
||||
setState((s) => ({ ...s, notes: value.slice(0, 1000) }));
|
||||
|
||||
const checkAnswer = () => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true },
|
||||
}))
|
||||
setRevealed(true)
|
||||
}
|
||||
}));
|
||||
setRevealed(true);
|
||||
};
|
||||
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const elapsedMs = now - startedAt
|
||||
const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null
|
||||
const timeUrgent = remainingMs != null && remainingMs < 60_000
|
||||
const elapsedMs = now - startedAt;
|
||||
const remainingMs = exam.timeLimitMinutes
|
||||
? exam.timeLimitMinutes * 60_000 - elapsedMs
|
||||
: null;
|
||||
const timeUrgent = remainingMs != null && remainingMs < 60_000;
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs != null && remainingMs <= 0) onSubmit()
|
||||
}, [remainingMs, onSubmit])
|
||||
if (remainingMs != null && remainingMs <= 0) onSubmit();
|
||||
}, [remainingMs, onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1)
|
||||
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1)
|
||||
if (e.key === 'f') toggleFlag()
|
||||
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) {
|
||||
const ci = Number.parseInt(e.key, 10) - 1
|
||||
const choices = q.choices
|
||||
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } }))
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
if (e.key === "ArrowRight" || e.key === "j") goTo(currentIdx + 1);
|
||||
if (e.key === "ArrowLeft" || e.key === "k") goTo(currentIdx - 1);
|
||||
if (e.key === "f") toggleFlag();
|
||||
if (/^[1-9]$/.test(e.key) && q.type === "multiple-choice" && q.choices) {
|
||||
const ci = Number.parseInt(e.key, 10) - 1;
|
||||
const choices = q.choices;
|
||||
if (choices[ci])
|
||||
setState((s) => ({
|
||||
...s,
|
||||
answers: { ...s.answers, [q.id]: choices[ci].id },
|
||||
}));
|
||||
}
|
||||
}
|
||||
globalThis.addEventListener('keydown', handler)
|
||||
return () => globalThis.removeEventListener('keydown', handler)
|
||||
}, [currentIdx, q, goTo, toggleFlag, setState])
|
||||
};
|
||||
globalThis.addEventListener("keydown", handler);
|
||||
return () => globalThis.removeEventListener("keydown", handler);
|
||||
}, [currentIdx, q, goTo, toggleFlag, setState]);
|
||||
|
||||
const graded = feedbackMode === 'immediate' && revealed
|
||||
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0)
|
||||
const result = graded ? gradeQuestion(q, answers[q.id]) : null
|
||||
const graded = feedbackMode === "immediate" && revealed;
|
||||
const answered =
|
||||
answers[q.id] != null &&
|
||||
(!Array.isArray(answers[q.id]) || (answers[q.id] as unknown[]).length > 0);
|
||||
const result = graded ? gradeQuestion(q, answers[q.id]) : null;
|
||||
|
||||
const navStatus = (qq: Question): string => {
|
||||
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) {
|
||||
const r = gradeQuestion(qq, answers[qq.id])
|
||||
if (r.status === 'correct') return 'correct'
|
||||
if (r.status === 'wrong') return 'wrong'
|
||||
if (feedbackMode === "immediate" && state.revealed?.[qq.id]) {
|
||||
const r = gradeQuestion(qq, answers[qq.id]);
|
||||
if (r.status === "correct") return "correct";
|
||||
if (r.status === "wrong") return "wrong";
|
||||
// fall through to answered/flagged for ungraded/unanswered revealed states (e.g. essay)
|
||||
}
|
||||
if (flags[qq.id]) return 'flagged'
|
||||
const a = answers[qq.id]
|
||||
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0)) return 'answered'
|
||||
return ''
|
||||
}
|
||||
if (flags[qq.id]) return "flagged";
|
||||
const a = answers[qq.id];
|
||||
if (a != null && (!Array.isArray(a) || (a as unknown[]).length > 0))
|
||||
return "answered";
|
||||
return "";
|
||||
};
|
||||
|
||||
const answeredCount = exam.questions.filter((qq) => {
|
||||
const st = navStatus(qq)
|
||||
return st === 'answered' || st === 'correct' || st === 'wrong'
|
||||
}).length
|
||||
const st = navStatus(qq);
|
||||
return st === "answered" || st === "correct" || st === "wrong";
|
||||
}).length;
|
||||
|
||||
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0)
|
||||
let answerText = ''
|
||||
if (!Array.isArray(q.answer) && typeof q.answer === 'string') {
|
||||
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer
|
||||
const totalPossible = exam.questions.reduce(
|
||||
(acc, qq) => acc + (qq.points ?? 1),
|
||||
0,
|
||||
);
|
||||
let answerText = "";
|
||||
if (!Array.isArray(q.answer) && typeof q.answer === "string") {
|
||||
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer;
|
||||
} else if (Array.isArray(q.answer)) {
|
||||
answerText = q.answer.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ')
|
||||
answerText = q.answer
|
||||
.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,125 +153,229 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
|
||||
<h2 className="sidebar-title">{exam.title}</h2>
|
||||
{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">
|
||||
{exam.questions.map((qq, i) => {
|
||||
const st = navStatus(qq)
|
||||
const st = navStatus(qq);
|
||||
return (
|
||||
<button key={qq.id} className={`nav-item${i === currentIdx ? ' active' : ''}`} onClick={() => goTo(i)}>
|
||||
<span className="nav-item-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="nav-item-title">
|
||||
{qq.category ? `${qq.category} · ` : ''}{qq.type.replace('-', ' ')}
|
||||
<button
|
||||
key={qq.id}
|
||||
className={`nav-item${i === currentIdx ? " active" : ""}`}
|
||||
onClick={() => goTo(i)}
|
||||
>
|
||||
<span className="nav-item-num">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className={`nav-dot${st ? ' ' + st : ''}`} />
|
||||
<span className="nav-item-title">
|
||||
{qq.category ? `${qq.category} · ` : ""}
|
||||
{qq.type.replace("-", " ")}
|
||||
</span>
|
||||
<span className={`nav-dot${st ? " " + st : ""}`} />
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section-label">Progress</div>
|
||||
<div className="mono" style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{answeredCount} of {total} answered
|
||||
<div className="sidebar-section-label">{t('exam.progressLabel')}</div>
|
||||
<div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
|
||||
{t('exam.progressText', { answered: answeredCount, total })}
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 32, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button className="btn btn-primary" onClick={onSubmit}>Submit exam</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onExit}>Exit to home</button>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 32,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={onSubmit}>
|
||||
{t('exam.submitExam')}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onExit}>
|
||||
{t('exam.exitToHome')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<header className="question-header">
|
||||
<span className="q-index">Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}</span>
|
||||
<span className="q-index">
|
||||
{t('exam.questionHeader', { current: String(currentIdx + 1).padStart(2, "0"), total: String(total).padStart(2, "0") })}
|
||||
</span>
|
||||
{q.category && <span className="q-category">{q.category}</span>}
|
||||
<span className="q-points">{q.points ?? 1} {(q.points ?? 1) === 1 ? 'pt' : 'pts'}</span>
|
||||
<span className="q-points">
|
||||
{q.points ?? 1} {(q.points ?? 1) === 1 ? t('common.pt') : t('common.pts')}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<Tex tag="div" className="q-prompt">{q.prompt}</Tex>
|
||||
<Tex tag="div" className="q-prompt">
|
||||
{q.prompt}
|
||||
</Tex>
|
||||
<QuestionMedia q={q} />
|
||||
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={setAnswer} locked={graded && q.type !== 'essay'} graded={graded} />
|
||||
<QuestionRenderer
|
||||
key={q.id}
|
||||
q={q}
|
||||
value={answers[q.id]}
|
||||
onChange={setAnswer}
|
||||
locked={graded && q.type !== "essay"}
|
||||
graded={graded}
|
||||
/>
|
||||
|
||||
{graded && result && q.type !== 'essay' && (
|
||||
<div className={`feedback ${result.correct ? 'correct' : 'wrong'}`}>
|
||||
<div className="feedback-label">{result.correct ? 'Correct' : 'Not quite'}</div>
|
||||
{graded && result && q.type !== "essay" && (
|
||||
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
||||
<div className="feedback-label">
|
||||
{result.correct ? t('exam.correct') : t('exam.notQuite')}
|
||||
</div>
|
||||
<div className="feedback-body">
|
||||
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
|
||||
{!result.correct &&
|
||||
q.answer &&
|
||||
q.type !== "match" &&
|
||||
q.type !== "order" && (
|
||||
<p>
|
||||
<strong>{t('exam.answerLabel')}</strong> <Tex>{answerText}</Tex>
|
||||
</p>
|
||||
)}
|
||||
{q.explanation && (
|
||||
<p>
|
||||
<strong>Answer:</strong>{' '}
|
||||
<Tex>{answerText}</Tex>
|
||||
<Tex>{q.explanation}</Tex>
|
||||
</p>
|
||||
)}
|
||||
{q.explanation && <p><Tex>{q.explanation}</Tex></p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="exam-footer">
|
||||
<button className="btn" onClick={() => goTo(currentIdx - 1)} disabled={currentIdx === 0}>← Previous</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => goTo(currentIdx - 1)}
|
||||
disabled={currentIdx === 0}
|
||||
>
|
||||
{t('exam.previous')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||||
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'}
|
||||
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && (
|
||||
<button className="btn btn-primary" onClick={checkAnswer}>Check answer</button>
|
||||
{feedbackMode === "immediate" &&
|
||||
!revealed &&
|
||||
q.type !== "essay" &&
|
||||
answered && (
|
||||
<button className="btn btn-primary" onClick={checkAnswer}>
|
||||
{t('exam.checkAnswer')}
|
||||
</button>
|
||||
)}
|
||||
{currentIdx < total - 1 ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => goTo(currentIdx + 1)}
|
||||
>
|
||||
{t('exam.next')}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={onSubmit}>
|
||||
{t('exam.submitExam')}
|
||||
</button>
|
||||
)}
|
||||
{currentIdx < total - 1
|
||||
? <button className="btn btn-primary" onClick={() => goTo(currentIdx + 1)}>Next →</button>
|
||||
: <button className="btn btn-primary" onClick={onSubmit}>Submit exam</button>}
|
||||
</footer>
|
||||
|
||||
<div style={{ marginTop: 48, fontSize: 11.5, color: 'var(--ink-4)', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span><span className="kbd">←</span> <span className="kbd">→</span> navigate</span>
|
||||
<span><span className="kbd">F</span> flag</span>
|
||||
{q.type === 'multiple-choice' && q.choices && (
|
||||
<span><span className="kbd">1</span>–<span className="kbd">{q.choices.length}</span> select</span>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 48,
|
||||
fontSize: 11.5,
|
||||
color: "var(--ink-4)",
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
|
||||
{t('exam.navigateHint')}
|
||||
</span>
|
||||
<span>
|
||||
<span className="kbd">F</span> {t('exam.flagHint')}
|
||||
</span>
|
||||
{q.type === "multiple-choice" && q.choices && (
|
||||
<span>
|
||||
<span className="kbd">1</span>–
|
||||
<span className="kbd">{q.choices.length}</span> {t('exam.selectHint')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="meta-rail">
|
||||
<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>
|
||||
{remainingMs != null && (
|
||||
<div className="meta-block">
|
||||
<div className="meta-label">Remaining</div>
|
||||
<div className={`meta-value mono${timeUrgent ? ' timer-urgent' : ''}`}>{formatTime(Math.max(0, remainingMs))}</div>
|
||||
<div className="meta-label">{t('exam.remaining')}</div>
|
||||
<div
|
||||
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
|
||||
>
|
||||
{formatTime(Math.max(0, remainingMs))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<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 }}>
|
||||
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
|
||||
{feedbackMode === "immediate"
|
||||
? t('exam.immediateFeedback')
|
||||
: t('exam.gradeAtEnd')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-block notes-block">
|
||||
<div className="meta-label">Notes</div>
|
||||
<div className="meta-label">{t('exam.notes')}</div>
|
||||
<textarea
|
||||
className="textarea notes-pad"
|
||||
value={notes}
|
||||
onChange={(e) => updateNotes(e.target.value)}
|
||||
placeholder="Add short notes while you work..."
|
||||
placeholder={t('exam.notesPlaceholder')}
|
||||
maxLength={1000}
|
||||
aria-label="Exam notes"
|
||||
aria-label={t('exam.notes')}
|
||||
/>
|
||||
<div className="notes-count mono">{notes.length}/1000</div>
|
||||
</div>
|
||||
{exam.author && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Exam, HistoryEntry } from '../types'
|
||||
import { validateExam, parseExam } from '../utils/validation'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { formatDate, formatTime } from '../utils/formatting'
|
||||
|
||||
interface Props {
|
||||
onLoadExam: (exam: Exam) => void
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void
|
||||
history: HistoryEntry[]
|
||||
onResume: () => void
|
||||
onClearHistory: () => void
|
||||
@@ -19,6 +20,7 @@ const SAMPLES = [
|
||||
]
|
||||
|
||||
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
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 msg = validateExam(data)
|
||||
if (msg) throw new Error(msg)
|
||||
onLoadExam(parseExam(data))
|
||||
onLoadExam(parseExam(data), true)
|
||||
} 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)
|
||||
onLoadExam(parseExam(data))
|
||||
} 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 (
|
||||
<div className="landing">
|
||||
<div className="landing-eyebrow">BrainWave · practice exam platform</div>
|
||||
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1>
|
||||
<div className="landing-eyebrow">{t('landing.eyebrow')}</div>
|
||||
<h1 className="landing-title">{t('landing.title')}<em>{t('landing.titleEmphasis')}</em></h1>
|
||||
<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>
|
||||
|
||||
<div className="landing-grid">
|
||||
@@ -395,9 +397,13 @@ Set a 20-minute time limit. Include explanations for each answer."
|
||||
if (f) void handleFile(f)
|
||||
}}
|
||||
>
|
||||
<div className="dropzone-icon" />
|
||||
<h3>Drop your exam JSON here</h3>
|
||||
<p>or click to browse · max 5 MB</p>
|
||||
<div className="dropzone-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>{t('landing.dropzoneTitle')}</h3>
|
||||
<p>{t('landing.dropzoneSub')}</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
@@ -413,8 +419,8 @@ Set a 20-minute time limit. Include explanations for each answer."
|
||||
onClick={downloadInstructions}
|
||||
id="download-instructions"
|
||||
>
|
||||
<span className="download-instructions-icon">↓</span>
|
||||
Download Instructions — How to create your own exam JSON
|
||||
<span className="download-instructions-icon">{t('landing.downloadInstructionsIcon')}</span>
|
||||
{t('landing.downloadInstructions')}
|
||||
</button>
|
||||
|
||||
{err && (
|
||||
@@ -426,23 +432,23 @@ Set a 20-minute time limit. Include explanations for each answer."
|
||||
{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={{ 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 }}>
|
||||
{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>
|
||||
<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 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>
|
||||
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
|
||||
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>{t('landing.supportedTypes')}</div>
|
||||
{t('landing.supportedTypesList')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="samples">
|
||||
<h4>Try a sample</h4>
|
||||
<h4>{t('landing.trySample')}</h4>
|
||||
{SAMPLES.map((s) => (
|
||||
<button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}>
|
||||
<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 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 }}>
|
||||
Past attempts
|
||||
{t('landing.pastAttempts')}
|
||||
</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>
|
||||
{history.map((h) => {
|
||||
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 className="history-title">{h.title}</div>
|
||||
<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 className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 { apiFetch, ApiError } from '../utils/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiError } from '../utils/api'
|
||||
import * as authApi from '../api/authApi'
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
@@ -8,6 +10,7 @@ interface Props {
|
||||
type Step = 'request' | 'reset' | 'success'
|
||||
|
||||
export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<Step>('request')
|
||||
const [email, setEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -19,9 +22,9 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const t = params.get('token')
|
||||
if (t) {
|
||||
setToken(t)
|
||||
const tk = params.get('token')
|
||||
if (tk) {
|
||||
setToken(tk)
|
||||
setStep('reset')
|
||||
window.history.replaceState({}, '', '/reset-password')
|
||||
}
|
||||
@@ -32,13 +35,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const data = await apiFetch<{ message: string }>('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const data = await authApi.forgotPassword(email)
|
||||
setMessage(data.message)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Request failed')
|
||||
setError(err instanceof ApiError ? err.message : t('resetPassword.requestFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -48,18 +48,15 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('resetPassword.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await apiFetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
})
|
||||
await authApi.resetPassword({ token: token!, newPassword })
|
||||
setStep('success')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Password reset failed')
|
||||
setError(err instanceof ApiError ? err.message : t('resetPassword.passwordResetFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -71,12 +68,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
{step === 'request' && !message && (
|
||||
<>
|
||||
<div className="verify-icon">🔑</div>
|
||||
<h2 className="verify-title">Reset Password</h2>
|
||||
<p className="verify-message">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
<h2 className="verify-title">{t('auth.resetPassword')}</h2>
|
||||
<p className="verify-message">{t('auth.resetPasswordDesc')}</p>
|
||||
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
||||
<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
|
||||
id="reset-email"
|
||||
className="input"
|
||||
@@ -88,10 +85,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
/>
|
||||
</div>
|
||||
<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 type="button" className="auth-forgot" onClick={onDone}>
|
||||
← Back to sign in
|
||||
← {t('auth.backToSignIn')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
@@ -100,10 +97,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
{step === 'request' && message && (
|
||||
<>
|
||||
<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>
|
||||
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Back to Home
|
||||
{t('auth.backToHome')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -111,12 +108,12 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
{step === 'reset' && (
|
||||
<>
|
||||
<div className="verify-icon">🔐</div>
|
||||
<h2 className="verify-title">Set New Password</h2>
|
||||
<p className="verify-message">Enter your new password below.</p>
|
||||
<h2 className="verify-title">{t('auth.setNewPassword')}</h2>
|
||||
<p className="verify-message">{t('auth.enterNewPassword')}</p>
|
||||
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
||||
<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
|
||||
id="new-password"
|
||||
className="input"
|
||||
@@ -129,7 +126,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="confirm-new-password"
|
||||
className="input"
|
||||
@@ -142,7 +139,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Resetting…' : 'Reset Password'}
|
||||
{submitting ? t('auth.resetting') : t('auth.resetPassword')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
@@ -151,10 +148,10 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
{step === 'success' && (
|
||||
<>
|
||||
<div className="verify-icon success">✓</div>
|
||||
<h2 className="verify-title">Password Updated</h2>
|
||||
<p className="verify-message">Your password has been reset successfully. You can now sign in with your new password.</p>
|
||||
<h2 className="verify-title">{t('auth.passwordUpdated')}</h2>
|
||||
<p className="verify-message">{t('auth.passwordUpdatedDesc')}</p>
|
||||
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Sign In →
|
||||
{t('auth.signIn')} →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
import { gradeQuestion } from '../utils/grading'
|
||||
import { formatTime } from '../utils/formatting'
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { answers, startedAt, completedAt } = state
|
||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||
|
||||
@@ -47,28 +49,28 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
||||
return (
|
||||
<div className="results">
|
||||
<div className="results-eyebrow">
|
||||
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}
|
||||
{reviewOnly ? t('results.reviewing') : t('results.examComplete')} · {exam.title}
|
||||
</div>
|
||||
<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)' }}>
|
||||
{passed
|
||||
? `Above the ${passing}% passing mark.`
|
||||
: `Below the ${passing}% passing mark. Keep practicing.`}
|
||||
? t('results.abovePassing', { passing })
|
||||
: t('results.belowPassing', { passing })}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="score-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -76,7 +78,7 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
||||
{Object.keys(byCat).length > 1 && (
|
||||
<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 }}>
|
||||
By category
|
||||
{t('results.byCategory')}
|
||||
</div>
|
||||
{Object.entries(byCat).map(([cat, c]) => {
|
||||
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>
|
||||
)}
|
||||
|
||||
<h2 className="review-heading">Review</h2>
|
||||
<h2 className="review-heading">{t('results.review')}</h2>
|
||||
{graded.map((g, i) => {
|
||||
const promptText = g.q.prompt
|
||||
.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 />
|
||||
{g.q.explanation && (
|
||||
<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>
|
||||
)}
|
||||
{g.q.rubric && (
|
||||
<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)' }}>
|
||||
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
||||
</ul>
|
||||
@@ -135,8 +137,8 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
|
||||
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>Retake exam</button>}
|
||||
<button className="btn" onClick={onExit}>Back to home</button>
|
||||
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>}
|
||||
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
|
||||
</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 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 {
|
||||
id: number
|
||||
@@ -12,6 +14,8 @@ export interface User {
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
needsSetup: boolean
|
||||
setUser: (user: User | null) => void
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, displayName: string) => Promise<string>
|
||||
logout: () => void
|
||||
@@ -23,16 +27,27 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [needsSetup, setNeedsSetup] = useState(false)
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!isAuthenticated()) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
|
||||
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
|
||||
// Always check setup status first
|
||||
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 {
|
||||
clearToken()
|
||||
setUser(null)
|
||||
@@ -46,20 +61,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [refreshUser])
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await apiFetch<{ token: string; user: User }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const data = await authApi.login({ email, password })
|
||||
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 data = await apiFetch<{ message: string }>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
})
|
||||
return data.message
|
||||
await authApi.register({ email, password, displayName })
|
||||
return 'Registration successful! Please check your email to verify your account.'
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
@@ -68,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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
+12
-8
@@ -1,11 +1,15 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
+83
-83
@@ -1,125 +1,125 @@
|
||||
// src/types.ts
|
||||
|
||||
export type QuestionType =
|
||||
| 'multiple-choice'
|
||||
| 'multiple-select'
|
||||
| 'short-answer'
|
||||
| 'translation'
|
||||
| 'math-input'
|
||||
| 'essay'
|
||||
| 'match'
|
||||
| 'order'
|
||||
| "multiple-choice"
|
||||
| "multiple-select"
|
||||
| "short-answer"
|
||||
| "translation"
|
||||
| "math-input"
|
||||
| "essay"
|
||||
| "match"
|
||||
| "order";
|
||||
|
||||
export interface Choice {
|
||||
id: string
|
||||
text: string
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MatchItem {
|
||||
id: string
|
||||
text: string
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface QuestionImage {
|
||||
src: string
|
||||
alt?: string
|
||||
caption?: string
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface QuestionAudio {
|
||||
src: string
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface QuestionCode {
|
||||
language: string
|
||||
content: string
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface QuestionTable {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string
|
||||
type: QuestionType
|
||||
category?: string
|
||||
points?: number
|
||||
prompt: string
|
||||
explanation?: string
|
||||
image?: QuestionImage
|
||||
audio?: QuestionAudio
|
||||
code?: QuestionCode
|
||||
table?: QuestionTable
|
||||
svg?: string
|
||||
answer?: string | string[] | Record<string, string>
|
||||
acceptedForms?: string[]
|
||||
caseSensitive?: boolean
|
||||
ignorePunctuation?: boolean
|
||||
ignoreAccents?: boolean
|
||||
sourceLanguage?: string
|
||||
targetLanguage?: string
|
||||
choices?: Choice[]
|
||||
minWords?: number
|
||||
rubric?: string[]
|
||||
left?: MatchItem[]
|
||||
right?: MatchItem[]
|
||||
items?: MatchItem[]
|
||||
id: string;
|
||||
type: QuestionType;
|
||||
category?: string;
|
||||
points?: number;
|
||||
prompt: string;
|
||||
explanation?: string;
|
||||
image?: QuestionImage;
|
||||
audio?: QuestionAudio;
|
||||
code?: QuestionCode;
|
||||
table?: QuestionTable;
|
||||
svg?: string;
|
||||
answer?: string | string[] | Record<string, string>;
|
||||
acceptedForms?: string[];
|
||||
caseSensitive?: boolean;
|
||||
ignorePunctuation?: boolean;
|
||||
ignoreAccents?: boolean;
|
||||
sourceLanguage?: string;
|
||||
targetLanguage?: string;
|
||||
choices?: Choice[];
|
||||
minWords?: number;
|
||||
rubric?: string[];
|
||||
left?: MatchItem[];
|
||||
right?: MatchItem[];
|
||||
items?: MatchItem[];
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
title: string
|
||||
subtitle?: string
|
||||
author?: string
|
||||
timeLimitMinutes?: number | null
|
||||
feedbackMode?: 'end' | 'immediate'
|
||||
passingScore?: number
|
||||
categories?: string[]
|
||||
questions: Question[]
|
||||
id?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
author?: string;
|
||||
timeLimitMinutes?: number | null;
|
||||
feedbackMode?: "end" | "immediate";
|
||||
passingScore?: number;
|
||||
categories?: string[];
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
export type FeedbackMode = 'immediate' | 'end'
|
||||
export type FeedbackMode = "immediate" | "end";
|
||||
|
||||
export interface ExamState {
|
||||
currentIdx: number
|
||||
answers: Record<string, unknown>
|
||||
flags: Record<string, boolean>
|
||||
notes: string
|
||||
revealed?: Record<string, boolean>
|
||||
startedAt: number
|
||||
completedAt?: number
|
||||
feedbackMode: FeedbackMode
|
||||
currentIdx: number;
|
||||
answers: Record<string, unknown>;
|
||||
flags: Record<string, boolean>;
|
||||
notes: string;
|
||||
revealed?: Record<string, boolean>;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
feedbackMode: FeedbackMode;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
title: string
|
||||
completedAt: number
|
||||
elapsedMs: number
|
||||
gradedCount: number
|
||||
correctCount: number
|
||||
totalPoints: number
|
||||
scorePoints: number
|
||||
passingScore: number
|
||||
exam: Exam
|
||||
state: ExamState
|
||||
id: string;
|
||||
title: string;
|
||||
completedAt: number;
|
||||
elapsedMs: number;
|
||||
gradedCount: number;
|
||||
correctCount: number;
|
||||
totalPoints: number;
|
||||
scorePoints: number;
|
||||
passingScore: number;
|
||||
exam: Exam;
|
||||
state: ExamState;
|
||||
}
|
||||
|
||||
export type GradeStatus = 'correct' | 'wrong' | 'ungraded' | 'unanswered'
|
||||
export type GradeStatus = "correct" | "wrong" | "ungraded" | "unanswered";
|
||||
|
||||
export interface GradeResult {
|
||||
status: GradeStatus
|
||||
correct: boolean
|
||||
points: number
|
||||
status: GradeStatus;
|
||||
correct: boolean;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
export type FontSize = 'small' | 'medium' | 'large'
|
||||
export type FeedbackTweak = 'auto' | 'immediate' | 'end'
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type FontSize = "small" | "medium" | "large";
|
||||
export type FeedbackTweak = "auto" | "immediate" | "end";
|
||||
|
||||
export interface Tweaks {
|
||||
theme: ThemeMode
|
||||
fontSize: FontSize
|
||||
feedbackMode: FeedbackTweak
|
||||
theme: ThemeMode;
|
||||
fontSize: FontSize;
|
||||
feedbackMode: FeedbackTweak;
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
// 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.
|
||||
let result = escaped
|
||||
const result = escaped
|
||||
.replaceAll(/(\/\/[^\n]*|#[^\n]*)/g, (m) => {
|
||||
const i = spans.length
|
||||
spans.push(`<span class="cm-comment">${m}</span>`)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { slugify, getExamUrlId, extractExamSlug } from './slug'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('lowercases', () => {
|
||||
expect(slugify('My Exam')).toBe('my-exam')
|
||||
})
|
||||
it('replaces spaces with hyphens', () => {
|
||||
expect(slugify('hello world')).toBe('hello-world')
|
||||
})
|
||||
it('replaces special chars with hyphens', () => {
|
||||
expect(slugify('Exam: Advanced!')).toBe('exam-advanced')
|
||||
})
|
||||
it('collapses consecutive hyphens', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world')
|
||||
})
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugify('!hello!')).toBe('hello')
|
||||
})
|
||||
it('preserves numbers', () => {
|
||||
expect(slugify('Exam 2024')).toBe('exam-2024')
|
||||
})
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
export function slugify(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function getExamUrlId(exam: { id?: string; title: string }): string {
|
||||
const slug = slugify(exam.title)
|
||||
if (exam.id) return `${slug}-${exam.id}`
|
||||
return slug
|
||||
}
|
||||
|
||||
export function extractExamSlug(examId: string): string {
|
||||
const lastDash = examId.lastIndexOf('-')
|
||||
if (lastDash === -1) return examId
|
||||
// Check if the part after the last dash looks like a generated id (8+ chars of a-z0-9)
|
||||
const suffix = examId.slice(lastDash + 1)
|
||||
if (/^[a-z0-9]{8,}$/.test(suffix)) return examId.slice(0, lastDash)
|
||||
return examId
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
export const parseExam = (data: unknown): Exam => data as Exam
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
export const parseExam = (data: unknown): Exam => {
|
||||
const exam = data as Exam
|
||||
if (!exam.id) {
|
||||
exam.id = generateId()
|
||||
}
|
||||
return exam
|
||||
}
|
||||
+7
-12
@@ -1,23 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
environment: "node",
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
|
||||
});
|
||||
+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