Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a37209f72 | ||
|
|
25e590639a | ||
|
|
7849c295ef | ||
|
|
f6d97baeb5 | ||
|
|
b58500901a | ||
|
|
e906951b06 | ||
|
|
a78fb8efc3 | ||
|
|
a40bfa7838 | ||
|
|
26041cba1e | ||
|
|
76dc09901a | ||
|
|
2715e6f9b3 | ||
|
|
63aa9b572a | ||
|
|
1ec1633dd9 | ||
|
|
7755e77039 | ||
|
|
201d21a678 | ||
|
|
993265318b | ||
|
|
f40f4508fd | ||
|
|
95cf0db5a4 | ||
|
|
028c732716 | ||
|
|
acbcfde617 | ||
|
|
c002f42459 | ||
|
|
6dd87972f6 | ||
|
|
1b5fd04040 | ||
|
|
4935251125 | ||
|
|
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 |
No files matched your search
@@ -33,7 +33,17 @@
|
||||
"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)"
|
||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)",
|
||||
"Bash(grep -E \"\\\\.tsx$\")",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Laurens Bartelds\\\\Documents\\\\Gitea\\\\brainwave\" -Force)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash list)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash pop)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff --stat HEAD)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" checkout -- .claude/settings.local.json)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- frontend/src/components/Overview.tsx)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- backend/src/validators/adminSchemas.ts backend/src/validators/adminSchemas.test.ts)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Deploy ARM to OrangePi
|
||||
name: Deploy ARM to OrangePi
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -21,16 +21,20 @@ jobs:
|
||||
- name: Setup SSH Agent
|
||||
run: |
|
||||
eval "$(ssh-agent -s)"
|
||||
printf '%s' "${{ secrets.ANSIBLE_SSH_KEY }}" | tr -d '\r\n' | base64 -d | ssh-add -
|
||||
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.
|
||||
- name: Add OrangePi to known_hosts
|
||||
run: |
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
ssh-keyscan -H "${{ secrets.ANSIBLE_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Write Inventory
|
||||
run: |
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
|
||||
"${{ secrets.ANSIBLE_HOST }}" \
|
||||
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
||||
|
||||
@@ -55,7 +59,8 @@ 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
|
||||
if: always()
|
||||
|
||||
@@ -17,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
|
||||
@@ -63,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:
|
||||
@@ -77,6 +81,10 @@ jobs:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- brainwave-db:/app/backend/db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
brainwave-db:
|
||||
|
||||
@@ -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
|
||||
+11
-1
@@ -1,4 +1,14 @@
|
||||
backend/brainwave.db-shm
|
||||
backend/brainwave.db-wal
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
.superpowers/
|
||||
backend/db/brainwave.db-shm
|
||||
backend/db/brainwave.db-wal
|
||||
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
|
||||
brainwave.db-shm
|
||||
brainwave.db-wal
|
||||
node_modules/
|
||||
brainwave.db
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
+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
+332
-3
@@ -11,9 +11,14 @@
|
||||
"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",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3"
|
||||
"nodemailer": "^7.0.3",
|
||||
"openai": "^4.95.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
@@ -951,12 +956,21 @@
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||
@@ -1115,6 +1129,18 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -1128,6 +1154,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
||||
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"humanize-ms": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1138,6 +1176,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1311,6 +1355,18 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -1416,6 +1472,15 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1434,6 +1499,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",
|
||||
@@ -1518,6 +1595,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
@@ -1585,6 +1677,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -1647,6 +1748,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",
|
||||
@@ -1692,6 +1808,62 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
|
||||
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-node": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "1.0.0",
|
||||
"web-streams-polyfill": "4.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1820,6 +1992,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
@@ -1832,6 +2019,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1852,6 +2048,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@@ -2414,6 +2619,46 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
@@ -2487,6 +2732,51 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "4.104.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
|
||||
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/openai/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/openai/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -3085,6 +3375,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -3157,7 +3453,6 @@
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -3352,6 +3647,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -3374,6 +3694,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,14 @@
|
||||
"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",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3"
|
||||
"nodemailer": "^7.0.3",
|
||||
"openai": "^4.95.0",
|
||||
"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', '100kb'),
|
||||
|
||||
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,17 +1,26 @@
|
||||
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 = config.dbPath === ':memory:'
|
||||
? ':memory:'
|
||||
: 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) {
|
||||
if (DB_PATH !== ':memory:') {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
}
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
if (DB_PATH !== ':memory:') {
|
||||
db.pragma('journal_mode = WAL')
|
||||
}
|
||||
db.pragma('foreign_keys = ON')
|
||||
runMigrations(db)
|
||||
}
|
||||
|
||||
@@ -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,110 @@ 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'`)
|
||||
// ── Add share_token and is_public columns if missing ──
|
||||
const examCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
||||
if (!examCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_token TEXT`);
|
||||
}
|
||||
if (!examCols.some((c) => c.name === 'is_public')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_public INTEGER DEFAULT 0`);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_exams_share ON saved_exams(share_token);
|
||||
`);
|
||||
|
||||
// ── 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'`);
|
||||
}
|
||||
|
||||
// ── Add storage_quota_bytes if missing ──
|
||||
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
||||
}
|
||||
|
||||
// ── Add plan if missing ──
|
||||
if (!cols.some((c) => c.name === "plan")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
|
||||
// ── Add is_favorite to saved_exams if missing ──
|
||||
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
||||
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_favorite INTEGER DEFAULT 0`);
|
||||
}
|
||||
|
||||
// ── Add tags to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'tags')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN tags TEXT DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
// ── Add collection_id to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'collection_id')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL`);
|
||||
}
|
||||
|
||||
// ── Add share_mode to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'share_mode')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_mode TEXT DEFAULT 'clone'`);
|
||||
}
|
||||
|
||||
// ── Add cloned_from_share_token to saved_exams if missing ──
|
||||
if (!examSavedCols.some((c) => c.name === 'cloned_from_share_token')) {
|
||||
db.exec(`ALTER TABLE saved_exams ADD COLUMN cloned_from_share_token TEXT`);
|
||||
}
|
||||
|
||||
// ── Collections table ──
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
|
||||
`);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
+67
-5
@@ -1,10 +1,20 @@
|
||||
import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { getDb, closeDb } from './db/database.js'
|
||||
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 gradeRoutes from './routes/grade.js'
|
||||
import shareRoutes from './routes/share.js'
|
||||
import collectionRoutes from './routes/collections.js'
|
||||
import aiRoutes from './routes/ai.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,28 +22,80 @@ 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' }))
|
||||
// Security headers
|
||||
app.use(helmet())
|
||||
|
||||
// CORS
|
||||
app.use(cors({ origin: config.corsOrigin }))
|
||||
|
||||
// Body parsing with reasonable limit
|
||||
app.use(express.json({ limit: config.bodyLimit }))
|
||||
|
||||
// Global API rate limiting
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 200,
|
||||
message: { error: 'Too many requests, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
})
|
||||
app.use('/api/', globalLimiter)
|
||||
|
||||
// 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/grade', gradeRoutes)
|
||||
app.use('/api/ai', aiRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/api/share', shareRoutes)
|
||||
app.use('/api/collections', collectionRoutes)
|
||||
|
||||
// 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(),
|
||||
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)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
|
||||
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
|
||||
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -9,6 +10,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 ')) {
|
||||
@@ -18,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
try {
|
||||
const token = header.slice(7)
|
||||
req.user = verifyToken(token)
|
||||
|
||||
// Check if the token has been blacklisted (e.g., after logout)
|
||||
if (isTokenBlacklisted(hashToken(token))) {
|
||||
res.status(401).json({ error: 'Token has been revoked' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' })
|
||||
|
||||
@@ -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,37 @@
|
||||
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 isOperational = err instanceof AppError && err.isOperational;
|
||||
|
||||
// Log full details server-side
|
||||
console.error(`[Error] ${statusCode} - ${err.message}`);
|
||||
if (!isOperational) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Never leak internal error details to the client for unexpected 500s
|
||||
const clientMessage = statusCode === 500 && !isOperational
|
||||
? 'Internal Server Error'
|
||||
: (err.message || 'Internal Server Error');
|
||||
|
||||
res.status(statusCode).json({
|
||||
status: 'error',
|
||||
message: clientMessage,
|
||||
});
|
||||
}
|
||||
@@ -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,55 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface DbCollection {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface CollectionWithCount extends DbCollection {
|
||||
exam_count: number
|
||||
}
|
||||
|
||||
export function createCollection(userId: number, name: string): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collections (user_id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(userId, name)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function getCollectionById(id: number, userId: number): DbCollection | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM collections WHERE id = ? AND user_id = ?').get(id, userId) as DbCollection | undefined
|
||||
}
|
||||
|
||||
export function listCollectionsByUser(userId: number): CollectionWithCount[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT c.*, COUNT(se.id) AS exam_count
|
||||
FROM collections c
|
||||
LEFT JOIN saved_exams se ON se.collection_id = c.id
|
||||
WHERE c.user_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY c.updated_at DESC
|
||||
`).all(userId) as CollectionWithCount[]
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, userId: number, name: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE collections
|
||||
SET name = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(name, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM collections WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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
|
||||
share_token: string | null
|
||||
is_public: number
|
||||
is_favorite: number
|
||||
tags: string | null
|
||||
share_mode: string | null
|
||||
cloned_from_share_token: string | null
|
||||
}
|
||||
|
||||
export interface ExamListItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string = '[]'): { id: number } {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson, tagsJson)
|
||||
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, share_token, is_favorite, tags, collection_id,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_favorite DESC, updated_at DESC
|
||||
`).all(userId) as ExamListItem[]
|
||||
}
|
||||
|
||||
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string | null = null): boolean {
|
||||
const db = getDb()
|
||||
if (tagsJson !== null) {
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, tags = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(title, subtitle, examJson, tagsJson, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
export function setShareToken(id: number, userId: number, token: string, mode: string = 'clone'): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_token = ?, is_public = 1, share_mode = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(token, mode, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function clearShareToken(id: number, userId: number): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_token = NULL, is_public = 0, share_mode = 'clone', updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getExamByShareToken(token: string): DbExam | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM saved_exams WHERE share_token = ? AND is_public = 1').get(token) as DbExam | undefined
|
||||
}
|
||||
|
||||
export function setShareMode(id: number, userId: number, mode: string): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET share_mode = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(mode, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function cloneExam(userId: number, sourceExam: DbExam, shareToken: string): { id: number } {
|
||||
const db = getDb()
|
||||
const parsed = JSON.parse(sourceExam.exam_json)
|
||||
parsed.id = undefined
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, cloned_from_share_token)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), shareToken)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export interface ClonedExamItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
cloned_from_share_token: string
|
||||
}
|
||||
|
||||
export function listClonedExams(userId: number): ClonedExamItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at, cloned_from_share_token,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ? AND cloned_from_share_token IS NOT NULL
|
||||
ORDER BY updated_at DESC
|
||||
`).all(userId) as ClonedExamItem[]
|
||||
}
|
||||
|
||||
export function setFavorite(id: number, userId: number, isFavorite: boolean): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET is_favorite = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(isFavorite ? 1 : 0, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getExamRawJson(id: number, userId: number): { title: string; exam_json: string } | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT title, exam_json FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as { title: string; exam_json: string } | undefined
|
||||
}
|
||||
|
||||
export function duplicateOwnExam(userId: number, sourceExam: DbExam): { id: number } {
|
||||
const db = getDb()
|
||||
const parsed = JSON.parse(sourceExam.exam_json)
|
||||
parsed.id = undefined
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), sourceExam.tags ?? '[]')
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
export function setExamCollection(id: number, userId: number, collectionId: number | null): boolean {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET collection_id = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(collectionId, id, userId)
|
||||
return result.changes > 0
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function getResultsWithExamJson(userId: number, limit = 100): Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
exam_id: number | null
|
||||
}> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT title, score_points, total_points, correct_count, graded_count, completed_at, exam_json, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ?
|
||||
`).all(userId, limit) as Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
exam_id: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ScoreTrend {
|
||||
completed_at: number
|
||||
score_pct: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export function getScoreTrends(userId: number, limit = 30): Array<{
|
||||
completed_at: number
|
||||
score_points: number
|
||||
total_points: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}> {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT completed_at, score_points, total_points, title, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ? AND total_points > 0
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT ?
|
||||
`).all(userId, limit) as Array<{
|
||||
completed_at: number
|
||||
score_points: number
|
||||
total_points: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}>
|
||||
}
|
||||
@@ -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,157 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
// Set env vars before any module that reads them at import time
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests-only'
|
||||
process.env.DB_PATH = ':memory:'
|
||||
|
||||
// Dynamic imports ensure env vars are set before module initialisation
|
||||
const { getDb, closeDb } = await import('../db/database.js')
|
||||
const userRepo = await import('./userRepo.js')
|
||||
const storageRepo = await import('./storageRepo.js')
|
||||
|
||||
describe('storageRepo', () => {
|
||||
it('returns zero usage for a new user', () => {
|
||||
const { id: userId } = userRepo.createUser({
|
||||
email: 'storage-test@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'Storage Test',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const usage = storageRepo.getUserStorageUsage(userId)
|
||||
expect(usage.usedBytes).toBe(0)
|
||||
expect(usage.examBytes).toBe(0)
|
||||
expect(usage.resultBytes).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates exam JSON length correctly', () => {
|
||||
const { id: userId } = userRepo.createUser({
|
||||
email: 'storage-exam@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'Exam Storage',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const db = getDb()
|
||||
const examJson = JSON.stringify({ title: 'Test Exam', questions: [] })
|
||||
db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, exam_json)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(userId, 'Test Exam', examJson)
|
||||
|
||||
const usage = storageRepo.getUserStorageUsage(userId)
|
||||
expect(usage.examBytes).toBe(examJson.length)
|
||||
expect(usage.usedBytes).toBe(examJson.length)
|
||||
})
|
||||
|
||||
it('calculates result JSON length correctly', () => {
|
||||
const { id: userId } = userRepo.createUser({
|
||||
email: 'storage-result@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'Result Storage',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const db = getDb()
|
||||
const stateJson = JSON.stringify({ answers: [1, 2, 3] })
|
||||
const examJson = JSON.stringify({ title: 'Result Exam' })
|
||||
db.prepare(`
|
||||
INSERT INTO exam_results
|
||||
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, 'Result Exam', 10, 20, 5, 10, 60000, stateJson, examJson)
|
||||
|
||||
const usage = storageRepo.getUserStorageUsage(userId)
|
||||
expect(usage.resultBytes).toBe(stateJson.length + examJson.length)
|
||||
expect(usage.usedBytes).toBe(stateJson.length + examJson.length)
|
||||
})
|
||||
|
||||
it('aggregates multiple exams and results', () => {
|
||||
const { id: userId } = userRepo.createUser({
|
||||
email: 'storage-multi@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'Multi Storage',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const db = getDb()
|
||||
const examJson1 = JSON.stringify({ title: 'Exam 1' })
|
||||
const examJson2 = JSON.stringify({ title: 'Exam 2', extra: 'data' })
|
||||
|
||||
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||
.run(userId, 'Exam 1', examJson1)
|
||||
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||
.run(userId, 'Exam 2', examJson2)
|
||||
|
||||
const stateJson = JSON.stringify({ answers: [] })
|
||||
db.prepare(`
|
||||
INSERT INTO exam_results
|
||||
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, examJson1)
|
||||
|
||||
const usage = storageRepo.getUserStorageUsage(userId)
|
||||
expect(usage.examBytes).toBe(examJson1.length + examJson2.length)
|
||||
expect(usage.resultBytes).toBe(stateJson.length + examJson1.length)
|
||||
expect(usage.usedBytes).toBe(examJson1.length + examJson2.length + stateJson.length + examJson1.length)
|
||||
})
|
||||
|
||||
it('lists storage items sorted by size desc', () => {
|
||||
const { id: userId } = userRepo.createUser({
|
||||
email: 'storage-items@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'Items Storage',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const db = getDb()
|
||||
const smallExam = JSON.stringify({ title: 'Small' })
|
||||
const largeExam = JSON.stringify({ title: 'Large Exam', content: 'x'.repeat(100) })
|
||||
const stateJson = JSON.stringify({ a: 'y'.repeat(50) })
|
||||
|
||||
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||
.run(userId, 'Small Exam', smallExam)
|
||||
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||
.run(userId, 'Large Exam', largeExam)
|
||||
db.prepare(`
|
||||
INSERT INTO exam_results
|
||||
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, smallExam)
|
||||
|
||||
const items = storageRepo.listStorageItems(userId)
|
||||
expect(items.length).toBe(3)
|
||||
expect(items[0].title).toBe('Large Exam')
|
||||
expect(items[0].type).toBe('exam')
|
||||
expect(items[1].title).toBe('Result')
|
||||
expect(items[1].type).toBe('result')
|
||||
expect(items[2].title).toBe('Small Exam')
|
||||
expect(items[2].type).toBe('exam')
|
||||
})
|
||||
|
||||
it('does not include other users data', () => {
|
||||
const { id: userA } = userRepo.createUser({
|
||||
email: 'user-a@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'User A',
|
||||
emailVerified: 1,
|
||||
})
|
||||
const { id: userB } = userRepo.createUser({
|
||||
email: 'user-b@example.com',
|
||||
passwordHash: 'hash',
|
||||
displayName: 'User B',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const db = getDb()
|
||||
const examJson = JSON.stringify({ title: 'A Exam' })
|
||||
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||
.run(userA, 'A Exam', examJson)
|
||||
|
||||
const usageA = storageRepo.getUserStorageUsage(userA)
|
||||
const usageB = storageRepo.getUserStorageUsage(userB)
|
||||
|
||||
expect(usageA.usedBytes).toBe(examJson.length)
|
||||
expect(usageB.usedBytes).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface StorageUsage {
|
||||
usedBytes: number
|
||||
examBytes: number
|
||||
resultBytes: number
|
||||
}
|
||||
|
||||
export interface StorageItem {
|
||||
id: number
|
||||
title: string
|
||||
type: 'exam' | 'result'
|
||||
sizeBytes: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function getUserStorageUsage(userId: number): StorageUsage {
|
||||
const db = getDb()
|
||||
const examRow = db.prepare(`
|
||||
SELECT COALESCE(SUM(LENGTH(exam_json)), 0) AS total
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
`).get(userId) as { total: number }
|
||||
|
||||
const resultRow = db.prepare(`
|
||||
SELECT COALESCE(SUM(LENGTH(state_json) + LENGTH(exam_json)), 0) AS total
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
`).get(userId) as { total: number }
|
||||
|
||||
return {
|
||||
usedBytes: examRow.total + resultRow.total,
|
||||
examBytes: examRow.total,
|
||||
resultBytes: resultRow.total,
|
||||
}
|
||||
}
|
||||
|
||||
export function listStorageItems(userId: number): StorageItem[] {
|
||||
const db = getDb()
|
||||
const exams = db.prepare(`
|
||||
SELECT id, title, LENGTH(exam_json) AS size_bytes, created_at
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY size_bytes DESC
|
||||
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
|
||||
|
||||
const results = db.prepare(`
|
||||
SELECT id, title, LENGTH(state_json) + LENGTH(exam_json) AS size_bytes, completed_at AS created_at
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY size_bytes DESC
|
||||
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
|
||||
|
||||
const items: StorageItem[] = [
|
||||
...exams.map((e) => ({ id: e.id, title: e.title, type: 'exam' as const, sizeBytes: e.size_bytes, createdAt: e.created_at })),
|
||||
...results.map((r) => ({ id: r.id, title: r.title, type: 'result' as const, sizeBytes: r.size_bytes, createdAt: r.created_at })),
|
||||
]
|
||||
|
||||
items.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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
|
||||
storage_quota_bytes: number
|
||||
plan: string
|
||||
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
|
||||
storageQuotaBytes?: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
displayName?: string
|
||||
passwordHash?: string
|
||||
role?: 'user' | 'admin'
|
||||
emailVerified?: number
|
||||
verifyToken?: string | null
|
||||
verifyExpires?: number | null
|
||||
resetToken?: string | null
|
||||
resetExpires?: number | null
|
||||
storageQuotaBytes?: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
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, storage_quota_bytes, plan)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.passwordHash,
|
||||
data.displayName.trim(),
|
||||
data.role ?? 'user',
|
||||
data.emailVerified ?? 1,
|
||||
data.verifyToken ?? null,
|
||||
data.verifyExpires ?? null,
|
||||
data.storageQuotaBytes ?? 1073741824,
|
||||
data.plan ?? 'free'
|
||||
)
|
||||
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 getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) 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 (data.storageQuotaBytes !== undefined) {
|
||||
fields.push('storage_quota_bytes = ?')
|
||||
values.push(data.storageQuotaBytes)
|
||||
}
|
||||
if (data.plan !== undefined) {
|
||||
fields.push('plan = ?')
|
||||
values.push(data.plan)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
+146
-167
@@ -1,190 +1,169 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { z } from 'zod'
|
||||
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, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
||||
import { gradeQuestionWithAI } from '../services/aiGrading.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({ settings: 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[] = []
|
||||
// PUT /api/admin/settings
|
||||
router.put(
|
||||
'/settings',
|
||||
validateBody(updateSettingsBatchSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { settings } = req.body
|
||||
const entries = Object.entries(settings) as Array<[string, string]>
|
||||
settingsRepo.setSettingsBatch(entries)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
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' })
|
||||
}
|
||||
})
|
||||
// POST /api/admin/test-ai
|
||||
router.post(
|
||||
'/test-ai',
|
||||
validateBody(z.object({
|
||||
provider: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
baseURL: z.string().optional(),
|
||||
})),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { provider, apiKey, model, baseURL } = req.body
|
||||
const result = await gradeQuestionWithAI(
|
||||
{
|
||||
type: 'short-answer',
|
||||
question: { prompt: 'What is 2+2?', answer: '4' },
|
||||
answer: '4',
|
||||
language: 'en',
|
||||
},
|
||||
{ provider, apiKey, model, baseURL },
|
||||
)
|
||||
res.json(result)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { generateQuestions, getHint, tutorChat } from '../services/aiAssist.js'
|
||||
import { getResultById } from '../repositories/resultRepo.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAuth)
|
||||
|
||||
const generateSchema = z.object({
|
||||
topic: z.string().min(1).max(2000),
|
||||
type: z.enum([
|
||||
'multiple-choice',
|
||||
'multiple-select',
|
||||
'short-answer',
|
||||
'translation',
|
||||
'math-input',
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
count: z.number().int().min(1).max(10),
|
||||
language: z.string().min(1).max(20),
|
||||
context: z.string().max(8000).optional(),
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/generate-question',
|
||||
validateBody(generateSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body as z.infer<typeof generateSchema>
|
||||
const questions = await generateQuestions(body)
|
||||
res.json({ questions })
|
||||
}),
|
||||
)
|
||||
|
||||
const hintSchema = z.object({
|
||||
question: z.record(z.unknown()),
|
||||
language: z.string().min(1).max(20),
|
||||
})
|
||||
|
||||
router.post(
|
||||
'/hint',
|
||||
validateBody(hintSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body as z.infer<typeof hintSchema>
|
||||
const result = await getHint(body)
|
||||
res.json(result)
|
||||
}),
|
||||
)
|
||||
|
||||
const tutorSchema = z.object({
|
||||
resultId: z.number().int().positive(),
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().min(1).max(4000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(40),
|
||||
language: z.string().min(1).max(20),
|
||||
})
|
||||
|
||||
interface TokenBucket {
|
||||
tokens: number
|
||||
lastRefill: number
|
||||
}
|
||||
const TUTOR_RATE_LIMIT = 30
|
||||
const TUTOR_REFILL_MS = 60 * 60 * 1000
|
||||
const tutorBuckets = new Map<number, TokenBucket>()
|
||||
|
||||
function consumeTutorToken(userId: number): boolean {
|
||||
const now = Date.now()
|
||||
const bucket = tutorBuckets.get(userId) ?? { tokens: TUTOR_RATE_LIMIT, lastRefill: now }
|
||||
const elapsed = now - bucket.lastRefill
|
||||
if (elapsed >= TUTOR_REFILL_MS) {
|
||||
bucket.tokens = TUTOR_RATE_LIMIT
|
||||
bucket.lastRefill = now
|
||||
}
|
||||
if (bucket.tokens <= 0) {
|
||||
tutorBuckets.set(userId, bucket)
|
||||
return false
|
||||
}
|
||||
bucket.tokens -= 1
|
||||
tutorBuckets.set(userId, bucket)
|
||||
return true
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/tutor-chat',
|
||||
validateBody(tutorSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body as z.infer<typeof tutorSchema>
|
||||
const userId = req.user!.userId
|
||||
|
||||
if (!consumeTutorToken(userId)) {
|
||||
res.status(429).json({ error: 'Tutor chat rate limit reached. Please try again later.' })
|
||||
return
|
||||
}
|
||||
|
||||
const result = getResultById(body.resultId, userId)
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
}
|
||||
|
||||
let exam: Record<string, unknown> = {}
|
||||
let state: Record<string, unknown> = {}
|
||||
try {
|
||||
exam = JSON.parse(result.exam_json)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Stored exam data is corrupted' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
state = JSON.parse(result.state_json)
|
||||
} catch {
|
||||
state = {}
|
||||
}
|
||||
|
||||
const reply = await tutorChat({
|
||||
exam,
|
||||
state,
|
||||
scoreSummary: {
|
||||
title: result.title,
|
||||
scorePoints: result.score_points,
|
||||
totalPoints: result.total_points,
|
||||
correctCount: result.correct_count,
|
||||
gradedCount: result.graded_count,
|
||||
},
|
||||
messages: body.messages,
|
||||
language: body.language,
|
||||
})
|
||||
|
||||
res.json(reply)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
+154
-236
@@ -1,248 +1,166 @@
|
||||
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 { validateQuery } 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 { hashToken } from '../utils/tokens.js'
|
||||
import { blacklistToken } from '../utils/tokenBlacklist.js'
|
||||
import {
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
updateProfileSchema,
|
||||
changePasswordSchema,
|
||||
verifyEmailSchema,
|
||||
resendVerificationSchema,
|
||||
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' })
|
||||
}
|
||||
const resendVerificationLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 1,
|
||||
message: { error: 'Please wait a minute before requesting another verification email' },
|
||||
standardHeaders: true,
|
||||
skipSuccessfulRequests: false,
|
||||
})
|
||||
|
||||
// ─── 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',
|
||||
validateQuery(verifyEmailSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.verifyEmail(req.query as { token: string })
|
||||
res.json({ message: 'Email verified successfully' })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/auth/resend-verification
|
||||
router.post(
|
||||
'/resend-verification',
|
||||
resendVerificationLimiter,
|
||||
validateBody(resendVerificationSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.resendVerification(req.body.email)
|
||||
res.json({ message: 'If the account exists, a new verification link has been sent' })
|
||||
})
|
||||
)
|
||||
|
||||
// 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' })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post(
|
||||
'/logout',
|
||||
requireAuth,
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const header = req.headers.authorization
|
||||
if (header?.startsWith('Bearer ')) {
|
||||
const token = header.slice(7)
|
||||
const tokenHash = hashToken(token)
|
||||
// Blacklist the token so it cannot be reused after logout
|
||||
// Expiry is 24h from now (matching JWT expiry)
|
||||
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express'
|
||||
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 collectionRepo from '../repositories/collectionRepo.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import {
|
||||
createCollectionSchema,
|
||||
updateCollectionSchema,
|
||||
collectionIdParamSchema,
|
||||
moveExamSchema,
|
||||
} from '../validators/collectionSchemas.js'
|
||||
import { examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
|
||||
// GET /api/collections
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const collections = collectionRepo.listCollectionsByUser(req.user!.userId)
|
||||
res.json(collections)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/collections
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(createCollectionSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const { id } = collectionRepo.createCollection(req.user!.userId, req.body.name)
|
||||
res.status(201).json({ id, name: req.body.name })
|
||||
})
|
||||
)
|
||||
|
||||
// PUT /api/collections/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
validateParams(collectionIdParamSchema),
|
||||
validateBody(updateCollectionSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = collectionRepo.updateCollection(id, req.user!.userId, req.body.name)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// DELETE /api/collections/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateParams(collectionIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const deleted = collectionRepo.deleteCollection(id, req.user!.userId)
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/collections/move/:examId
|
||||
router.post(
|
||||
'/move/:examId',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(moveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const examId = Number(req.params.examId)
|
||||
const collectionId = req.body.collectionId ?? null
|
||||
|
||||
// Verify exam belongs to user
|
||||
const exam = examRepo.getExamById(examId, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
|
||||
// If collectionId provided, verify it belongs to user
|
||||
if (collectionId !== null) {
|
||||
const collection = collectionRepo.getCollectionById(collectionId, req.user!.userId)
|
||||
if (!collection) {
|
||||
throw new AppError(404, 'Collection not found')
|
||||
}
|
||||
}
|
||||
|
||||
examRepo.setExamCollection(examId, req.user!.userId, collectionId)
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
+203
-82
@@ -1,99 +1,220 @@
|
||||
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, favoriteSchema, shareModeSchema, batchDeleteSchema } from '../validators/examSchemas.js'
|
||||
import { sanitizeExam } from '../utils/sanitizeExam.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')
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 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
|
||||
const parsedExam = JSON.parse(exam.exam_json)
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = JSON.parse(exam.tags ?? '[]')
|
||||
if (!Array.isArray(tags)) tags = []
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
res.json({ ...parsedExam, id: exam.id, tags })
|
||||
})
|
||||
)
|
||||
|
||||
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))
|
||||
// POST /api/exams
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(saveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const tags = Array.isArray(req.body.tags) ? req.body.tags : []
|
||||
const { id } = examRepo.createExam(
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam),
|
||||
JSON.stringify(tags)
|
||||
)
|
||||
res.status(201).json({ id, title: exam.title })
|
||||
})
|
||||
)
|
||||
|
||||
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
|
||||
// PUT /api/exams/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(updateExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const tags = Array.isArray(req.body.tags) ? JSON.stringify(req.body.tags) : null
|
||||
const updated = examRepo.updateExam(
|
||||
id,
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
exam.subtitle ?? null,
|
||||
JSON.stringify(exam),
|
||||
tags
|
||||
)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ message: 'Exam deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
// 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({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/share
|
||||
router.post(
|
||||
'/:id/share',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const token = exam.share_token ?? crypto.randomUUID().replace(/-/g, '')
|
||||
const mode = exam.share_mode ?? 'clone'
|
||||
examRepo.setShareToken(id, req.user!.userId, token, mode)
|
||||
res.json({ shareToken: token, mode })
|
||||
})
|
||||
)
|
||||
|
||||
// PUT /api/exams/:id/share-mode
|
||||
router.put(
|
||||
'/:id/share-mode',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(shareModeSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = examRepo.setShareMode(id, req.user!.userId, req.body.mode)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/cloned
|
||||
router.get(
|
||||
'/cloned',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exams = examRepo.listClonedExams(req.user!.userId)
|
||||
res.json(exams)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/batch-delete
|
||||
router.post(
|
||||
'/batch-delete',
|
||||
validateBody(batchDeleteSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const ids: number[] = req.body.ids
|
||||
let deleted = 0
|
||||
for (const id of ids) {
|
||||
if (examRepo.deleteExam(id, req.user!.userId)) {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
res.json({ deleted })
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/unshare
|
||||
router.post(
|
||||
'/:id/unshare',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const cleared = examRepo.clearShareToken(id, req.user!.userId)
|
||||
if (!cleared) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// PATCH /api/exams/:id/favorite
|
||||
router.patch(
|
||||
'/:id/favorite',
|
||||
validateParams(examIdParamSchema),
|
||||
validateBody(favoriteSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const updated = examRepo.setFavorite(id, req.user!.userId, req.body.isFavorite)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/exams/:id/export
|
||||
router.get(
|
||||
'/:id/export',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const raw = examRepo.getExamRawJson(id, req.user!.userId)
|
||||
if (!raw) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(raw.title)}.json"`)
|
||||
res.send(raw.exam_json)
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/exams/:id/duplicate
|
||||
router.post(
|
||||
'/:id/duplicate',
|
||||
validateParams(examIdParamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = examRepo.getExamById(id, req.user!.userId)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const { id: newId } = examRepo.duplicateOwnExam(req.user!.userId, exam)
|
||||
res.status(201).json({ id: newId, title: exam.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { gradeQuestionWithAI } from '../services/aiGrading.js'
|
||||
|
||||
const gradeRequestSchema = z.object({
|
||||
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
|
||||
question: z.record(z.unknown()),
|
||||
answer: z.unknown(),
|
||||
language: z.string().min(1),
|
||||
})
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
validateBody(gradeRequestSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { type, question, answer, language } = req.body as z.infer<typeof gradeRequestSchema>
|
||||
const result = await gradeQuestionWithAI({ type, question, answer, language })
|
||||
res.json(result)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
+110
-120
@@ -1,139 +1,129 @@
|
||||
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 * as examRepo from '../repositories/examRepo.js'
|
||||
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
|
||||
import { analyzeResults } from '../services/studyCoach.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)
|
||||
// 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({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
// GET /api/results/analyze
|
||||
router.get(
|
||||
'/analyze',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const rawResults = resultRepo.getResultsWithExamJson(req.user!.userId, 200)
|
||||
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
|
||||
const analysis = await analyzeResults(rawResults, lang)
|
||||
|
||||
// Find weakest exam (lowest score that still exists in workspace)
|
||||
let weakestExamId: number | null = null
|
||||
if (rawResults.length > 0) {
|
||||
const sorted = [...rawResults].sort((a, b) => {
|
||||
const pa = a.total_points > 0 ? a.score_points / a.total_points : 0
|
||||
const pb = b.total_points > 0 ? b.score_points / b.total_points : 0
|
||||
return pa - pb
|
||||
})
|
||||
for (const r of sorted) {
|
||||
if (r.exam_id) {
|
||||
const exam = examRepo.getExamById(r.exam_id, req.user!.userId)
|
||||
if (exam) {
|
||||
weakestExamId = r.exam_id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Result deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
res.json({ ...analysis, weakestExamId })
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/trends
|
||||
router.get(
|
||||
'/trends',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const raw = resultRepo.getScoreTrends(req.user!.userId, 30)
|
||||
const trends = raw.map((r) => ({
|
||||
completed_at: r.completed_at,
|
||||
score_pct: r.total_points > 0 ? Math.round((r.score_points / r.total_points) * 100) : 0,
|
||||
title: r.title,
|
||||
exam_id: r.exam_id,
|
||||
}))
|
||||
res.json(trends)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,117 @@
|
||||
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 { getDb } from '../db/database.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_name') ?? 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) => {
|
||||
const db = getDb()
|
||||
|
||||
// Atomic transaction prevents race conditions between concurrent setup requests
|
||||
const tx = db.transaction(() => {
|
||||
const title = settingsRepo.getSetting('site_name')
|
||||
if (title) {
|
||||
throw new AppError(400, 'Setup already completed')
|
||||
}
|
||||
|
||||
const userCount = userRepo.countUsers()
|
||||
if (userCount > 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 = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
|
||||
const { id } = userRepo.createUser({
|
||||
email,
|
||||
passwordHash,
|
||||
displayName,
|
||||
role: 'admin',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const settings: Array<[string, string]> = [
|
||||
['site_name', 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)
|
||||
|
||||
return { id, email, displayName }
|
||||
})
|
||||
|
||||
const { id, email, displayName } = tx()
|
||||
|
||||
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,45 @@
|
||||
import { Router } from 'express'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/share/:token — public, no auth required
|
||||
router.get(
|
||||
'/:token',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const token = String(req.params.token)
|
||||
const exam = examRepo.getExamByShareToken(token)
|
||||
if (!exam) {
|
||||
throw new AppError(404, 'Shared exam not found')
|
||||
}
|
||||
const parsed = JSON.parse(exam.exam_json)
|
||||
res.json({
|
||||
title: exam.title,
|
||||
subtitle: exam.subtitle,
|
||||
questionCount: JSON.parse(exam.exam_json).questions?.length ?? 0,
|
||||
exam: parsed,
|
||||
shareMode: exam.share_mode ?? 'clone',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/share/:token/clone — requires auth
|
||||
router.post(
|
||||
'/:token/clone',
|
||||
requireAuth,
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const token = String(req.params.token)
|
||||
const source = examRepo.getExamByShareToken(token)
|
||||
if (!source) {
|
||||
throw new AppError(404, 'Shared exam not found')
|
||||
}
|
||||
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
|
||||
res.status(201).json({ id, title: source.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,320 @@
|
||||
import { z } from 'zod'
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
|
||||
export type QuestionType =
|
||||
| 'multiple-choice'
|
||||
| 'multiple-select'
|
||||
| 'short-answer'
|
||||
| 'translation'
|
||||
| 'math-input'
|
||||
| 'essay'
|
||||
| 'match'
|
||||
| 'order'
|
||||
| 'fill-in-the-blanks'
|
||||
| 'true-false'
|
||||
| 'code-submission'
|
||||
| 'dropdown-completion'
|
||||
| 'numeric-range'
|
||||
| 'dictation'
|
||||
| 'grammar-correction'
|
||||
| 'flashcard'
|
||||
|
||||
const choiceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const matchItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const generatedQuestionSchema = z.object({
|
||||
type: z.enum([
|
||||
'multiple-choice',
|
||||
'multiple-select',
|
||||
'short-answer',
|
||||
'translation',
|
||||
'math-input',
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
prompt: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
points: z.number().int().positive().optional(),
|
||||
explanation: z.string().optional(),
|
||||
answer: z.union([z.string(), z.array(z.string()), z.record(z.string())]).optional(),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
sourceLanguage: z.string().optional(),
|
||||
targetLanguage: z.string().optional(),
|
||||
choices: z.array(choiceSchema).optional(),
|
||||
minWords: z.number().int().nonnegative().optional(),
|
||||
rubric: z.array(z.string()).optional(),
|
||||
left: z.array(matchItemSchema).optional(),
|
||||
right: z.array(matchItemSchema).optional(),
|
||||
items: z.array(matchItemSchema).optional(),
|
||||
textSegments: z.array(z.string()).optional(),
|
||||
blanks: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
acceptedAnswers: z.array(z.string()),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
ignoreAccents: z.boolean().optional(),
|
||||
}),
|
||||
).optional(),
|
||||
dropdowns: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
options: z.array(choiceSchema),
|
||||
correctOptionId: z.string(),
|
||||
}),
|
||||
).optional(),
|
||||
codeConfig: z.object({
|
||||
language: z.string(),
|
||||
starterCode: z.string().optional(),
|
||||
}).optional(),
|
||||
numericConfig: z.object({
|
||||
answer: z.number(),
|
||||
tolerance: z.number(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
}).optional(),
|
||||
textToCorrect: z.string().optional(),
|
||||
})
|
||||
|
||||
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
|
||||
|
||||
export interface GenerateQuestionsRequest {
|
||||
topic: string
|
||||
type: QuestionType
|
||||
count: number
|
||||
language: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
function shapeHintForType(type: QuestionType): string {
|
||||
switch (type) {
|
||||
case 'multiple-choice':
|
||||
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"; provide 3-4 plausible distractors), answer (string id of the correct choice).`
|
||||
case 'multiple-select':
|
||||
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"), answer (array of correct choice ids).`
|
||||
case 'short-answer':
|
||||
return `Required fields: prompt (string), answer (string), acceptedForms (array of acceptable alternate strings, may be empty), caseSensitive (boolean, usually false).`
|
||||
case 'translation':
|
||||
return `Required fields: prompt (the source text), sourceLanguage (e.g. "en"), targetLanguage (e.g. "es"), answer (the canonical translation), acceptedForms (other valid translations).`
|
||||
case 'math-input':
|
||||
return `Required fields: prompt (string, may include LaTeX), answer (LaTeX string), acceptedForms (array of equivalent LaTeX forms).`
|
||||
case 'essay':
|
||||
return `Required fields: prompt (string), minWords (integer, e.g. 100), rubric (array of 3-5 short rubric criteria strings).`
|
||||
case 'match':
|
||||
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
|
||||
case 'order':
|
||||
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
|
||||
case 'fill-in-the-blanks':
|
||||
return `Required fields: prompt (string), textSegments (array of text strings to interleave with blanks), blanks (array of {id, acceptedAnswers} where acceptedAnswers is an array of correct strings for that blank). The number of textSegments must be blanks.length + 1.`
|
||||
case 'true-false':
|
||||
return `Required fields: prompt (string), answer (boolean, true or false).`
|
||||
case 'code-submission':
|
||||
return `Required fields: prompt (string), codeConfig ({language: string, starterCode?: string}), rubric (array of 3-5 short rubric criteria strings).`
|
||||
case 'dropdown-completion':
|
||||
return `Required fields: prompt (string), textSegments (array of text strings to interleave with dropdowns), dropdowns (array of {id, options: [{id, text}], correctOptionId}). The number of textSegments must be dropdowns.length + 1.`
|
||||
case 'numeric-range':
|
||||
return `Required fields: prompt (string), numericConfig ({answer: number, tolerance: number, min?: number, max?: number}), answer (same number as numericConfig.answer).`
|
||||
case 'dictation':
|
||||
return `Required fields: prompt (string), audio ({src: string, a URL to an audio file}), answer (the correct text), acceptedForms (array of acceptable alternate strings), caseSensitive (boolean, usually false).`
|
||||
case 'grammar-correction':
|
||||
return `Required fields: prompt (string), textToCorrect (the sentence with errors to fix), answer (the corrected sentence), acceptedForms (array of acceptable corrected forms), caseSensitive (boolean, usually false).`
|
||||
case 'flashcard':
|
||||
return `Required fields: prompt (the front of the card, e.g. a word or phrase), answer (the back of the card, e.g. translation or definition), explanation (optional, extra info to show after reveal).`
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<GeneratedQuestion[]> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const count = Math.max(1, Math.min(10, Math.floor(req.count)))
|
||||
const model = getAIModel()
|
||||
const shape = shapeHintForType(req.type)
|
||||
|
||||
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic.
|
||||
|
||||
Topic: ${req.topic}
|
||||
${req.context ? `\nAdditional context / source material:\n${req.context}\n` : ''}
|
||||
Write all user-facing text (prompts, choices, rubric items, explanations) in ${req.language}.
|
||||
|
||||
${shape}
|
||||
|
||||
Respond ONLY with a JSON object of the form:
|
||||
{ "questions": [ ... ${count} question object(s) ... ] }
|
||||
|
||||
Each question object must have "type": "${req.type}" and the fields listed above. Do not include "id" — that will be assigned later. Do not include any text outside the JSON object.`
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.6,
|
||||
max_completion_tokens: 1800,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from AI provider')
|
||||
}
|
||||
|
||||
const obj = parsed as { questions?: unknown }
|
||||
if (!Array.isArray(obj.questions)) {
|
||||
throw new Error('AI response did not include a "questions" array')
|
||||
}
|
||||
|
||||
const validated: GeneratedQuestion[] = []
|
||||
for (const q of obj.questions) {
|
||||
const result = generatedQuestionSchema.safeParse(q)
|
||||
if (result.success && result.data.type === req.type) {
|
||||
validated.push(result.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (validated.length === 0) {
|
||||
throw new Error('AI returned no valid questions for the requested type')
|
||||
}
|
||||
return validated
|
||||
}
|
||||
|
||||
export interface HintRequest {
|
||||
question: Record<string, unknown>
|
||||
language: string
|
||||
}
|
||||
|
||||
const HINT_STRIP_KEYS = ['answer', 'acceptedForms']
|
||||
|
||||
function stripAnswerFields(question: Record<string, unknown>): Record<string, unknown> {
|
||||
const clone: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(question)) {
|
||||
if (HINT_STRIP_KEYS.includes(k)) continue
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
export async function getHint(req: HintRequest): Promise<{ hint: string }> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const safeQuestion = stripAnswerFields(req.question)
|
||||
const model = getAIModel()
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Question:\n${JSON.stringify(safeQuestion)}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 160,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from AI provider')
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const hint = typeof obj.hint === 'string' ? obj.hint.trim() : ''
|
||||
if (!hint) {
|
||||
throw new Error('AI did not return a hint')
|
||||
}
|
||||
return { hint }
|
||||
}
|
||||
|
||||
export interface TutorChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface TutorChatRequest {
|
||||
exam: Record<string, unknown>
|
||||
state: Record<string, unknown>
|
||||
scoreSummary: {
|
||||
title: string
|
||||
scorePoints: number
|
||||
totalPoints: number
|
||||
correctCount: number
|
||||
gradedCount: number
|
||||
}
|
||||
messages: TutorChatMessage[]
|
||||
language: string
|
||||
}
|
||||
|
||||
export async function tutorChat(req: TutorChatRequest): Promise<{ reply: string }> {
|
||||
const client = getAIClient()
|
||||
if (!client) {
|
||||
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const model = getAIModel()
|
||||
const recent = req.messages.slice(-10)
|
||||
|
||||
const systemPrompt = `You are a friendly, concise tutor reviewing a completed exam attempt with a student. Reply in ${req.language}. Keep answers short (under 200 words). When the student references a question by number, use the exam's question order. You may explain mistakes, give similar practice problems, or clarify rubric criteria. You may reference the student's actual answers and the AI feedback already produced. Never invent score numbers — use only what's in the exam summary.
|
||||
|
||||
Exam summary:
|
||||
- Title: ${req.scoreSummary.title}
|
||||
- Score: ${req.scoreSummary.scorePoints}/${req.scoreSummary.totalPoints}
|
||||
- Correct: ${req.scoreSummary.correctCount}/${req.scoreSummary.gradedCount}
|
||||
|
||||
Exam (JSON): ${JSON.stringify(req.exam).slice(0, 8000)}
|
||||
|
||||
Student's submission state (answers, flags, etc., JSON): ${JSON.stringify(req.state).slice(0, 4000)}`
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recent.map((m) => ({ role: m.role, content: m.content })),
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_completion_tokens: 600,
|
||||
})
|
||||
|
||||
const reply = completion.choices[0]?.message?.content?.trim()
|
||||
if (!reply) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
return { reply }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import OpenAI from 'openai'
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
|
||||
export interface AIClientOverride {
|
||||
provider?: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export function getAIClient(override?: AIClientOverride): OpenAI | null {
|
||||
const provider = override?.provider ?? getSetting('ai_provider')
|
||||
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
|
||||
if (!provider || !apiKey) return null
|
||||
|
||||
const customBase = override?.baseURL ?? getSetting('ai_base_url')
|
||||
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
|
||||
return new OpenAI({ apiKey, baseURL })
|
||||
}
|
||||
|
||||
export function getAIModel(override?: string): string {
|
||||
return override || getSetting('ai_model') || 'gpt-4o-mini'
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
|
||||
export interface GradingResult {
|
||||
suggestedGrade: number
|
||||
feedback: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
interface GradeRequest {
|
||||
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
|
||||
question: Record<string, unknown>
|
||||
answer: unknown
|
||||
language: string
|
||||
}
|
||||
|
||||
function buildPrompt(req: GradeRequest): string {
|
||||
const { type, question, answer, language } = req
|
||||
|
||||
const base = `You are an exam grading assistant. Grade the student's answer and respond ONLY with a JSON object containing:
|
||||
- "suggestedGrade": number between 0.0 and 1.0
|
||||
- "feedback": string with 1-3 sentences explaining the grade in ${language}
|
||||
- "detail": type-specific breakdown object (see below)
|
||||
|
||||
Do not include any text outside the JSON object.`
|
||||
|
||||
switch (type) {
|
||||
case 'essay': {
|
||||
const rubric = (question.rubric as string[] | undefined) ?? []
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Rubric criteria: ${rubric.join('; ') || 'None provided'}
|
||||
Student answer: ${String(answer ?? '')}
|
||||
|
||||
Evaluate how well the essay covers the rubric criteria, structure, and quality. Provide "detail" as:
|
||||
{ "rubricBreakdown": [{ "item": string, "met": boolean }] }
|
||||
Each item should correspond to a rubric criterion.`
|
||||
}
|
||||
|
||||
case 'short-answer': {
|
||||
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Accepted answers: ${accepted.join(' | ')}
|
||||
Student answer: ${String(answer ?? '')}
|
||||
|
||||
Determine if the student's answer is semantically equivalent to any accepted form. Provide "detail" as:
|
||||
{ "matchedForm": string | null, "reason": string }`
|
||||
}
|
||||
|
||||
case 'translation': {
|
||||
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||
const source = question.sourceLanguage as string
|
||||
const target = question.targetLanguage as string
|
||||
return `${base}
|
||||
|
||||
Translate from ${source} to ${target}.
|
||||
Question: ${question.prompt}
|
||||
Accepted translations: ${accepted.join(' | ')}
|
||||
Student answer: ${String(answer ?? '')}
|
||||
|
||||
Evaluate meaning accuracy and fluency, not literal word-for-word matching. Provide "detail" as:
|
||||
{ "accuracy": number (0-1), "fluency": number (0-1), "notes": string }`
|
||||
}
|
||||
|
||||
case 'math': {
|
||||
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Accepted answers (LaTeX): ${accepted.join(' | ')}
|
||||
Student answer (LaTeX): ${String(answer ?? '')}
|
||||
|
||||
Determine if the student's LaTeX expression is mathematically equivalent to the accepted answer. Provide "detail" as:
|
||||
{ "equivalent": boolean, "explanation": string }`
|
||||
}
|
||||
|
||||
case 'ordering': {
|
||||
const correctOrder = question.answer as string[]
|
||||
const items = question.items as Array<{ id: string; text: string }> | undefined
|
||||
const itemMap = new Map(items?.map(i => [i.id, i.text]) ?? [])
|
||||
const given = answer as string[]
|
||||
const correctTexts = correctOrder.map(id => itemMap.get(id) ?? id)
|
||||
const givenTexts = given?.map(id => itemMap.get(id) ?? id) ?? []
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Correct order: ${correctTexts.join(' → ')}
|
||||
Student order: ${givenTexts.join(' → ')}
|
||||
|
||||
Determine how many items are in the correct position. Provide "detail" as:
|
||||
{ "correctPositions": number[], "totalItems": number }
|
||||
correctPositions should be 1-based indices of items in the correct position.`
|
||||
}
|
||||
|
||||
case 'match': {
|
||||
const correctPairs = question.answer as Record<string, string>
|
||||
const left = question.left as Array<{ id: string; text: string }> | undefined
|
||||
const right = question.right as Array<{ id: string; text: string }> | undefined
|
||||
const leftMap = new Map(left?.map(i => [i.id, i.text]) ?? [])
|
||||
const rightMap = new Map(right?.map(i => [i.id, i.text]) ?? [])
|
||||
const given = (answer as Record<string, string> | undefined) ?? {}
|
||||
const correctPairTexts: string[] = []
|
||||
const incorrectPairTexts: string[] = []
|
||||
for (const [lId, rId] of Object.entries(correctPairs)) {
|
||||
const pairText = `${leftMap.get(lId) ?? lId} → ${rightMap.get(rId) ?? rId}`
|
||||
if (given[lId] === rId) {
|
||||
correctPairTexts.push(pairText)
|
||||
} else {
|
||||
const actualR = given[lId] ? (rightMap.get(given[lId]) ?? given[lId]) : '(unanswered)'
|
||||
incorrectPairTexts.push(`${leftMap.get(lId) ?? lId} → ${actualR} (should be ${rightMap.get(rId) ?? rId})`)
|
||||
}
|
||||
}
|
||||
return `${base}
|
||||
|
||||
Question: ${question.prompt}
|
||||
Correct pairings:
|
||||
${Object.entries(correctPairs).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
|
||||
Student pairings:
|
||||
${Object.entries(given).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
|
||||
|
||||
Determine partial credit based on correct pairings. Provide "detail" as:
|
||||
{ "correctPairs": string[], "incorrectPairs": string[] }
|
||||
Use the human-readable text labels in the arrays.`
|
||||
}
|
||||
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
export async function gradeQuestionWithAI(
|
||||
req: GradeRequest,
|
||||
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
||||
): Promise<GradingResult> {
|
||||
const client = getAIClient(override)
|
||||
if (!client) {
|
||||
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
|
||||
}
|
||||
|
||||
const model = getAIModel(override?.model)
|
||||
const prompt = buildPrompt(req)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.2,
|
||||
max_completion_tokens: 800,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON response from AI provider')
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const suggestedGrade = typeof obj.suggestedGrade === 'number'
|
||||
? Math.max(0, Math.min(1, obj.suggestedGrade))
|
||||
: 0
|
||||
|
||||
return {
|
||||
suggestedGrade,
|
||||
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
|
||||
detail: obj.detail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as storageRepo from '../repositories/storageRepo.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 rawVerifyToken = generateVerificationToken()
|
||||
const verifyTokenHash = hashToken(rawVerifyToken)
|
||||
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||
|
||||
const { id } = userRepo.createUser({
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
displayName: input.displayName,
|
||||
role,
|
||||
emailVerified: 0,
|
||||
verifyToken: verifyTokenHash,
|
||||
verifyExpires,
|
||||
})
|
||||
|
||||
const user = userRepo.getUserById(id)!
|
||||
|
||||
// Don't await — fire and forget so registration isn't blocked by email failures
|
||||
sendVerificationEmail(user.email, rawVerifyToken).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')
|
||||
}
|
||||
|
||||
if (!user.email_verified) {
|
||||
throw new AppError(403, 'Please verify your email before logging in')
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
const usage = storageRepo.getUserStorageUsage(userId)
|
||||
const usedPercent = user.storage_quota_bytes > 0
|
||||
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
|
||||
: 0
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role,
|
||||
emailVerified: !!user.email_verified,
|
||||
storageQuotaBytes: user.storage_quota_bytes,
|
||||
usedBytes: usage.usedBytes,
|
||||
usedPercent,
|
||||
plan: user.plan,
|
||||
}
|
||||
}
|
||||
|
||||
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 tokenHash = hashToken(input.token)
|
||||
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
|
||||
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 resendVerification(email: string) {
|
||||
const user = userRepo.getUserByEmail(email)
|
||||
if (!user || user.email_verified) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawVerifyToken = generateVerificationToken()
|
||||
const verifyTokenHash = hashToken(rawVerifyToken)
|
||||
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||
|
||||
userRepo.updateUser(user.id, {
|
||||
verifyToken: verifyTokenHash,
|
||||
verifyExpires,
|
||||
})
|
||||
|
||||
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
|
||||
}
|
||||
|
||||
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 rawResetToken = generateVerificationToken()
|
||||
const resetTokenHash = hashToken(rawResetToken)
|
||||
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
||||
|
||||
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
|
||||
|
||||
// Fire and forget
|
||||
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
|
||||
}
|
||||
|
||||
export async function resetPassword(input: ResetPasswordInput) {
|
||||
const tokenHash = hashToken(input.token)
|
||||
const user = userRepo.getUserByResetTokenHash(tokenHash)
|
||||
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,155 @@
|
||||
import { getAIClient, getAIModel } from './aiClient.js'
|
||||
|
||||
export interface WeakArea {
|
||||
category: string
|
||||
avgScore: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export interface StudyCoachResult {
|
||||
weakAreas: WeakArea[]
|
||||
studyTips: string[]
|
||||
streakDays: number
|
||||
}
|
||||
|
||||
interface ResultSummary {
|
||||
title: string
|
||||
scorePoints: number
|
||||
totalPoints: number
|
||||
correctCount: number
|
||||
gradedCount: number
|
||||
completedAt: number
|
||||
categories: Record<string, { score: number; total: number }>
|
||||
}
|
||||
|
||||
|
||||
function calculateStreak(results: Array<{ completed_at: number }>): number {
|
||||
if (results.length === 0) return 0
|
||||
const days = new Set<string>()
|
||||
for (const r of results) {
|
||||
const d = new Date(r.completed_at * 1000)
|
||||
days.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
}
|
||||
const sorted = Array.from(days).sort()
|
||||
if (sorted.length === 0) return 0
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
|
||||
|
||||
let streak = 0
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const expected = new Date(today)
|
||||
expected.setDate(today.getDate() - (sorted.length - 1 - i))
|
||||
const expectedStr = `${expected.getFullYear()}-${expected.getMonth()}-${expected.getDate()}`
|
||||
if (sorted[i] === expectedStr || (i === sorted.length - 1 && sorted[i] === todayStr)) {
|
||||
streak++
|
||||
} else if (i === sorted.length - 1) {
|
||||
// Most recent is not today — check if it was yesterday
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
const yestStr = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`
|
||||
if (sorted[i] === yestStr) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
export async function analyzeResults(
|
||||
results: Array<{
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
completed_at: number
|
||||
exam_json: string
|
||||
}>,
|
||||
language = 'en',
|
||||
): Promise<StudyCoachResult> {
|
||||
const streakDays = calculateStreak(results)
|
||||
|
||||
// Build per-category stats from exam_json
|
||||
const categoryStats: Record<string, { score: number; total: number; attempts: number }> = {}
|
||||
for (const r of results) {
|
||||
let exam: { questions?: Array<{ category?: string; points?: number }> } | undefined
|
||||
try {
|
||||
exam = JSON.parse(r.exam_json)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const questions = exam?.questions ?? []
|
||||
// We don't have per-question results in the stored result, so we approximate
|
||||
// using the overall score distributed across categories proportionally
|
||||
const totalQuestionPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0)
|
||||
if (totalQuestionPoints === 0) continue
|
||||
const overallRatio = r.total_points > 0 ? r.score_points / r.total_points : 0
|
||||
for (const q of questions) {
|
||||
const cat = q.category ?? 'Uncategorized'
|
||||
const pts = q.points ?? 1
|
||||
if (!categoryStats[cat]) {
|
||||
categoryStats[cat] = { score: 0, total: 0, attempts: 0 }
|
||||
}
|
||||
categoryStats[cat].score += pts * overallRatio
|
||||
categoryStats[cat].total += pts
|
||||
}
|
||||
// Mark attempt for each unique category in this exam
|
||||
const catsInExam = new Set(questions.map((q) => q.category ?? 'Uncategorized'))
|
||||
for (const cat of catsInExam) {
|
||||
categoryStats[cat].attempts++
|
||||
}
|
||||
}
|
||||
|
||||
const weakAreas: WeakArea[] = Object.entries(categoryStats)
|
||||
.map(([category, stats]) => ({
|
||||
category,
|
||||
avgScore: stats.total > 0 ? Math.round((stats.score / stats.total) * 100) : 0,
|
||||
attempts: stats.attempts,
|
||||
}))
|
||||
.filter((w) => w.attempts >= 1)
|
||||
.sort((a, b) => a.avgScore - b.avgScore)
|
||||
.slice(0, 5)
|
||||
|
||||
const client = getAIClient()
|
||||
if (!client || weakAreas.length === 0) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
|
||||
const model = getAIModel()
|
||||
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
|
||||
- "studyTips": string[] (2-3 short tips in ${language})
|
||||
|
||||
Do not include any text outside the JSON object.
|
||||
|
||||
Weak areas (score %, attempts):
|
||||
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
|
||||
|
||||
try {
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 400,
|
||||
})
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const tips = Array.isArray(parsed.studyTips)
|
||||
? parsed.studyTips.filter((t): t is string => typeof t === 'string')
|
||||
: []
|
||||
|
||||
return { weakAreas, studyTips: tips, streakDays }
|
||||
} catch {
|
||||
return { weakAreas, studyTips: [], streakDays }
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@
|
||||
/**
|
||||
* Server-side sanitization for user-provided exam content.
|
||||
* Removes script tags and event handlers from string fields.
|
||||
*/
|
||||
|
||||
function sanitizeString(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value
|
||||
return value
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<script[^>]*\/>/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '')
|
||||
}
|
||||
|
||||
function sanitizeObject(obj: unknown): unknown {
|
||||
if (typeof obj === 'string') return sanitizeString(obj)
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeObject)
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
result[key] = sanitizeObject(val)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
|
||||
return sanitizeObject(exam) as Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* In-memory token blacklist with SQLite persistence.
|
||||
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
|
||||
*/
|
||||
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
const memoryCache = new Set<string>()
|
||||
let initialized = false
|
||||
|
||||
function initCache(): void {
|
||||
if (initialized) return
|
||||
const db = getDb()
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
|
||||
`)
|
||||
// Load recent blacklisted tokens into memory
|
||||
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
|
||||
.all(Date.now()) as { token_hash: string }[]
|
||||
for (const row of rows) {
|
||||
memoryCache.add(row.token_hash)
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
export function isTokenBlacklisted(tokenHash: string): boolean {
|
||||
initCache()
|
||||
return memoryCache.has(tokenHash)
|
||||
}
|
||||
|
||||
export function blacklistToken(tokenHash: string, expiresAt: number): void {
|
||||
initCache()
|
||||
memoryCache.add(tokenHash)
|
||||
const db = getDb()
|
||||
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
|
||||
.run(tokenHash, expiresAt)
|
||||
}
|
||||
|
||||
export function cleanupExpiredTokens(): void {
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
|
||||
}
|
||||
@@ -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,13 +10,17 @@ 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 {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||
|
||||
describe('updateUserRoleSchema', () => {
|
||||
it('accepts valid role change to admin', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid role change to user', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: 5, role: 'user' })
|
||||
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)
|
||||
})
|
||||
|
||||
it('rejects negative user_id', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: -1, role: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-numeric user_id', () => {
|
||||
const result = updateUserRoleSchema.safeParse({ user_id: '1', role: 'admin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing fields', () => {
|
||||
expect(updateUserRoleSchema.safeParse({ user_id: 1 }).success).toBe(false)
|
||||
expect(updateUserRoleSchema.safeParse({ role: 'admin' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSettingSchema', () => {
|
||||
it('accepts valid setting', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 'BrainWave' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown key', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'unknown_key', value: 'x' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty key', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: '', value: 'BrainWave' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts empty value (clearing a setting)', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'smtp_pass', value: '' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-string value', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 123 })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts each allowed key', () => {
|
||||
const keys = [
|
||||
'site_name', 'site_language', 'maintenance_mode', 'maintenance_message',
|
||||
'allow_guest_mode', 'registration_enabled', 'default_theme',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_secure',
|
||||
'app_url', 'max_upload_mb', 'ai_provider', 'ai_model', 'ai_api_key', 'ai_base_url',
|
||||
]
|
||||
for (const key of keys) {
|
||||
const result = updateSettingSchema.safeParse({ key, value: 'x' })
|
||||
expect(result.success, `key ${key} should be accepted`).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSettingsBatchSchema', () => {
|
||||
it('accepts valid batch', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { site_name: 'BrainWave', maintenance_mode: 'false' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty settings record', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({ settings: {} })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty value in batch (clearing)', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { smtp_pass: '' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-string value in batch', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { site_name: 42 },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing settings property', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listUsersQuerySchema', () => {
|
||||
it('accepts empty query and defaults page to 1', () => {
|
||||
const result = listUsersQuerySchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.page).toBe(1)
|
||||
})
|
||||
|
||||
it('parses page string into number', () => {
|
||||
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('rejects negative page', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '-1' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects decimal page', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '1.5' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts search', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ search: 'john' })
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data.search).toBe('john')
|
||||
})
|
||||
|
||||
it('accepts both page and search', () => {
|
||||
const result = listUsersQuerySchema.safeParse({ page: '2', search: 'jane' })
|
||||
expect(result.success).toBe(true)
|
||||
expect((result as any).data).toEqual({ page: 2, search: 'jane' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('userIdParamSchema', () => {
|
||||
it('accepts numeric id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: '7' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts multi-digit id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: '12345' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: 'seven' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = userIdParamSchema.safeParse({ id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects id with non-digit characters', () => {
|
||||
expect(userIdParamSchema.safeParse({ id: '1a' }).success).toBe(false)
|
||||
expect(userIdParamSchema.safeParse({ id: '-1' }).success).toBe(false)
|
||||
expect(userIdParamSchema.safeParse({ id: '1.0' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updateUserRoleSchema = z.object({
|
||||
user_id: z.number().min(1),
|
||||
role: z.enum(['user', 'admin']),
|
||||
})
|
||||
|
||||
const ALLOWED_SETTING_KEYS = [
|
||||
'site_name',
|
||||
'site_language',
|
||||
'maintenance_mode',
|
||||
'maintenance_message',
|
||||
'allow_guest_mode',
|
||||
'registration_enabled',
|
||||
'default_theme',
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_user',
|
||||
'smtp_pass',
|
||||
'smtp_from',
|
||||
'smtp_secure',
|
||||
'app_url',
|
||||
'max_upload_mb',
|
||||
'ai_provider',
|
||||
'ai_model',
|
||||
'ai_api_key',
|
||||
'ai_base_url',
|
||||
] as const
|
||||
|
||||
export const updateSettingSchema = z.object({
|
||||
key: z.enum(ALLOWED_SETTING_KEYS),
|
||||
value: z.string(),
|
||||
})
|
||||
|
||||
export const updateSettingsBatchSchema = z.object({
|
||||
settings: z.record(z.string()),
|
||||
})
|
||||
|
||||
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,131 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, 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('accepts passwords with hash as special character', () => {
|
||||
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('resendVerificationSchema', () => {
|
||||
it('accepts valid email', () => {
|
||||
const result = resendVerificationSchema.safeParse({ email: 'test@example.com' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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,54 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
|
||||
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 resendVerificationSchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
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')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
|
||||
})
|
||||
|
||||
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')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
|
||||
})
|
||||
|
||||
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 ResendVerificationInput = z.infer<typeof resendVerificationSchema>
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
createCollectionSchema,
|
||||
updateCollectionSchema,
|
||||
collectionIdParamSchema,
|
||||
moveExamSchema,
|
||||
} from './collectionSchemas'
|
||||
|
||||
describe('createCollectionSchema', () => {
|
||||
it('accepts valid name', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 'Math 101' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const result = createCollectionSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects name longer than 100 chars', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(101) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts name at max length (100)', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(100) })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-string name', () => {
|
||||
const result = createCollectionSchema.safeParse({ name: 42 })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCollectionSchema', () => {
|
||||
it('accepts valid name', () => {
|
||||
const result = updateCollectionSchema.safeParse({ name: 'Renamed' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = updateCollectionSchema.safeParse({ name: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects name over 100 chars', () => {
|
||||
const result = updateCollectionSchema.safeParse({ name: 'b'.repeat(101) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectionIdParamSchema', () => {
|
||||
it('accepts numeric id', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: '12' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-numeric id', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: 'abc' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative id string', () => {
|
||||
const result = collectionIdParamSchema.safeParse({ id: '-3' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveExamSchema', () => {
|
||||
it('accepts numeric collectionId', () => {
|
||||
const result = moveExamSchema.safeParse({ collectionId: 4 })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null collectionId (moving out of any collection)', () => {
|
||||
const result = moveExamSchema.safeParse({ collectionId: null })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty payload (collectionId omitted)', () => {
|
||||
const result = moveExamSchema.safeParse({})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects string collectionId', () => {
|
||||
const result = moveExamSchema.safeParse({ collectionId: '4' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createCollectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const updateCollectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
})
|
||||
|
||||
export const collectionIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export const moveExamSchema = z.object({
|
||||
collectionId: z.number().nullable().optional(),
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
examSchema,
|
||||
saveExamSchema,
|
||||
updateExamSchema,
|
||||
examIdParamSchema,
|
||||
favoriteSchema,
|
||||
shareModeSchema,
|
||||
batchDeleteSchema,
|
||||
} 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 empty title', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: '',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('accepts each supported question type', () => {
|
||||
const types = [
|
||||
'multiple-choice', 'multiple-select', 'short-answer', 'translation',
|
||||
'math-input', 'essay', 'match', 'order',
|
||||
'fill-in-the-blanks', 'true-false', 'code-submission',
|
||||
'dropdown-completion', 'numeric-range',
|
||||
'dictation', 'grammar-correction', 'flashcard',
|
||||
]
|
||||
for (const type of types) {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type, prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success, `type ${type} should be accepted`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unsupported question type', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'file-upload', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts answer as string, array, or record', () => {
|
||||
const variants = ['answer', ['a', 'b'], { left1: 'right1' }]
|
||||
for (const answer of variants) {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-string/array/record/number/boolean answer', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: null }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts null timeLimitMinutes', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
timeLimitMinutes: null,
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts feedbackMode end and immediate', () => {
|
||||
for (const feedbackMode of ['end', 'immediate'] as const) {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
feedbackMode,
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid feedbackMode', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'X',
|
||||
feedbackMode: 'never',
|
||||
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts question with rich content (image, audio, code, table)', () => {
|
||||
const result = examSchema.safeParse({
|
||||
title: 'Rich',
|
||||
questions: [{
|
||||
id: '1',
|
||||
type: 'multiple-choice',
|
||||
prompt: 'Pick one',
|
||||
image: { src: 'a.png', alt: 'desc', caption: 'cap' },
|
||||
audio: { src: 'a.mp3' },
|
||||
code: { language: 'js', content: 'x=1' },
|
||||
table: { headers: ['a'], rows: [['b']] },
|
||||
choices: [{ id: 'c1', text: 'one' }, { id: 'c2', text: 'two' }],
|
||||
}],
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
it('rejects empty id', () => {
|
||||
const result = examIdParamSchema.safeParse({ id: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('favoriteSchema', () => {
|
||||
it('accepts true', () => {
|
||||
expect(favoriteSchema.safeParse({ isFavorite: true }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts false', () => {
|
||||
expect(favoriteSchema.safeParse({ isFavorite: false }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-boolean', () => {
|
||||
expect(favoriteSchema.safeParse({ isFavorite: 'true' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing field', () => {
|
||||
expect(favoriteSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shareModeSchema', () => {
|
||||
it('accepts view', () => {
|
||||
expect(shareModeSchema.safeParse({ mode: 'view' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts clone', () => {
|
||||
expect(shareModeSchema.safeParse({ mode: 'clone' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown mode', () => {
|
||||
expect(shareModeSchema.safeParse({ mode: 'edit' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('batchDeleteSchema', () => {
|
||||
it('accepts array of ids', () => {
|
||||
expect(batchDeleteSchema.safeParse({ ids: [1, 2, 3] }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts empty array', () => {
|
||||
expect(batchDeleteSchema.safeParse({ ids: [] }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects string ids', () => {
|
||||
expect(batchDeleteSchema.safeParse({ ids: ['1', '2'] }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing ids', () => {
|
||||
expect(batchDeleteSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
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 blankSchema = z.object({
|
||||
id: z.string(),
|
||||
acceptedAnswers: z.array(z.string()),
|
||||
acceptedForms: z.array(z.string()).optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
ignorePunctuation: z.boolean().optional(),
|
||||
ignoreAccents: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const dropdownSchema = z.object({
|
||||
id: z.string(),
|
||||
options: z.array(choiceSchema),
|
||||
correctOptionId: z.string(),
|
||||
})
|
||||
|
||||
const codeConfigSchema = z.object({
|
||||
language: z.string(),
|
||||
starterCode: z.string().optional(),
|
||||
})
|
||||
|
||||
const numericConfigSchema = z.object({
|
||||
answer: z.number(),
|
||||
tolerance: z.number(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
})
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum([
|
||||
'multiple-choice',
|
||||
'multiple-select',
|
||||
'short-answer',
|
||||
'translation',
|
||||
'math-input',
|
||||
'essay',
|
||||
'match',
|
||||
'order',
|
||||
'fill-in-the-blanks',
|
||||
'true-false',
|
||||
'code-submission',
|
||||
'dropdown-completion',
|
||||
'numeric-range',
|
||||
'dictation',
|
||||
'grammar-correction',
|
||||
'flashcard',
|
||||
]),
|
||||
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()), z.boolean(), z.number()]).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(),
|
||||
textSegments: z.array(z.string()).optional(),
|
||||
blanks: z.array(blankSchema).optional(),
|
||||
dropdowns: z.array(dropdownSchema).optional(),
|
||||
codeConfig: codeConfigSchema.optional(),
|
||||
numericConfig: numericConfigSchema.optional(),
|
||||
textToCorrect: z.string().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,
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const updateExamSchema = z.object({
|
||||
exam: examSchema,
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const examIdParamSchema = z.object({
|
||||
id: z.string().regex(/^\d+$/, 'ID must be a number'),
|
||||
})
|
||||
|
||||
export const favoriteSchema = z.object({
|
||||
isFavorite: z.boolean(),
|
||||
})
|
||||
|
||||
export const shareModeSchema = z.object({
|
||||
mode: z.enum(['view', 'clone']),
|
||||
})
|
||||
|
||||
export const batchDeleteSchema = z.object({
|
||||
ids: z.array(z.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,118 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { setupSchema, setupStatusSchema } from './setupSchemas'
|
||||
|
||||
describe('setupSchema', () => {
|
||||
const baseValid = {
|
||||
email: 'admin@example.com',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
}
|
||||
|
||||
it('accepts minimal valid setup', () => {
|
||||
const result = setupSchema.safeParse(baseValid)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts passwords with hash as special character', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123#' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts full setup including SMTP fields', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
...baseValid,
|
||||
siteTitle: 'BrainWave',
|
||||
language: 'nl',
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: '587',
|
||||
smtpUser: 'user',
|
||||
smtpPass: 'pass',
|
||||
smtpFrom: 'noreply@example.com',
|
||||
smtpSecure: 'auto',
|
||||
appUrl: 'https://example.com',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, email: 'bad' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects short password', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: '123' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password missing complexity (no special char)', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password missing uppercase', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'adminpass123!' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password missing digit', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass!' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty displayName', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, displayName: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects displayName over 100 chars', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, displayName: 'a'.repeat(101) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects email longer than 255 chars', () => {
|
||||
const longLocal = 'a'.repeat(250)
|
||||
const result = setupSchema.safeParse({ ...baseValid, email: `${longLocal}@x.com` })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid language', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, language: 'fr' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts each language enum value', () => {
|
||||
for (const language of ['en', 'nl'] as const) {
|
||||
const result = setupSchema.safeParse({ ...baseValid, language })
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid smtpSecure value', () => {
|
||||
const result = setupSchema.safeParse({ ...baseValid, smtpSecure: 'maybe' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts each smtpSecure enum value', () => {
|
||||
for (const smtpSecure of ['auto', 'true', 'false'] as const) {
|
||||
const result = setupSchema.safeParse({ ...baseValid, smtpSecure })
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
|
||||
|
||||
export const setupSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
|
||||
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(),
|
||||
})
|
||||
@@ -16,5 +16,5 @@
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
@@ -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
+160
-1
@@ -9,9 +9,14 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^18.0.3",
|
||||
"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 +232,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 +1805,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 +2300,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",
|
||||
@@ -2831,6 +2899,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.3",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
|
||||
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -3075,6 +3155,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 +3286,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 +3704,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,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"katex": "^0.16.45",
|
||||
"marked": "^18.0.3",
|
||||
"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",
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="BrainWave">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" fill="none" stroke="#1f1f24" stroke-width="2" />
|
||||
<rect x="6" y="6" width="10" height="12" rx="1.5" fill="#1f1f24" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 271 B |
+449
-184
@@ -1,62 +1,69 @@
|
||||
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";
|
||||
import ShareView from "./components/ShareView";
|
||||
|
||||
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 [reviewResultId, setReviewResultId] = useState<number | 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 +71,289 @@ 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')
|
||||
const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
|
||||
if (reviewExam && reviewState) {
|
||||
try {
|
||||
const parsedExam = JSON.parse(reviewExam) as Exam
|
||||
const parsedState = JSON.parse(reviewState) as ExamState
|
||||
setExam(parsedExam)
|
||||
setExamState(withNotes(parsedState))
|
||||
if (reviewResultIdRaw) {
|
||||
const id = Number(reviewResultIdRaw)
|
||||
if (Number.isFinite(id) && id > 0) setReviewResultId(id)
|
||||
}
|
||||
localStorage.removeItem('brainwave.review.exam')
|
||||
localStorage.removeItem('brainwave.review.state')
|
||||
localStorage.removeItem('brainwave.review.resultId')
|
||||
} 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, isRedo = false) => {
|
||||
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,
|
||||
isRedo,
|
||||
});
|
||||
});
|
||||
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({
|
||||
// Skip saving to history / API when this is a redo session
|
||||
if (!examState.isRedo) {
|
||||
if (user && isAuthenticated()) {
|
||||
void resultApi.submitResult({
|
||||
exam_id: savedExamIdRef.current,
|
||||
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(() => {
|
||||
// Fallback: save locally
|
||||
appendHistory(entry)
|
||||
})
|
||||
} else {
|
||||
appendHistory(entry)
|
||||
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(() => {
|
||||
appendHistory(entry);
|
||||
});
|
||||
} else {
|
||||
appendHistory(entry);
|
||||
}
|
||||
|
||||
setHistory((prev) => [entry, ...prev]);
|
||||
}
|
||||
|
||||
// Always update local state for immediate display
|
||||
setHistory((prev) => [entry, ...prev])
|
||||
clearState()
|
||||
setExamState((s) => s ? { ...s, completedAt } : s)
|
||||
setScreen('results')
|
||||
}, [exam, examState, user])
|
||||
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 redoFailed = useCallback(() => {
|
||||
if (!exam || !examState) return;
|
||||
const graded = exam.questions.map((q) =>
|
||||
gradeQuestion(q, examState.answers[q.id]),
|
||||
);
|
||||
const failedQuestions = exam.questions
|
||||
.map((q, i) => ({ q, result: graded[i] }))
|
||||
.filter(({ result }) => !result.correct)
|
||||
.map(({ q }) => q);
|
||||
|
||||
if (failedQuestions.length === 0) return;
|
||||
|
||||
// Shuffle failed questions using Fisher-Yates
|
||||
const shuffled = [...failedQuestions];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
const redoExam: Exam = { ...exam, questions: shuffled };
|
||||
loadExam(redoExam, false, savedExamIdRef.current, true);
|
||||
}, [exam, examState]);
|
||||
|
||||
const exit = () => {
|
||||
setExam(null);
|
||||
setExamState(null);
|
||||
savedExamIdRef.current = null;
|
||||
sessionStorage.removeItem('brainwave.pendingResultId');
|
||||
navigate(user && isAuthenticated() ? "/workspace" : "/");
|
||||
};
|
||||
|
||||
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 retakeAttempt = (entry: HistoryEntry) => {
|
||||
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
|
||||
if (remoteId && user && isAuthenticated()) {
|
||||
void resultApi.getResult(remoteId)
|
||||
.then((data) => {
|
||||
loadExam(data.exam as unknown as Exam, false, null, false);
|
||||
})
|
||||
.catch(() => {
|
||||
loadExam(entry.exam, false, null, false);
|
||||
});
|
||||
} else {
|
||||
loadExam(entry.exam, false, null, false);
|
||||
}
|
||||
};
|
||||
|
||||
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(user && isAuthenticated() ? "/workspace" : "/");
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
if (needsSetup) {
|
||||
return (
|
||||
<SetupWizard onComplete={() => void refreshUser()} />
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === 'verify') {
|
||||
if (pathname === "/verify") {
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
@@ -209,10 +364,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 +378,7 @@ function AppInner() {
|
||||
</header>
|
||||
<ResetPassword onDone={handleAuthFlowDone} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -234,26 +389,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 +430,155 @@ 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={
|
||||
user && isAuthenticated() ? (
|
||||
<Navigate to="/workspace" replace />
|
||||
) : (
|
||||
<Landing
|
||||
onLoadExam={loadExam}
|
||||
history={history}
|
||||
onResume={resume}
|
||||
onClearHistory={clearAllHistory}
|
||||
onReviewAttempt={reviewAttempt}
|
||||
onRetakeAttempt={retakeAttempt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
onRedoFailed={redoFailed}
|
||||
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}
|
||||
resultId={reviewResultId}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
user?.role === "admin" ? (
|
||||
<AdminDashboard onExit={exit} />
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/workspace" element={<Navigate to="/workspace/overview" replace />} />
|
||||
<Route path="/workspace/:tab" element={<Workspace onLoadExam={loadExam} />} />
|
||||
<Route path="/exam-editor/:id" element={<ExamEditor />} />
|
||||
<Route path="/share/:token" element={<ShareView onLoadExam={loadExam} />} />
|
||||
<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,111 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
import type { GradingResult } from './gradeApi.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
|
||||
ai_provider: string
|
||||
ai_model: string
|
||||
ai_api_key: string
|
||||
ai_base_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 }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface AiTestPayload {
|
||||
provider: string
|
||||
apiKey: string
|
||||
model: string
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export function testAiConnection(payload: AiTestPayload): Promise<GradingResult> {
|
||||
return apiFetch('/api/admin/test-ai', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
import type { Question, QuestionType } from '../types'
|
||||
|
||||
export interface GenerateQuestionsRequest {
|
||||
topic: string
|
||||
type: QuestionType
|
||||
count: number
|
||||
language: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface GeneratedQuestion extends Omit<Question, 'id'> {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface GenerateQuestionsResponse {
|
||||
questions: GeneratedQuestion[]
|
||||
}
|
||||
|
||||
export function generateQuestions(req: GenerateQuestionsRequest): Promise<GenerateQuestionsResponse> {
|
||||
return apiFetch('/api/ai/generate-question', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
|
||||
export interface HintRequest {
|
||||
question: Record<string, unknown>
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface HintResponse {
|
||||
hint: string
|
||||
}
|
||||
|
||||
export function getHint(req: HintRequest): Promise<HintResponse> {
|
||||
return apiFetch('/api/ai/hint', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
|
||||
export interface TutorChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface TutorChatRequest {
|
||||
resultId: number
|
||||
messages: TutorChatMessage[]
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface TutorChatResponse {
|
||||
reply: string
|
||||
}
|
||||
|
||||
export function tutorChat(req: TutorChatRequest): Promise<TutorChatResponse> {
|
||||
return apiFetch('/api/ai/tutor-chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
displayName: string
|
||||
role: string
|
||||
emailVerified?: boolean
|
||||
storageQuotaBytes?: number
|
||||
usedBytes?: number
|
||||
usedPercent?: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
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?token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
export function resendVerification(email: string): Promise<{ message: string }> {
|
||||
return apiFetch('/api/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) })
|
||||
}
|
||||
|
||||
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,29 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
|
||||
export interface Collection {
|
||||
id: number
|
||||
name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
exam_count: number
|
||||
}
|
||||
|
||||
export function listCollections(): Promise<Collection[]> {
|
||||
return apiFetch('/api/collections')
|
||||
}
|
||||
|
||||
export function createCollection(name: string): Promise<{ id: number; name: string }> {
|
||||
return apiFetch('/api/collections', { method: 'POST', body: JSON.stringify({ name }) })
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, name: string): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/${id}`, { method: 'PUT', body: JSON.stringify({ name }) })
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function moveExamToCollection(examId: number, collectionId: number | null): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/move/${examId}`, { method: 'POST', body: JSON.stringify({ collectionId }) })
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
export function listExams(): Promise<ExamListItem[]> {
|
||||
return apiFetch('/api/exams')
|
||||
}
|
||||
|
||||
export function getExam(id: number): Promise<Exam & { tags?: string[] }> {
|
||||
return apiFetch(`/api/exams/${id}`)
|
||||
}
|
||||
|
||||
export function createExam(exam: Exam, tags?: string[]): Promise<{ id: number; title: string }> {
|
||||
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam, tags }) })
|
||||
}
|
||||
|
||||
export function updateExam(id: number, exam: Exam, tags?: string[]): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam, tags }) })
|
||||
}
|
||||
|
||||
export function deleteExam(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function shareExam(id: number): Promise<{ shareToken: string }> {
|
||||
return apiFetch(`/api/exams/${id}/share`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function unshareExam(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}/unshare`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSharedExam(token: string): Promise<{ title: string; subtitle: string | null; questionCount: number; exam: unknown; shareMode: string }> {
|
||||
return apiFetch(`/api/share/${token}`)
|
||||
}
|
||||
|
||||
export function cloneSharedExam(token: string): Promise<{ id: number; title: string }> {
|
||||
return apiFetch(`/api/share/${token}/clone`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function setFavorite(id: number, isFavorite: boolean): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}/favorite`, { method: 'PATCH', body: JSON.stringify({ isFavorite }) })
|
||||
}
|
||||
|
||||
export function exportExam(id: number): Promise<Blob> {
|
||||
return fetch(`/api/exams/${id}/export`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('brainwave.auth.token') || ''}`,
|
||||
},
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error('Export failed')
|
||||
return res.blob()
|
||||
})
|
||||
}
|
||||
|
||||
export function duplicateExam(id: number): Promise<{ id: number; title: string }> {
|
||||
return apiFetch(`/api/exams/${id}/duplicate`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export interface ClonedExamItem {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
question_count: number
|
||||
cloned_from_share_token: string
|
||||
}
|
||||
|
||||
export function listClonedExams(): Promise<ClonedExamItem[]> {
|
||||
return apiFetch('/api/exams/cloned')
|
||||
}
|
||||
|
||||
export function setShareMode(id: number, mode: 'view' | 'clone'): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}/share-mode`, { method: 'PUT', body: JSON.stringify({ mode }) })
|
||||
}
|
||||
|
||||
export function batchDelete(ids: number[]): Promise<{ deleted: number }> {
|
||||
return apiFetch('/api/exams/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) })
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from '../utils/api.js'
|
||||
|
||||
export interface GradingResult {
|
||||
suggestedGrade: number
|
||||
feedback: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
export interface GradeRequest {
|
||||
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
|
||||
question: Record<string, unknown>
|
||||
answer: unknown
|
||||
language: string
|
||||
}
|
||||
|
||||
export function gradeQuestion(req: GradeRequest): Promise<GradingResult> {
|
||||
return apiFetch('/api/grade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Re-export shared utilities
|
||||
export { apiFetch, setToken, clearToken, isAuthenticated, ApiError } from '../utils/api.js'
|
||||
@@ -0,0 +1,71 @@
|
||||
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' })
|
||||
}
|
||||
|
||||
export interface WeakArea {
|
||||
category: string
|
||||
avgScore: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
export interface StudyCoachResult {
|
||||
weakAreas: WeakArea[]
|
||||
studyTips: string[]
|
||||
streakDays: number
|
||||
weakestExamId: number | null
|
||||
}
|
||||
|
||||
export interface ScoreTrend {
|
||||
completed_at: number
|
||||
score_pct: number
|
||||
title: string
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export function analyzeResults(): Promise<StudyCoachResult> {
|
||||
return apiFetch('/api/results/analyze')
|
||||
}
|
||||
|
||||
export function getScoreTrends(): Promise<ScoreTrend[]> {
|
||||
return apiFetch('/api/results/trends')
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ApiError } from '../utils/api'
|
||||
import * as authApi from '../api/authApi'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
@@ -9,7 +11,11 @@ interface Props {
|
||||
|
||||
type Tab = 'login' | 'register'
|
||||
|
||||
const VERIFICATION_RESEND_DELAY_MS = 60_000
|
||||
const VERIFICATION_REQUIRED_MESSAGE = 'Please verify your email before logging in'
|
||||
|
||||
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('')
|
||||
@@ -18,23 +24,51 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [resendingVerification, setResendingVerification] = useState(false)
|
||||
const [verificationEmail, setVerificationEmail] = useState('')
|
||||
const [verificationResendAvailableAt, setVerificationResendAvailableAt] = useState<number | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!verificationResendAvailableAt) return undefined
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [verificationResendAvailableAt])
|
||||
|
||||
const verificationResendSeconds = verificationResendAvailableAt
|
||||
? Math.max(0, Math.ceil((verificationResendAvailableAt - now) / 1000))
|
||||
: 0
|
||||
const canResendVerification = verificationEmail.length > 0 && verificationResendSeconds === 0 && !resendingVerification
|
||||
|
||||
const switchTab = (t: Tab) => {
|
||||
setTab(t)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setNotice(null)
|
||||
setVerificationEmail('')
|
||||
setVerificationResendAvailableAt(null)
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Login failed')
|
||||
if (err instanceof ApiError && err.status === 403 && err.message === VERIFICATION_REQUIRED_MESSAGE) {
|
||||
setVerificationEmail(email)
|
||||
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -43,8 +77,9 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
setError(t('auth.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
@@ -52,26 +87,43 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!canResendVerification) return
|
||||
|
||||
setResendingVerification(true)
|
||||
setNotice(null)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await authApi.resendVerification(verificationEmail)
|
||||
setNotice(response.message)
|
||||
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('auth.requestFailed'))
|
||||
} finally {
|
||||
setResendingVerification(false)
|
||||
}
|
||||
}
|
||||
|
||||
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,23 +132,24 @@ 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>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
{success && <div className="auth-success">{success}</div>}
|
||||
{notice && <div className="auth-success">{notice}</div>}
|
||||
|
||||
{!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 +162,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,22 +175,41 @@ 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>
|
||||
{verificationEmail && error === VERIFICATION_REQUIRED_MESSAGE && (
|
||||
<div className="auth-field" style={{ marginTop: 8 }}>
|
||||
<p className="auth-subtitle" style={{ marginBottom: 8 }}>
|
||||
{t('auth.verificationResendHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => void handleResendVerification()}
|
||||
disabled={!canResendVerification}
|
||||
>
|
||||
{resendingVerification
|
||||
? t('auth.sending')
|
||||
: verificationResendSeconds > 0
|
||||
? t('auth.resendVerificationLinkIn', { seconds: verificationResendSeconds })
|
||||
: t('auth.resendVerificationLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!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 +222,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 +235,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 +249,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 +263,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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,180 @@
|
||||
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 * as aiApi from "../api/aiApi";
|
||||
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, i18n } = 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 [hintCache, setHintCache] = useState<Record<string, string>>({});
|
||||
const [hintLoading, setHintLoading] = useState(false);
|
||||
const [hintError, setHintError] = useState<string | null>(null);
|
||||
const currentHint = hintCache[q.id];
|
||||
|
||||
useEffect(() => {
|
||||
setHintError(null);
|
||||
}, [q.id]);
|
||||
|
||||
const requestHint = async () => {
|
||||
if (currentHint || hintLoading) return;
|
||||
setHintLoading(true);
|
||||
setHintError(null);
|
||||
try {
|
||||
const safeQ: Record<string, unknown> = { ...(q as unknown as Record<string, unknown>) };
|
||||
delete safeQ.answer;
|
||||
delete safeQ.acceptedForms;
|
||||
const res = await aiApi.getHint({
|
||||
question: safeQ,
|
||||
language: i18n.language || 'en',
|
||||
});
|
||||
setHintCache((prev) => ({ ...prev, [q.id]: res.hint }));
|
||||
} catch (e) {
|
||||
setHintError((e as Error).message);
|
||||
} finally {
|
||||
setHintLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 +183,263 @@ 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" markdown>
|
||||
{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>
|
||||
{(currentHint || hintError) && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderLeft: '3px solid var(--accent)',
|
||||
background: 'var(--paper)',
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-2)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>
|
||||
{t('exam.hintLabel')}
|
||||
</div>
|
||||
{currentHint && <Tex>{currentHint}</Tex>}
|
||||
{hintError && <span style={{ color: 'var(--wrong)' }}>{hintError}</span>}
|
||||
</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 btn-ghost" onClick={toggleFlag}>
|
||||
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => goTo(currentIdx - 1)}
|
||||
disabled={currentIdx === 0}
|
||||
>
|
||||
{t('exam.previous')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||||
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||||
</button>
|
||||
{!revealed && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void requestHint()}
|
||||
disabled={hintLoading || !!currentHint}
|
||||
title={t('exam.hintTitle')}
|
||||
>
|
||||
{hintLoading
|
||||
? t('exam.hintLoading')
|
||||
: currentHint
|
||||
? t('exam.hintShown')
|
||||
: t('exam.needHint')}
|
||||
</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,15 +1,18 @@
|
||||
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'
|
||||
import { downloadInstructions } from '../utils/instructions'
|
||||
|
||||
interface Props {
|
||||
onLoadExam: (exam: Exam) => void
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void
|
||||
history: HistoryEntry[]
|
||||
onResume: () => void
|
||||
onClearHistory: () => void
|
||||
onReviewAttempt: (entry: HistoryEntry) => void
|
||||
onRetakeAttempt?: (entry: HistoryEntry) => void
|
||||
}
|
||||
|
||||
const SAMPLES = [
|
||||
@@ -18,7 +21,8 @@ const SAMPLES = [
|
||||
{ path: '/samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
|
||||
]
|
||||
|
||||
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
|
||||
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt, onRetakeAttempt }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
@@ -30,9 +34,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,339 +50,18 @@ 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 }))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadInstructions = () => {
|
||||
const content = `BRAINWAVE EXAM JSON — FORMAT GUIDE
|
||||
====================================
|
||||
Give this document to your preferred LLM (ChatGPT, Claude, Gemini, etc.) along with your exam content,
|
||||
and ask it to produce a valid JSON file you can upload to BrainWave.
|
||||
|
||||
────────────────────────────────────
|
||||
TOP-LEVEL STRUCTURE
|
||||
────────────────────────────────────
|
||||
{
|
||||
"title": "string (REQUIRED) — The name of the exam",
|
||||
"subtitle": "string (optional) — A short description shown under the title",
|
||||
"author": "string (optional) — Who created the exam",
|
||||
"timeLimitMinutes": number | null (optional) — Time limit in minutes, or null/omit for untimed,
|
||||
"feedbackMode": "end" | "immediate" (optional, default: "end")
|
||||
- "end": students see all feedback after submitting the full exam
|
||||
- "immediate": feedback is shown after each question is answered,
|
||||
"passingScore": number (optional) — Passing percentage, e.g. 70 means 70%,
|
||||
"categories": ["string", ...] (optional) — Category labels for organizing questions,
|
||||
"questions": [ ... ] (REQUIRED) — Array of question objects (see below)
|
||||
}
|
||||
|
||||
────────────────────────────────────
|
||||
QUESTION OBJECT (common fields)
|
||||
────────────────────────────────────
|
||||
Every question MUST have these fields:
|
||||
"id": "string" — Unique identifier, e.g. "q1", "q2", etc.
|
||||
"type": "string" — One of the supported types listed below
|
||||
"prompt": "string" — The question text (supports LaTeX with $...$ or $$...$$)
|
||||
|
||||
Optional fields available on ALL question types:
|
||||
"category": "string" — Which category this question belongs to
|
||||
"points": number — Point value (default: 1)
|
||||
"explanation": "string" — Explanation shown after grading (supports LaTeX)
|
||||
"image": { "src": "URL", "alt": "string", "caption": "string" } — Attach an image
|
||||
"audio": { "src": "URL" } — Attach an audio clip
|
||||
"code": { "language": "string", "content": "string" } — Show a code block
|
||||
"table": { "headers": ["Col1", "Col2"], "rows": [["a","b"], ["c","d"]] } — Show a data table
|
||||
"svg": "string" — Raw SVG markup to display
|
||||
|
||||
────────────────────────────────────
|
||||
QUESTION TYPES
|
||||
────────────────────────────────────
|
||||
|
||||
1. MULTIPLE CHOICE ("multiple-choice")
|
||||
Student picks ONE correct answer from a list.
|
||||
|
||||
Required fields:
|
||||
"choices": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }, ...]
|
||||
"answer": "a" ← the id of the correct choice
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q1",
|
||||
"type": "multiple-choice",
|
||||
"prompt": "What is the capital of France?",
|
||||
"choices": [
|
||||
{ "id": "a", "text": "London" },
|
||||
{ "id": "b", "text": "Paris" },
|
||||
{ "id": "c", "text": "Berlin" }
|
||||
],
|
||||
"answer": "b"
|
||||
}
|
||||
|
||||
2. MULTIPLE SELECT ("multiple-select")
|
||||
Student selects ALL correct answers (checkboxes).
|
||||
|
||||
Required fields:
|
||||
"choices": [{ "id": "a", "text": "Option A" }, ...]
|
||||
"answer": ["a", "c"] ← array of correct choice ids
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q2",
|
||||
"type": "multiple-select",
|
||||
"prompt": "Which are prime numbers?",
|
||||
"choices": [
|
||||
{ "id": "a", "text": "2" },
|
||||
{ "id": "b", "text": "4" },
|
||||
{ "id": "c", "text": "7" },
|
||||
{ "id": "d", "text": "9" }
|
||||
],
|
||||
"answer": ["a", "c"]
|
||||
}
|
||||
|
||||
3. SHORT ANSWER ("short-answer")
|
||||
Student types a short text answer.
|
||||
|
||||
Required fields:
|
||||
"answer": "string" — The correct answer
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt1", "alt2"] — Other accepted answers
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
"ignoreAccents": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q3",
|
||||
"type": "short-answer",
|
||||
"prompt": "What element has the chemical symbol 'O'?",
|
||||
"answer": "Oxygen",
|
||||
"caseSensitive": false
|
||||
}
|
||||
|
||||
4. TRANSLATION ("translation")
|
||||
Student translates text between languages.
|
||||
|
||||
Required fields:
|
||||
"answer": "string" — The correct translation
|
||||
"sourceLanguage": "string" — e.g. "English"
|
||||
"targetLanguage": "string" — e.g. "Spanish"
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt translation 1", "alt translation 2"]
|
||||
"caseSensitive": true|false
|
||||
"ignorePunctuation": true|false
|
||||
"ignoreAccents": true|false
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q4",
|
||||
"type": "translation",
|
||||
"sourceLanguage": "English",
|
||||
"targetLanguage": "Spanish",
|
||||
"prompt": "Translate: \\"Good morning, how are you?\\"",
|
||||
"answer": "Buenos días, ¿cómo estás?",
|
||||
"acceptedForms": ["Buenos dias, como estas"],
|
||||
"caseSensitive": false,
|
||||
"ignorePunctuation": true,
|
||||
"ignoreAccents": true
|
||||
}
|
||||
|
||||
5. MATH INPUT ("math-input")
|
||||
Student enters a LaTeX math expression.
|
||||
|
||||
Required fields:
|
||||
"answer": "string" — Correct LaTeX expression
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt LaTeX 1", "alt LaTeX 2"]
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q5",
|
||||
"type": "math-input",
|
||||
"prompt": "Compute the derivative of $x^3$.",
|
||||
"answer": "3x^2",
|
||||
"acceptedForms": ["3x^{2}", "3 x^2"]
|
||||
}
|
||||
|
||||
6. ESSAY ("essay")
|
||||
Student writes a long-form response. Graded manually (always marked "ungraded").
|
||||
|
||||
Optional fields:
|
||||
"minWords": number — Minimum word count required
|
||||
"rubric": ["criterion 1", "criterion 2"] — Grading rubric shown during review
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q6",
|
||||
"type": "essay",
|
||||
"prompt": "Discuss the causes of World War I.",
|
||||
"points": 10,
|
||||
"minWords": 100,
|
||||
"rubric": [
|
||||
"Mentions alliance systems",
|
||||
"Discusses assassination of Archduke Franz Ferdinand",
|
||||
"Addresses nationalism and imperialism",
|
||||
"Well-structured argument"
|
||||
]
|
||||
}
|
||||
|
||||
7. MATCH ("match")
|
||||
Student matches items from a left column to items in a right column.
|
||||
|
||||
Required fields:
|
||||
"left": [{ "id": "l1", "text": "Item A" }, { "id": "l2", "text": "Item B" }, ...]
|
||||
"right": [{ "id": "r1", "text": "Match 1" }, { "id": "r2", "text": "Match 2" }, ...]
|
||||
"answer": { "l1": "r2", "l2": "r1" } ← maps each left id to its correct right id
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q7",
|
||||
"type": "match",
|
||||
"prompt": "Match the country to its capital.",
|
||||
"left": [
|
||||
{ "id": "l1", "text": "France" },
|
||||
{ "id": "l2", "text": "Japan" },
|
||||
{ "id": "l3", "text": "Brazil" }
|
||||
],
|
||||
"right": [
|
||||
{ "id": "r1", "text": "Brasília" },
|
||||
{ "id": "r2", "text": "Tokyo" },
|
||||
{ "id": "r3", "text": "Paris" }
|
||||
],
|
||||
"answer": { "l1": "r3", "l2": "r2", "l3": "r1" }
|
||||
}
|
||||
|
||||
8. ORDER ("order")
|
||||
Student arranges items in the correct sequence (drag & drop).
|
||||
|
||||
Required fields:
|
||||
"items": [{ "id": "s1", "text": "Step 1" }, { "id": "s2", "text": "Step 2" }, ...]
|
||||
"answer": ["s1", "s2", ...] ← array of item ids in the correct order
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q8",
|
||||
"type": "order",
|
||||
"prompt": "Put these historical events in chronological order.",
|
||||
"items": [
|
||||
{ "id": "e1", "text": "Moon landing (1969)" },
|
||||
{ "id": "e2", "text": "Fall of the Berlin Wall (1989)" },
|
||||
{ "id": "e3", "text": "World Wide Web invented (1991)" },
|
||||
{ "id": "e4", "text": "First iPhone released (2007)" }
|
||||
],
|
||||
"answer": ["e1", "e2", "e3", "e4"]
|
||||
}
|
||||
|
||||
────────────────────────────────────
|
||||
LATEX SUPPORT
|
||||
────────────────────────────────────
|
||||
Use LaTeX math in prompts, choices, and explanations:
|
||||
- Inline math: $x^2 + y^2 = z^2$
|
||||
- Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$
|
||||
|
||||
Remember to double-escape backslashes in JSON strings:
|
||||
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$"
|
||||
|
||||
────────────────────────────────────
|
||||
MEDIA ATTACHMENTS
|
||||
────────────────────────────────────
|
||||
You can attach images, audio, code blocks, tables, and SVGs to any question.
|
||||
Use publicly accessible URLs for image and audio sources.
|
||||
|
||||
Image: "image": { "src": "https://example.com/photo.jpg", "alt": "Description", "caption": "Optional caption" }
|
||||
Audio: "audio": { "src": "https://example.com/clip.mp3" }
|
||||
Code: "code": { "language": "python", "content": "def hello():\\n print('hi')" }
|
||||
Table: "table": { "headers": ["Name", "Value"], "rows": [["A", "1"], ["B", "2"]] }
|
||||
|
||||
────────────────────────────────────
|
||||
COMPLETE MINI EXAMPLE
|
||||
────────────────────────────────────
|
||||
{
|
||||
"title": "Biology 101 — Cell Structure Quiz",
|
||||
"subtitle": "Chapters 3-4 review",
|
||||
"author": "Prof. Smith",
|
||||
"timeLimitMinutes": 15,
|
||||
"feedbackMode": "end",
|
||||
"passingScore": 70,
|
||||
"categories": ["Cells", "Organelles"],
|
||||
"questions": [
|
||||
{
|
||||
"id": "q1",
|
||||
"type": "multiple-choice",
|
||||
"category": "Cells",
|
||||
"points": 2,
|
||||
"prompt": "Which organelle is known as the 'powerhouse of the cell'?",
|
||||
"choices": [
|
||||
{ "id": "a", "text": "Nucleus" },
|
||||
{ "id": "b", "text": "Mitochondria" },
|
||||
{ "id": "c", "text": "Ribosome" },
|
||||
{ "id": "d", "text": "Golgi apparatus" }
|
||||
],
|
||||
"answer": "b",
|
||||
"explanation": "Mitochondria produce ATP through cellular respiration."
|
||||
},
|
||||
{
|
||||
"id": "q2",
|
||||
"type": "short-answer",
|
||||
"category": "Organelles",
|
||||
"points": 1,
|
||||
"prompt": "What is the gel-like fluid inside a cell called?",
|
||||
"answer": "cytoplasm",
|
||||
"acceptedForms": ["Cytoplasm", "cytosol"],
|
||||
"caseSensitive": false
|
||||
},
|
||||
{
|
||||
"id": "q3",
|
||||
"type": "order",
|
||||
"category": "Cells",
|
||||
"points": 3,
|
||||
"prompt": "Order these from smallest to largest:",
|
||||
"items": [
|
||||
{ "id": "a", "text": "Atom" },
|
||||
{ "id": "b", "text": "Molecule" },
|
||||
{ "id": "c", "text": "Organelle" },
|
||||
{ "id": "d", "text": "Cell" }
|
||||
],
|
||||
"answer": ["a", "b", "c", "d"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
────────────────────────────────────
|
||||
TIPS FOR LLM PROMPTING
|
||||
────────────────────────────────────
|
||||
When asking your LLM to create an exam, include:
|
||||
1. This document (paste it in full)
|
||||
2. The subject matter or source material
|
||||
3. How many questions you want
|
||||
4. Which question types to use
|
||||
5. Desired difficulty level
|
||||
6. Whether to include a time limit
|
||||
|
||||
Example prompt: "Using the attached format guide, create a 10-question exam on
|
||||
World War II. Use a mix of multiple-choice, short-answer, and ordering questions.
|
||||
Set a 20-minute time limit. Include explanations for each answer."
|
||||
`
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'brainwave-exam-format-guide.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const resumable = loadState()
|
||||
|
||||
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 +78,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 +100,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 +113,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 +147,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 +159,15 @@ 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>
|
||||
{onRetakeAttempt && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => onRetakeAttempt(h)}>{t('landing.retake')}</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as collectionApi from '../api/collectionApi'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
interface SavedExam {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
question_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
share_token: string | null
|
||||
is_favorite: number
|
||||
tags: string
|
||||
collection_id: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
type CollectionFilter = number | null | 'all' | 'uncategorized'
|
||||
|
||||
export default function MyExams({ onLoadExam }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState<SavedExam[]>([])
|
||||
const [collections, setCollections] = useState<collectionApi.Collection[]>([])
|
||||
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 [sharingId, setSharingId] = useState<number | null>(null)
|
||||
const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
|
||||
const [exportingId, setExportingId] = useState<number | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeFilter, setActiveFilter] = useState<CollectionFilter>('all')
|
||||
const [showNewCollection, setShowNewCollection] = useState(false)
|
||||
const [newCollectionName, setNewCollectionName] = useState('')
|
||||
const [creatingCollection, setCreatingCollection] = useState(false)
|
||||
const [movingExamId, setMovingExamId] = useState<number | null>(null)
|
||||
const [menuOpenId, setMenuOpenId] = useState<number | null>(null)
|
||||
const [menuSection, setMenuSection] = useState<'main' | 'move' | 'share'>('main')
|
||||
|
||||
const closeMenu = () => { setMenuOpenId(null); setMenuSection('main') }
|
||||
const openMenu = (id: number) => { setMenuOpenId(id); setMenuSection('main') }
|
||||
const [editingCollectionId, setEditingCollectionId] = useState<number | null>(null)
|
||||
const [editingCollectionName, setEditingCollectionName] = useState('')
|
||||
const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [batchMoveOpen, setBatchMoveOpen] = useState(false)
|
||||
const [batchDeleting, setBatchDeleting] = useState(false)
|
||||
const [batchExporting, setBatchExporting] = useState(false)
|
||||
const [batchMoving, setBatchMoving] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [examData, collectionData] = await Promise.all([
|
||||
examApi.listExams(),
|
||||
collectionApi.listCollections(),
|
||||
])
|
||||
setExams(examData)
|
||||
setCollections(collectionData)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const deleteExam = async (id: number) => {
|
||||
try {
|
||||
await examApi.deleteExam(id)
|
||||
setDeleteConfirm(null)
|
||||
void loadData()
|
||||
} 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 handleShare = async (id: number) => {
|
||||
setSharingId(id)
|
||||
try {
|
||||
const res = await examApi.shareExam(id)
|
||||
const url = `${window.location.origin}/share/${res.shareToken}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
closeMenu()
|
||||
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e)))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setSharingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnshare = async (id: number) => {
|
||||
try {
|
||||
await examApi.unshareExam(id)
|
||||
closeMenu()
|
||||
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e)))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFavorite = async (id: number, isFavorite: boolean) => {
|
||||
try {
|
||||
await examApi.setFavorite(id, isFavorite)
|
||||
setExams((prev) => prev.map((e) => {
|
||||
if (e.id !== id) return e
|
||||
const updated = { ...e, is_favorite: isFavorite ? 1 : 0 }
|
||||
return updated
|
||||
}).sort((a, b) => (b.is_favorite - a.is_favorite) || (b.updated_at - a.updated_at)))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async (id: number, title: string) => {
|
||||
setExportingId(id)
|
||||
try {
|
||||
const blob = await examApi.exportExam(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setExportingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async (id: number) => {
|
||||
setDuplicatingId(id)
|
||||
try {
|
||||
await examApi.duplicateExam(id)
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setDuplicatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
const name = newCollectionName.trim()
|
||||
if (!name) return
|
||||
setCreatingCollection(true)
|
||||
try {
|
||||
await collectionApi.createCollection(name)
|
||||
setNewCollectionName('')
|
||||
setShowNewCollection(false)
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setCreatingCollection(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveExam = async (examId: number, collectionId: number | null) => {
|
||||
setMovingExamId(examId)
|
||||
try {
|
||||
await collectionApi.moveExamToCollection(examId, collectionId)
|
||||
closeMenu()
|
||||
setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e))
|
||||
setCollections((prev) => prev.map((c) => {
|
||||
if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 }
|
||||
const oldExam = exams.find((e) => e.id === examId)
|
||||
if (oldExam?.collection_id !== null && c.id === oldExam?.collection_id) return { ...c, exam_count: Math.max(0, c.exam_count - 1) }
|
||||
return c
|
||||
}))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setMovingExamId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameCollection = async (id: number) => {
|
||||
const name = editingCollectionName.trim()
|
||||
if (!name) {
|
||||
setEditingCollectionId(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await collectionApi.updateCollection(id, name)
|
||||
setCollections((prev) => prev.map((c) => c.id === id ? { ...c, name } : c))
|
||||
setEditingCollectionId(null)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCollection = async (id: number) => {
|
||||
try {
|
||||
await collectionApi.deleteCollection(id)
|
||||
setDeleteCollectionId(null)
|
||||
setExams((prev) => prev.map((e) => e.collection_id === id ? { ...e, collection_id: null } : e))
|
||||
setCollections((prev) => prev.filter((c) => c.id !== id))
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
setSelectedIds(new Set(filteredExams.map((e) => e.id)))
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (!window.confirm(t('workspace.batchDeleteConfirm'))) return
|
||||
setBatchDeleting(true)
|
||||
try {
|
||||
await examApi.batchDelete(Array.from(selectedIds))
|
||||
setSelectedIds(new Set())
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBatchDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchExport = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
setBatchExporting(true)
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
const exam = exams.find((e) => e.id === id)
|
||||
if (exam) {
|
||||
const blob = await examApi.exportExam(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${exam.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBatchExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchMove = async (collectionId: number | null) => {
|
||||
if (selectedIds.size === 0) return
|
||||
setBatchMoving(true)
|
||||
try {
|
||||
for (const id of selectedIds) {
|
||||
await collectionApi.moveExamToCollection(id, collectionId)
|
||||
}
|
||||
setBatchMoveOpen(false)
|
||||
setSelectedIds(new Set())
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBatchMoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const parseTags = (tagsJson: string): string[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(tagsJson)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const filteredExams = exams.filter((exam) => {
|
||||
if (!searchQuery.trim()) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
const tags = parseTags(exam.tags)
|
||||
return (
|
||||
exam.title.toLowerCase().includes(q) ||
|
||||
(exam.subtitle?.toLowerCase().includes(q) ?? false) ||
|
||||
tags.some((t) => t.toLowerCase().includes(q))
|
||||
)
|
||||
}).filter((exam) => {
|
||||
if (activeFilter === 'all') return true
|
||||
if (activeFilter === 'uncategorized') return exam.collection_id === null
|
||||
return exam.collection_id === activeFilter
|
||||
})
|
||||
|
||||
const uncategorizedCount = exams.filter((e) => e.collection_id === null).length
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in" style={{ display: 'flex', gap: 20 }}>
|
||||
{/* Collections Sidebar */}
|
||||
<div style={{ width: 220, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--ink-3)', marginBottom: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{t('workspace.collections')}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
|
||||
onClick={() => setShowNewCollection(true)}
|
||||
title={t('workspace.newCollection')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewCollection && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder={t('workspace.collectionNamePlaceholder')}
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateCollection() }}
|
||||
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleCreateCollection()} disabled={creatingCollection} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||||
{t('workspace.create')}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { setShowNewCollection(false); setNewCollectionName('') }} style={{ fontSize: 11, padding: '4px 8px' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm${activeFilter === 'all' ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'all' ? 600 : 400 }}
|
||||
>
|
||||
<span>{t('workspace.allExams')}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{exams.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm${activeFilter === 'uncategorized' ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter('uncategorized')}
|
||||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'uncategorized' ? 600 : 400 }}
|
||||
>
|
||||
<span>{t('workspace.uncategorized')}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{uncategorizedCount}</span>
|
||||
</button>
|
||||
{collections.map((col) => (
|
||||
<div key={col.id} style={{ position: 'relative' }}>
|
||||
{editingCollectionId === col.id ? (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={editingCollectionName}
|
||||
onChange={(e) => setEditingCollectionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleRenameCollection(col.id)
|
||||
if (e.key === 'Escape') setEditingCollectionId(null)
|
||||
}}
|
||||
onBlur={() => void handleRenameCollection(col.id)}
|
||||
style={{ fontSize: 12, padding: '3px 6px', flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`btn btn-ghost btn-sm${activeFilter === col.id ? ' active' : ''}`}
|
||||
onClick={() => setActiveFilter(col.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setEditingCollectionName(col.name)
|
||||
setEditingCollectionId(col.id)
|
||||
}}
|
||||
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === col.id ? 600 : 400 }}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{col.name}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>{col.exam_count}</span>
|
||||
</button>
|
||||
)}
|
||||
{activeFilter === col.id && editingCollectionId !== col.id && (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '0 8px 4px', marginTop: -2 }}>
|
||||
<button className="btn btn-ghost btn-sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => { setEditingCollectionName(col.name); setEditingCollectionId(col.id) }}>
|
||||
{t('workspace.rename')}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm admin-btn-danger-ghost" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setDeleteCollectionId(col.id)}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{deleteCollectionId === col.id && (
|
||||
<div style={{ padding: '4px 8px', fontSize: 11, background: 'var(--wrong-weak)', borderRadius: 6, margin: '2px 0' }}>
|
||||
<div style={{ marginBottom: 4 }}>{t('workspace.deleteCollectionConfirm')}</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="btn btn-sm admin-btn-danger" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => void handleDeleteCollection(col.id)}>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => setDeleteCollectionId(null)}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exams List */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<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>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder={t('workspace.searchExams')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Batch toolbar */}
|
||||
{!loading && filteredExams.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.size > 0 && selectedIds.size === filteredExams.length}
|
||||
onChange={() => {
|
||||
if (selectedIds.size === filteredExams.length) clearSelection()
|
||||
else selectAllFiltered()
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < filteredExams.length
|
||||
}}
|
||||
/>
|
||||
{selectedIds.size === 0 ? t('workspace.batchSelectAll') : t('workspace.batchSelected', { count: selectedIds.size })}
|
||||
</label>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<div style={{ width: 1, height: 16, background: 'var(--line-2)' }} />
|
||||
<button
|
||||
className="btn btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => void handleBatchDelete()}
|
||||
disabled={batchDeleting}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{batchDeleting ? t('common.loading') : `${t('common.delete')} (${selectedIds.size})`}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => void handleBatchExport()}
|
||||
disabled={batchExporting}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{batchExporting ? t('common.loading') : `${t('workspace.export')} (${selectedIds.size})`}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => setBatchMoveOpen((v) => !v)}
|
||||
disabled={batchMoving}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{batchMoving ? t('common.loading') : `${t('workspace.moveTo')} (${selectedIds.size})`}
|
||||
</button>
|
||||
{batchMoveOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 'calc(100% + 4px)',
|
||||
zIndex: 10,
|
||||
minWidth: 180,
|
||||
background: 'var(--paper)',
|
||||
border: '1px solid var(--line-2)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
padding: '6px 0',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="user-menu-item"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={() => void handleBatchMove(null)}
|
||||
>
|
||||
{t('workspace.uncategorized')}
|
||||
</button>
|
||||
{collections.map((col) => (
|
||||
<button
|
||||
key={col.id}
|
||||
className="user-menu-item"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={() => void handleBatchMove(col.id)}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
) : filteredExams.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">
|
||||
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResults') : t('workspace.noSavedExams')}
|
||||
</div>
|
||||
<div className="workspace-empty-desc">
|
||||
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResultsDesc') : t('workspace.noSavedExamsDesc')}
|
||||
</div>
|
||||
{!searchQuery && activeFilter === 'all' && (
|
||||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||
{t('workspace.createFirstExam')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-list">
|
||||
{filteredExams.map((exam) => {
|
||||
const tags = parseTags(exam.tags)
|
||||
return (
|
||||
<div key={exam.id} className={`workspace-card${exam.is_favorite ? ' workspace-card-favorite' : ''}`}>
|
||||
<div className="workspace-card-body">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(exam.id)}
|
||||
onChange={() => toggleSelect(exam.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => void handleFavorite(exam.id, !exam.is_favorite)}
|
||||
title={exam.is_favorite ? t('workspace.unpin') : t('workspace.pin')}
|
||||
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
|
||||
>
|
||||
{exam.is_favorite ? '★' : '☆'}
|
||||
</button>
|
||||
<div className="workspace-card-title">{exam.title}</div>
|
||||
</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>
|
||||
{tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--paper)',
|
||||
color: 'var(--ink-3)',
|
||||
border: '1px solid var(--line-2)',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-card-actions">
|
||||
{deleteConfirm === exam.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<span style={{ fontSize: 13, color: 'var(--ink-3)', marginRight: 4 }}>
|
||||
{t('workspace.batchDeleteConfirm')}
|
||||
</span>
|
||||
<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-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>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm workspace-menu-trigger"
|
||||
onClick={() => menuOpenId === exam.id ? closeMenu() : openMenu(exam.id)}
|
||||
disabled={startingId === exam.id}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpenId === exam.id}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpenId === exam.id && (
|
||||
<>
|
||||
<button
|
||||
className="workspace-menu-backdrop"
|
||||
onClick={closeMenu}
|
||||
aria-label="Close menu"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div className="workspace-menu" role="menu">
|
||||
{menuSection === 'main' && (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => { closeMenu(); void handleDuplicate(exam.id) }}
|
||||
disabled={duplicatingId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">⧉</span>
|
||||
<span>{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => { closeMenu(); void handleExport(exam.id, exam.title) }}
|
||||
disabled={exportingId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">↓</span>
|
||||
<span>{exportingId === exam.id ? t('common.loading') : t('workspace.export')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => setMenuSection('move')}
|
||||
disabled={movingExamId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">▤</span>
|
||||
<span>{t('workspace.moveTo')}</span>
|
||||
<span className="workspace-menu-chevron">›</span>
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => setMenuSection('share')}
|
||||
disabled={sharingId === exam.id}
|
||||
>
|
||||
<span className="workspace-menu-icon">↗</span>
|
||||
<span>
|
||||
{sharingId === exam.id
|
||||
? t('common.loading')
|
||||
: (exam.share_token ? t('share.shared') : t('share.share'))}
|
||||
</span>
|
||||
<span className="workspace-menu-chevron">›</span>
|
||||
</button>
|
||||
<div className="workspace-menu-divider" />
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
|
||||
onClick={() => { closeMenu(); setDeleteConfirm(exam.id) }}
|
||||
>
|
||||
<span className="workspace-menu-icon">✕</span>
|
||||
<span>{t('common.delete')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{menuSection === 'move' && (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-back"
|
||||
onClick={() => setMenuSection('main')}
|
||||
>
|
||||
‹ {t('common.back')}
|
||||
</button>
|
||||
<div className="workspace-menu-divider" />
|
||||
{exam.collection_id !== null && (
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => void handleMoveExam(exam.id, null)}
|
||||
>
|
||||
{t('workspace.removeFromCollection')}
|
||||
</button>
|
||||
)}
|
||||
{collections.map((col) => (
|
||||
col.id !== exam.collection_id && (
|
||||
<button
|
||||
key={col.id}
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => void handleMoveExam(exam.id, col.id)}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
{collections.length === 0 && exam.collection_id === null && (
|
||||
<div className="user-menu-item workspace-menu-item" style={{ color: 'var(--ink-3)', cursor: 'default' }}>
|
||||
{t('workspace.collections')}: 0
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{menuSection === 'share' && (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-back"
|
||||
onClick={() => setMenuSection('main')}
|
||||
>
|
||||
‹ {t('common.back')}
|
||||
</button>
|
||||
<div className="workspace-menu-divider" />
|
||||
{exam.share_token ? (
|
||||
<>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/share/${exam.share_token}`
|
||||
void navigator.clipboard.writeText(url)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
{t('share.copyLink')}
|
||||
</button>
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
|
||||
onClick={() => void handleUnshare(exam.id)}
|
||||
>
|
||||
{t('share.revokeLink')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="user-menu-item workspace-menu-item"
|
||||
onClick={() => void handleShare(exam.id)}
|
||||
>
|
||||
{sharingId === exam.id ? t('common.loading') : t('share.generateLink')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
import * as examApi from '../api/examApi'
|
||||
import { getExamUrlId } from '../utils/slug'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
interface ResultItem {
|
||||
id: number
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
passing_score: number
|
||||
elapsed_ms: number
|
||||
completed_at: number
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
export default function MyResults({ onLoadExam }: Props) {
|
||||
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 [retakingId, setRetakingId] = 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 retakeExam = async (item: ResultItem) => {
|
||||
if (!item.exam_id) return
|
||||
setRetakingId(item.id)
|
||||
try {
|
||||
const exam = await examApi.getExam(item.exam_id)
|
||||
onLoadExam(exam, false, item.exam_id)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setRetakingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}))
|
||||
localStorage.setItem('brainwave.review.resultId', String(item.id))
|
||||
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-primary btn-sm"
|
||||
onClick={() => void retakeExam(r)}
|
||||
disabled={retakingId === r.id || !r.exam_id}
|
||||
title={r.exam_id ? undefined : t('results.cantRetake')}
|
||||
>
|
||||
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
|
||||
</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
import * as examApi from '../api/examApi'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { validateExam, parseExam } from '../utils/validation'
|
||||
import type { Exam } from '../types'
|
||||
import StudyCoach from './StudyCoach'
|
||||
|
||||
interface Props {
|
||||
examCount: number
|
||||
resultCount: number
|
||||
recentResults: resultApi.ResultListItem[]
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
export default function Overview({ examCount, resultCount, recentResults, onLoadExam }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadErr, setUploadErr] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [trends, setTrends] = useState<resultApi.ScoreTrend[]>([])
|
||||
const [trendsLoading, setTrendsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
resultApi.getScoreTrends()
|
||||
.then((data) => setTrends(data))
|
||||
.catch(() => {/* ignore */})
|
||||
.finally(() => setTrendsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setUploadErr(null)
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text) as unknown
|
||||
const msg = validateExam(data)
|
||||
if (msg) throw new Error(msg)
|
||||
onLoadExam(parseExam(data), true)
|
||||
} catch (e) {
|
||||
setUploadErr(t('landing.loadError', { message: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetakeWeakest = async (examId: number) => {
|
||||
try {
|
||||
const exam = await examApi.getExam(examId)
|
||||
onLoadExam(exam, false, examId)
|
||||
} catch {
|
||||
setUploadErr(t('landing.loadError', { message: 'Could not load exam' }))
|
||||
}
|
||||
}
|
||||
|
||||
const resumable = loadState()
|
||||
const totalQuestions = recentResults.reduce((sum, r) => sum + (r.graded_count || 0), 0)
|
||||
const avgScore = recentResults.length > 0
|
||||
? Math.round(recentResults.reduce((sum, r) => sum + (r.total_points ? (r.score_points / r.total_points) * 100 : 0), 0) / recentResults.length)
|
||||
: 0
|
||||
|
||||
// SVG chart dimensions
|
||||
const margin = { top: 10, right: 20, bottom: 30, left: 40 }
|
||||
const viewW = 520
|
||||
const viewH = 200
|
||||
const cw = viewW - margin.left - margin.right
|
||||
const ch = viewH - margin.top - margin.bottom
|
||||
|
||||
const chartData = trends.slice(-20)
|
||||
const hasChartData = chartData.length >= 2
|
||||
|
||||
const xMin = chartData.length ? chartData[0].completed_at : 0
|
||||
const xMax = chartData.length ? chartData[chartData.length - 1].completed_at : 0
|
||||
const xRange = xMax - xMin || 1
|
||||
|
||||
const xScale = (v: number) => margin.left + ((v - xMin) / xRange) * cw
|
||||
const yScale = (v: number) => margin.top + ch - (v / 100) * ch
|
||||
|
||||
const linePoints = chartData.map((d, i) => {
|
||||
const x = xScale(d.completed_at)
|
||||
const y = yScale(d.score_pct)
|
||||
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
}).join(' ')
|
||||
|
||||
const gridLines = [0, 25, 50, 75, 100].map((pct) => ({
|
||||
y: yScale(pct),
|
||||
label: `${pct}%`,
|
||||
}))
|
||||
|
||||
const xLabelIndices = [...new Set([0, Math.floor(chartData.length / 2), chartData.length - 1])]
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
{/* Dashboard Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 24 }}>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{examCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.myExams')}</div>
|
||||
</div>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{resultCount}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.totalAttempts')}</div>
|
||||
</div>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{avgScore}%</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.avgScore')}</div>
|
||||
</div>
|
||||
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{totalQuestions}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.questionsAnswered')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropzone${dragOver ? ' drag-over' : ''}`}
|
||||
style={{ flex: 1, minWidth: 220, padding: 16 }}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); setDragOver(false)
|
||||
const f = e.dataTransfer.files?.[0]
|
||||
if (f) void handleFile(f)
|
||||
}}
|
||||
>
|
||||
<div className="dropzone-icon" style={{ marginBottom: 6 }}>
|
||||
<svg width="18" height="18" 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>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.uploadExam')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.uploadDropzone')}</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f) }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="workspace-card"
|
||||
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)' }}
|
||||
onClick={() => navigate('/exam-editor/new')}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 6 }}>+</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.newExam')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.createFirstExam')}</div>
|
||||
</button>
|
||||
|
||||
{resumable && (
|
||||
<button
|
||||
className="workspace-card"
|
||||
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)', borderColor: 'var(--accent)' }}
|
||||
onClick={() => onLoadExam(resumable.exam, false, null)}
|
||||
>
|
||||
<div style={{ fontSize: 20, marginBottom: 6 }}>▶</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('landing.resumeTitle')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{resumable.exam.title}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadErr && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 14px', border: '1px solid var(--wrong)', borderRadius: 8, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
|
||||
{uploadErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Study Coach */}
|
||||
<StudyCoach onRetakeWeakest={handleRetakeWeakest} />
|
||||
|
||||
{/* Performance Trends */}
|
||||
{hasChartData && !trendsLoading && (
|
||||
<div className="workspace-card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ padding: '16px 20px 4px' }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>{t('workspace.performanceTrends')}</h3>
|
||||
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 'auto', overflow: 'visible' }}>
|
||||
{/* Grid lines */}
|
||||
{gridLines.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line
|
||||
x1={margin.left}
|
||||
x2={margin.left + cw}
|
||||
y1={g.y}
|
||||
y2={g.y}
|
||||
stroke="var(--line-2)"
|
||||
strokeDasharray="2,2"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text x={margin.left - 6} y={g.y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Trend line */}
|
||||
<path
|
||||
d={linePoints}
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Data points */}
|
||||
{chartData.map((d, i) => {
|
||||
const x = xScale(d.completed_at)
|
||||
const y = yScale(d.score_pct)
|
||||
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={4}
|
||||
fill="var(--accent)"
|
||||
stroke="var(--paper-2)"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<title>{`${d.title} — ${d.score_pct}% (${date})`}</title>
|
||||
</circle>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* X-axis labels */}
|
||||
{xLabelIndices.map((idx) => {
|
||||
const d = chartData[idx]
|
||||
if (!d) return null
|
||||
const x = xScale(d.completed_at)
|
||||
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<text key={idx} x={x} y={viewH - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">
|
||||
{date}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Results */}
|
||||
{recentResults.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.recentResults')}</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/results')}>
|
||||
{t('workspace.viewAll')} →
|
||||
</button>
|
||||
</div>
|
||||
<div className="workspace-list">
|
||||
{recentResults.map((r) => {
|
||||
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
|
||||
const passed = pct >= (r.passing_score ?? 60)
|
||||
const d = new Date(r.completed_at * 1000)
|
||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={r.id} className="workspace-card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{r.title}</div>
|
||||
<div className="muted" style={{ fontSize: 11 }}>{dateStr}</div>
|
||||
</div>
|
||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`} style={{ fontSize: 11 }}>{pct}%</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<div className="workspace-card" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 14, marginBottom: 8 }}>{t('workspace.signInPrompt')}</div>
|
||||
<button className="btn btn-primary" onClick={() => { /* handled by parent */ }}>
|
||||
{t('auth.signIn')}
|
||||
</button>
|
||||
</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,74 +1,152 @@
|
||||
import { useState } from 'react'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
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'
|
||||
import * as gradeApi from '../api/gradeApi'
|
||||
import * as aiApi from '../api/aiApi'
|
||||
|
||||
interface Props {
|
||||
exam: Exam
|
||||
state: ExamState
|
||||
onRestart: () => void
|
||||
onExit: () => void
|
||||
onRedoFailed?: () => void
|
||||
reviewOnly?: boolean
|
||||
resultId?: number | null
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) {
|
||||
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
|
||||
|
||||
function isAiApplicable(q: Question): boolean {
|
||||
return AI_TYPES.has(q.type)
|
||||
}
|
||||
|
||||
function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
|
||||
switch (q.type) {
|
||||
case 'essay': return 'essay'
|
||||
case 'short-answer': return 'short-answer'
|
||||
case 'translation': return 'translation'
|
||||
case 'math-input': return 'math'
|
||||
case 'match': return 'match'
|
||||
case 'order': return 'ordering'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly, resultId }: Props) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { answers, startedAt, completedAt } = state
|
||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||
|
||||
const graded = exam.questions.map((q) => ({
|
||||
q,
|
||||
userAnswer: answers[q.id],
|
||||
result: gradeQuestion(q, answers[q.id]),
|
||||
}))
|
||||
const [aiReviews, setAiReviews] = useState<Record<string, gradeApi.GradingResult>>({})
|
||||
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
|
||||
const [aiErrors, setAiErrors] = useState<Record<string, string>>({})
|
||||
const [acceptedGrades, setAcceptedGrades] = useState<Record<string, number>>({})
|
||||
|
||||
const graded = exam.questions.map((q) => {
|
||||
const accepted = acceptedGrades[q.id]
|
||||
const base = gradeQuestion(q, answers[q.id])
|
||||
if (accepted != null) {
|
||||
const pts = q.points ?? 1
|
||||
return {
|
||||
...base,
|
||||
status: (accepted >= 0.5 ? 'correct' : 'wrong') as typeof base.status,
|
||||
correct: accepted >= 0.5,
|
||||
points: Math.round(pts * accepted),
|
||||
}
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const totalPoints = exam.questions.reduce((a, q) => a + (q.points ?? 1), 0)
|
||||
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0)
|
||||
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length
|
||||
const correctCount = graded.filter((g) => g.result.correct).length
|
||||
const scorePoints = graded.reduce((a, g) => a + g.points, 0)
|
||||
const gradedCount = graded.filter((g) => g.status !== 'ungraded').length
|
||||
const correctCount = graded.filter((g) => g.correct).length
|
||||
const failedCount = gradedCount - correctCount
|
||||
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
|
||||
const passing = exam.passingScore ?? 60
|
||||
const passed = pct >= passing
|
||||
|
||||
const byCat: Record<string, { earned: number; possible: number; correct: number; total: number }> = {}
|
||||
graded.forEach((g) => {
|
||||
const cat = g.q.category ?? 'Uncategorized'
|
||||
graded.forEach((g, i) => {
|
||||
const cat = exam.questions[i].category ?? 'Uncategorized'
|
||||
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 }
|
||||
byCat[cat].possible += g.q.points ?? 1
|
||||
byCat[cat].earned += g.result.points
|
||||
byCat[cat].possible += exam.questions[i].points ?? 1
|
||||
byCat[cat].earned += g.points
|
||||
byCat[cat].total += 1
|
||||
if (g.result.correct) byCat[cat].correct += 1
|
||||
if (g.correct) byCat[cat].correct += 1
|
||||
})
|
||||
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null)
|
||||
|
||||
const runAiReview = async (q: Question) => {
|
||||
const type = mapTypeForApi(q)
|
||||
if (!type) return
|
||||
setAiLoading((prev) => ({ ...prev, [q.id]: true }))
|
||||
setAiErrors((prev) => { const n = { ...prev }; delete n[q.id]; return n })
|
||||
try {
|
||||
const result = await gradeApi.gradeQuestion({
|
||||
type,
|
||||
question: q as unknown as Record<string, unknown>,
|
||||
answer: answers[q.id],
|
||||
language: i18n.language === 'nl' ? 'nl' : 'en',
|
||||
})
|
||||
setAiReviews((prev) => ({ ...prev, [q.id]: result }))
|
||||
} catch (e) {
|
||||
setAiErrors((prev) => ({ ...prev, [q.id]: (e as Error).message }))
|
||||
} finally {
|
||||
setAiLoading((prev) => { const n = { ...prev }; delete n[q.id]; return n })
|
||||
}
|
||||
}
|
||||
|
||||
const acceptGrade = (qid: string, grade: number) => {
|
||||
setAcceptedGrades((prev) => ({ ...prev, [qid]: grade }))
|
||||
}
|
||||
|
||||
const keepOriginal = (qid: string) => {
|
||||
setAcceptedGrades((prev) => { const n = { ...prev }; delete n[qid]; return n })
|
||||
setAiReviews((prev) => { const n = { ...prev }; delete n[qid]; return n })
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'correct': return t('exam.correct')
|
||||
case 'wrong': return t('exam.notQuite')
|
||||
case 'ungraded': return 'Ungraded'
|
||||
case 'unanswered': return 'Unanswered'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
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 +154,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,40 +169,101 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="review-heading">Review</h2>
|
||||
{graded.map((g, i) => {
|
||||
const promptText = g.q.prompt
|
||||
{resultId != null && <TutorChatPanel resultId={resultId} />}
|
||||
|
||||
<h2 className="review-heading">{t('results.review')}</h2>
|
||||
{exam.questions.map((q, i) => {
|
||||
const g = graded[i]
|
||||
const promptText = q.prompt
|
||||
.replace(/\$\$([^$]+)\$\$/g, '[math]')
|
||||
.replace(/\$([^$]+)\$/g, '[math]')
|
||||
const aiResult = aiReviews[q.id]
|
||||
const aiBusy = aiLoading[q.id]
|
||||
const aiErr = aiErrors[q.id]
|
||||
const accepted = acceptedGrades[q.id]
|
||||
const applicable = isAiApplicable(q)
|
||||
|
||||
return (
|
||||
<div key={g.q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
|
||||
<div key={q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
|
||||
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
|
||||
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="review-title">
|
||||
<Tex>{promptText.slice(0, 90)}</Tex>
|
||||
{promptText.length > 90 ? '…' : ''}
|
||||
</span>
|
||||
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
|
||||
<span className={`review-status ${g.status}`}>
|
||||
{statusLabel(g.status)}
|
||||
{accepted != null && ' ✦'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
|
||||
</div>
|
||||
{openIdx === i && (
|
||||
<div className="review-body">
|
||||
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
||||
<Tex>{g.q.prompt}</Tex>
|
||||
<Tex markdown>{q.prompt}</Tex>
|
||||
</div>
|
||||
<QuestionMedia q={g.q} />
|
||||
<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-body"><Tex>{g.q.explanation}</Tex></div>
|
||||
<QuestionMedia q={q} />
|
||||
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
|
||||
|
||||
{applicable && !reviewOnly && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
{!aiResult && !aiBusy && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); void runAiReview(q) }}
|
||||
>
|
||||
{t('results.ai.button')} ▶
|
||||
</button>
|
||||
)}
|
||||
{aiBusy && (
|
||||
<span className="muted" style={{ fontSize: 13 }}>{t('common.loading')}</span>
|
||||
)}
|
||||
{aiErr && (
|
||||
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
|
||||
{t('results.ai.error')}: {aiErr}
|
||||
</div>
|
||||
)}
|
||||
{aiResult && (
|
||||
<div style={{ marginTop: 12, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
||||
{t('results.ai.suggestedGrade')}: {Math.round(aiResult.suggestedGrade * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||
{aiResult.feedback}
|
||||
</div>
|
||||
{!!aiResult.detail && (
|
||||
<AiDetail detail={aiResult.detail} type={q.type} />
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); acceptGrade(q.id, aiResult.suggestedGrade) }}
|
||||
>
|
||||
{t('results.ai.accept')} ✓
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); keepOriginal(q.id) }}
|
||||
>
|
||||
{t('results.ai.keepOriginal')} ✗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{g.q.rubric && (
|
||||
|
||||
{q.explanation && (
|
||||
<div className="feedback" style={{ marginTop: 24 }}>
|
||||
<div className="feedback-label">{t('results.explanation')}</div>
|
||||
<div className="feedback-body"><Tex>{q.explanation}</Tex></div>
|
||||
</div>
|
||||
)}
|
||||
{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>)}
|
||||
{q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -135,9 +274,215 @@ 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>
|
||||
{onRedoFailed && failedCount > 0 && (
|
||||
<button className="btn" onClick={onRedoFailed}>
|
||||
{t('results.redoFailed')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiDetail({ detail, type }: { detail: unknown; type: string }) {
|
||||
const { t } = useTranslation()
|
||||
if (!detail || typeof detail !== 'object') return null
|
||||
|
||||
const d = detail as Record<string, unknown>
|
||||
|
||||
if (type === 'essay' && Array.isArray(d.rubricBreakdown)) {
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="meta-label" style={{ marginBottom: 6, fontSize: 12 }}>{t('results.ai.rubricBreakdown')}</div>
|
||||
<table style={{ fontSize: 12, width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{(d.rubricBreakdown as Array<{ item: string; met: boolean }>).map((r, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--line-1)' }}>
|
||||
<td style={{ padding: '4px 0' }}>{r.item}</td>
|
||||
<td style={{ textAlign: 'right', color: r.met ? 'var(--right)' : 'var(--wrong)' }}>
|
||||
{r.met ? '✓' : '✗'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if ((type === 'ordering' || type === 'order') && Array.isArray(d.correctPositions)) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{t('results.ai.suggestedGrade')}: {(d.correctPositions as number[]).length} / {Number(d.totalItems ?? 0)} {t('results.correct').toLowerCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'match' && (Array.isArray(d.correctPairs) || Array.isArray(d.incorrectPairs))) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12 }}>
|
||||
{Array.isArray(d.correctPairs) && (d.correctPairs as string[]).length > 0 && (
|
||||
<div style={{ color: 'var(--right)', marginBottom: 4 }}>
|
||||
✓ {(d.correctPairs as string[]).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(d.incorrectPairs) && (d.incorrectPairs as string[]).length > 0 && (
|
||||
<div style={{ color: 'var(--wrong)' }}>
|
||||
✗ {(d.incorrectPairs as string[]).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'math-input' && typeof d.equivalent === 'boolean') {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{d.explanation as string}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'translation' && (typeof d.accuracy === 'number' || typeof d.fluency === 'number')) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
Accuracy: {Math.round((d.accuracy as number) * 100)}% · Fluency: {Math.round((d.fluency as number) * 100)}%
|
||||
{!!d.notes && <div style={{ marginTop: 4 }}>{d.notes as string}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'short-answer' && d.matchedForm != null) {
|
||||
return (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||
{d.reason as string}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function TutorChatPanel({ resultId }: { resultId: number }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [messages, setMessages] = useState<aiApi.TutorChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
const next: aiApi.TutorChatMessage[] = [...messages, { role: 'user', content: text }]
|
||||
setMessages(next)
|
||||
setInput('')
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await aiApi.tutorChat({
|
||||
resultId,
|
||||
messages: next,
|
||||
language: i18n.language || 'en',
|
||||
})
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply }])
|
||||
} catch (e) {
|
||||
setErr((e as Error).message)
|
||||
setMessages((prev) => prev.slice(0, -1))
|
||||
setInput(text)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: '24px 0', border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '14px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--ink-1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>{t('results.tutor.title')}</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 10 }}>
|
||||
{t('results.tutor.subtitle')}
|
||||
</div>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 10, fontStyle: 'italic' }}>
|
||||
{t('results.tutor.placeholder')}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
background: m.role === 'user' ? 'var(--accent-weak, var(--paper))' : 'var(--paper)',
|
||||
border: '1px solid var(--line-1)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ marginBottom: 8, padding: '6px 10px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 12 }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void send()
|
||||
}
|
||||
}}
|
||||
placeholder={t('results.tutor.inputPlaceholder')}
|
||||
disabled={busy}
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
{busy ? t('common.loading') : t('results.tutor.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function StoragePreview() {
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const used = user?.usedBytes ?? 0
|
||||
const quota = user?.storageQuotaBytes ?? 1073741824
|
||||
const percent = user?.usedPercent ?? 0
|
||||
|
||||
let barColor = 'var(--accent)'
|
||||
let warningText: string | null = null
|
||||
if (percent >= 95) {
|
||||
barColor = 'var(--wrong)'
|
||||
warningText = t('workspace.storageWarningCritical')
|
||||
} else if (percent >= 80) {
|
||||
barColor = 'var(--warn)'
|
||||
warningText = t('workspace.storageWarningHigh')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="storage-overview">
|
||||
<div className="storage-bar-track">
|
||||
<div
|
||||
className="storage-bar-fill"
|
||||
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="storage-stats">
|
||||
<span className="storage-used">{formatBytes(used)}</span>
|
||||
<span className="storage-of"> {t('workspace.storageOf')} </span>
|
||||
<span className="storage-total">{formatBytes(quota)}</span>
|
||||
<span className="storage-percent"> ({percent}%)</span>
|
||||
</div>
|
||||
{warningText && (
|
||||
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
</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 className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>{t('workspace.storage')}</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body workspace-settings-form">
|
||||
<StoragePreview />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as examApi from '../api/examApi'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
export default function ShareView({ onLoadExam }: { onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<{ title: string; subtitle: string | null; questionCount: number; exam: Exam; shareMode: string } | null>(null)
|
||||
const [cloning, setCloning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Invalid link')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
examApi.getSharedExam(token)
|
||||
.then((res) => {
|
||||
setData({
|
||||
title: res.title,
|
||||
subtitle: res.subtitle,
|
||||
questionCount: res.questionCount,
|
||||
exam: res.exam as Exam,
|
||||
shareMode: res.shareMode ?? 'clone',
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e) => {
|
||||
setError((e as Error).message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [token])
|
||||
|
||||
const handleTake = () => {
|
||||
if (!data) return
|
||||
onLoadExam(data.exam, false, null)
|
||||
}
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!token || !data) return
|
||||
if (!user) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
setCloning(true)
|
||||
try {
|
||||
await examApi.cloneSharedExam(token)
|
||||
navigate('/workspace/exams')
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
setCloning(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="workspace-empty">{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">!</div>
|
||||
<div className="workspace-empty-title">{error || 'Not found'}</div>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/')} style={{ marginTop: 16 }}>
|
||||
{t('results.backToHome')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="landing" style={{ maxWidth: 640, margin: '0 auto', paddingTop: 40 }}>
|
||||
<div className="landing-eyebrow">{t('common.appName')}</div>
|
||||
<h1 className="landing-title">{data.title}</h1>
|
||||
{data.subtitle && <p className="landing-sub">{data.subtitle}</p>}
|
||||
|
||||
<div style={{ marginTop: 24, padding: 20, border: '1px solid var(--line-2)', borderRadius: 12, background: 'var(--paper-2)' }}>
|
||||
<div style={{ fontSize: 14, marginBottom: 8 }}>
|
||||
<span className="mono" style={{ color: 'var(--ink-3)' }}>{data.questionCount}</span> {data.questionCount === 1 ? t('common.question') : t('common.questions')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
|
||||
<button className="btn btn-primary" onClick={handleTake}>
|
||||
{t('share.takeExam')}
|
||||
</button>
|
||||
{user && data.shareMode !== 'view' ? (
|
||||
<button className="btn btn-ghost" onClick={() => void handleClone()} disabled={cloning}>
|
||||
{cloning ? t('common.loading') : t('share.saveToWorkspace')}
|
||||
</button>
|
||||
) : user ? (
|
||||
<span className="muted" style={{ fontSize: 12, alignSelf: 'center' }}>
|
||||
{t('workspace.shareModeView')}
|
||||
</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate('/')}>
|
||||
{t('auth.signIn')} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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 Props {
|
||||
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||
}
|
||||
|
||||
export default function SharedWithMe({ onLoadExam }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState<examApi.ClonedExamItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await examApi.listClonedExams()
|
||||
setExams(data)
|
||||
} catch {
|
||||
setExams([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="workspace-section">
|
||||
<div className="workspace-empty" style={{ padding: 40 }}>{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (exams.length === 0) {
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 600 }}>{t('workspace.sharedWithMe')}</h2>
|
||||
<div className="workspace-empty" style={{ padding: 40 }}>
|
||||
<div className="workspace-empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="workspace-empty-title">{t('workspace.noSharedExams')}</div>
|
||||
<div className="workspace-empty-desc">{t('workspace.noSharedExamsDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 600 }}>{t('workspace.sharedWithMe')}</h2>
|
||||
<div className="workspace-list" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{exams.map((exam) => (
|
||||
<div
|
||||
key={exam.id}
|
||||
className="workspace-card"
|
||||
style={{ padding: 16, display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
void examApi.getExam(exam.id).then((e) => onLoadExam(e, false, exam.id))
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{exam.title}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
navigate(`/exam-editor/${exam.id}`)
|
||||
}}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export default function StorageManager() {
|
||||
const { t } = useTranslation()
|
||||
const { user, refreshUser } = useAuth()
|
||||
const [exams, setExams] = useState<examApi.ExamListItem[]>([])
|
||||
const [results, setResults] = useState<resultApi.ResultListItem[]>([])
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [deleting, setDeleting] = useState<number | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const examList = await examApi.listExams()
|
||||
setExams(examList)
|
||||
const resultList = await resultApi.listResults()
|
||||
setResults(resultList.results)
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleDeleteExam = useCallback(async (id: number) => {
|
||||
if (!window.confirm(t('workspace.deleteExamConfirm'))) return
|
||||
setDeleting(id)
|
||||
try {
|
||||
await examApi.deleteExam(id)
|
||||
await loadData()
|
||||
await refreshUser()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}, [loadData, refreshUser, t])
|
||||
|
||||
const handleDeleteResult = useCallback(async (id: number) => {
|
||||
if (!window.confirm(t('workspace.deleteResultConfirm'))) return
|
||||
setDeleting(id)
|
||||
try {
|
||||
await resultApi.deleteResult(id)
|
||||
await loadData()
|
||||
await refreshUser()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}, [loadData, refreshUser, t])
|
||||
|
||||
const used = user?.usedBytes ?? 0
|
||||
const quota = user?.storageQuotaBytes ?? 1073741824
|
||||
const percent = user?.usedPercent ?? 0
|
||||
|
||||
let barColor = 'var(--accent)'
|
||||
let warningText: string | null = null
|
||||
if (percent >= 95) {
|
||||
barColor = 'var(--wrong)'
|
||||
warningText = t('workspace.storageWarningCritical')
|
||||
} else if (percent >= 80) {
|
||||
barColor = 'var(--warn)'
|
||||
warningText = t('workspace.storageWarningHigh')
|
||||
}
|
||||
|
||||
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.storage')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>{t('workspace.storageUsage')}</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body workspace-settings-form">
|
||||
<div className="storage-overview">
|
||||
<div className="storage-bar-track">
|
||||
<div
|
||||
className="storage-bar-fill"
|
||||
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="storage-stats">
|
||||
<span className="storage-used">{formatBytes(used)}</span>
|
||||
<span className="storage-of"> {t('workspace.storageOf')} </span>
|
||||
<span className="storage-total">{formatBytes(quota)}</span>
|
||||
<span className="storage-percent"> ({percent}%)</span>
|
||||
</div>
|
||||
{warningText && (
|
||||
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="storage-breakdown">
|
||||
<div className="storage-breakdown-row">
|
||||
<span>{t('workspace.storageExams')}</span>
|
||||
<span>{exams.length}</span>
|
||||
</div>
|
||||
<div className="storage-breakdown-row">
|
||||
<span>{t('workspace.storageResults')}</span>
|
||||
<span>{results.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-settings-footer">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setShowDetails((v) => !v)}
|
||||
>
|
||||
{showDetails ? t('workspace.hideDetails') : t('workspace.showDetails')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>{t('workspace.storageDetails')}</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body">
|
||||
{exams.length === 0 && results.length === 0 ? (
|
||||
<p className="text-muted">{t('workspace.noStorageItems')}</p>
|
||||
) : (
|
||||
<div className="storage-detail-list">
|
||||
{exams.map((exam) => (
|
||||
<div key={`exam-${exam.id}`} className="storage-detail-item">
|
||||
<div className="storage-detail-info">
|
||||
<span className="storage-detail-type">{t('workspace.exam')}</span>
|
||||
<span className="storage-detail-title">{exam.title}</span>
|
||||
</div>
|
||||
<div className="storage-detail-actions">
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteExam(exam.id)}
|
||||
disabled={deleting === exam.id}
|
||||
>
|
||||
{deleting === exam.id ? t('common.loading') : t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{results.map((result) => (
|
||||
<div key={`result-${result.id}`} className="storage-detail-item">
|
||||
<div className="storage-detail-info">
|
||||
<span className="storage-detail-type">{t('workspace.result')}</span>
|
||||
<span className="storage-detail-title">{result.title}</span>
|
||||
</div>
|
||||
<div className="storage-detail-actions">
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteResult(result.id)}
|
||||
disabled={deleting === result.id}
|
||||
>
|
||||
{deleting === result.id ? t('common.loading') : t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import * as resultApi from '../api/resultApi'
|
||||
|
||||
interface Props {
|
||||
onRetakeWeakest?: (examId: number) => void
|
||||
}
|
||||
|
||||
export default function StudyCoach({ onRetakeWeakest }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<resultApi.StudyCoachResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
resultApi.analyzeResults()
|
||||
.then((res) => setData(res))
|
||||
.catch(() => {/* ignore */})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="workspace-card" style={{ marginBottom: 24 }}>
|
||||
<div className="workspace-empty" style={{ padding: 24 }}>{t('common.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || (data.weakAreas.length === 0 && data.studyTips.length === 0 && !data.weakestExamId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-card" style={{ marginBottom: 24, borderLeft: '3px solid var(--accent)' }}>
|
||||
<div style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>🤖</span>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.studyCoachTitle')}</h3>
|
||||
{data.streakDays > 0 && (
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 'auto' }}>
|
||||
🔥 {data.streakDays} {data.streakDays === 1 ? t('workspace.streakDay') : t('workspace.streakDays')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.weakestExamId && onRetakeWeakest && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'var(--wrong)', color: 'white', borderColor: 'var(--wrong)' }}
|
||||
onClick={() => onRetakeWeakest(data.weakestExamId!)}
|
||||
>
|
||||
🔄 {t('workspace.retakeWeakest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.weakAreas.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{data.weakAreas.slice(0, 3).map((area) => (
|
||||
<div key={area.category} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1, height: 6, background: 'var(--line-2)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${area.avgScore}%`,
|
||||
height: '100%',
|
||||
background: area.avgScore >= 60 ? 'var(--ok)' : 'var(--wrong)',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 12, minWidth: 40, textAlign: 'right' }}>
|
||||
{area.avgScore}%
|
||||
</span>
|
||||
<span style={{ fontSize: 13, minWidth: 120 }}>{area.category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.studyTips.length > 0 && (
|
||||
<div style={{ padding: 12, background: 'var(--paper)', borderRadius: 8 }}>
|
||||
<div className="mono" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--ink-3)', marginBottom: 8 }}>
|
||||
{t('workspace.studyTips')}
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
||||
{data.studyTips.map((tip, i) => (
|
||||
<li key={i}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate('/workspace/results')}
|
||||
>
|
||||
{t('workspace.viewAllResults')} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import type React from 'react'
|
||||
import { renderLatex } from '../utils/latex'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
|
||||
interface TexProps {
|
||||
children?: string | null
|
||||
tag?: keyof React.JSX.IntrinsicElements
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
markdown?: boolean
|
||||
}
|
||||
|
||||
// Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text.
|
||||
export default function Tex({ children, tag = 'span', className, style }: Readonly<TexProps>) {
|
||||
// When markdown is true, uses renderMarkdown which applies marked + KaTeX + DOMPurify.
|
||||
export default function Tex({ children, tag = 'span', className, style, markdown }: Readonly<TexProps>) {
|
||||
const Tag = tag as React.ElementType
|
||||
const html = String(children ?? '').split('\n').map(renderLatex).join('<br/>')
|
||||
const text = String(children ?? '')
|
||||
const html = markdown
|
||||
? renderMarkdown(text)
|
||||
: text.split('\n').map(renderLatex).join('<br/>')
|
||||
return <Tag className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
@@ -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,133 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ReactNode } 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 { downloadInstructions } from '../utils/instructions'
|
||||
import type { Exam } from '../types'
|
||||
import MyExams from './MyExams'
|
||||
import MyResults from './MyResults'
|
||||
import Settings from './Settings'
|
||||
import StorageManager from './StorageManager'
|
||||
import Overview from './Overview'
|
||||
import SharedWithMe from './SharedWithMe'
|
||||
|
||||
type TabKey = 'overview' | 'exams' | 'shared' | 'results' | 'storage' | 'settings'
|
||||
|
||||
const TAB_KEYS: { key: TabKey; labelKey: string; icon: ReactNode }[] = [
|
||||
{ key: 'overview', labelKey: 'workspace.overview', icon: '◈' },
|
||||
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
|
||||
{
|
||||
key: 'shared',
|
||||
labelKey: 'workspace.sharedWithMe',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
|
||||
{ key: 'storage', labelKey: 'workspace.storage', 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) ?? 'overview'
|
||||
const [examCount, setExamCount] = useState(0)
|
||||
const [resultCount, setResultCount] = useState(0)
|
||||
const [recentResults, setRecentResults] = useState<resultApi.ResultListItem[]>([])
|
||||
|
||||
const loadCounts = useCallback(async () => {
|
||||
try {
|
||||
const exams = await examApi.listExams()
|
||||
setExamCount(exams.length)
|
||||
const results = await resultApi.listResults()
|
||||
setResultCount(results.total)
|
||||
setRecentResults(results.results.slice(0, 5))
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) void loadCounts()
|
||||
}, [user, loadCounts])
|
||||
|
||||
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
|
||||
|
||||
if (authLoading) return null
|
||||
if (!user) return <Navigate to="/" replace />
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="workspace-sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={downloadInstructions}
|
||||
title={t('workspace.downloadInstructions')}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, border: '1.5px solid currentColor', borderRadius: 5, fontSize: 12, lineHeight: 1, flexShrink: 0 }}>
|
||||
{t('landing.downloadInstructionsIcon')}
|
||||
</span>
|
||||
{t('workspace.downloadInstructions')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{activeTab === 'overview' && (
|
||||
<Overview
|
||||
examCount={examCount}
|
||||
resultCount={resultCount}
|
||||
recentResults={recentResults}
|
||||
onLoadExam={onLoadExam}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'shared' && <SharedWithMe onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
|
||||
{activeTab === 'storage' && <StorageManager />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user