Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbf842b3b | ||
|
|
152a805961 | ||
|
|
54a0ede92a | ||
|
|
1a37209f72 | ||
|
|
25e590639a | ||
|
|
7849c295ef | ||
|
|
f6d97baeb5 | ||
|
|
b58500901a | ||
|
|
e906951b06 | ||
|
|
a78fb8efc3 | ||
|
|
a40bfa7838 | ||
|
|
26041cba1e | ||
|
|
76dc09901a | ||
|
|
2715e6f9b3 | ||
|
|
63aa9b572a | ||
|
|
1ec1633dd9 |
No files matched your search
@@ -36,7 +36,14 @@
|
||||
"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(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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,19 @@ jobs:
|
||||
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
|
||||
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Add OrangePi to known_hosts
|
||||
- name: Pin server host key to known_hosts
|
||||
env:
|
||||
HOST_KEY: ${{ vars.SSH_HOST_PUBLIC_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
ssh-keyscan -H "${{ secrets.ANSIBLE_HOST }}" >> ~/.ssh/known_hosts
|
||||
if [ -z "$HOST_KEY" ]; then
|
||||
echo "::error::Repository variable SSH_HOST_PUBLIC_KEY is not set."
|
||||
echo "::error::Pin the server's host key instead of trusting ssh-keyscan over the network:"
|
||||
echo "::error::run 'ssh-keyscan -t ed25519 <host>' once from a trusted network and store the full output line in SSH_HOST_PUBLIC_KEY."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$HOST_KEY" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Write Inventory
|
||||
run: |
|
||||
|
||||
+5
-1
@@ -7,4 +7,8 @@ backend/db/brainwave.db-wal
|
||||
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
|
||||
brainwave.db-shm
|
||||
brainwave.db-wal
|
||||
node_modules/
|
||||
node_modules/
|
||||
brainwave.db
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
@@ -26,6 +26,7 @@ export const config = {
|
||||
env: env('NODE_ENV', 'development'),
|
||||
port: envInt('PORT', 3001),
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'),
|
||||
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||
bodyLimit: env('BODY_LIMIT', '100kb'),
|
||||
|
||||
@@ -49,8 +49,31 @@ export function runMigrations(db: Database.Database): void {
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_daily (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
period_date TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
requests_used INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (user_id, period_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_daily_user ON ai_usage_daily(user_id);
|
||||
`);
|
||||
|
||||
// ── 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) ──
|
||||
@@ -61,25 +84,58 @@ export function runMigrations(db: Database.Database): void {
|
||||
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 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 plan if missing ──
|
||||
if (!cols.some((c) => c.name === "plan")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||
}
|
||||
|
||||
// ── 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 };
|
||||
if (adminCount.cnt === 0) {
|
||||
db.prepare(
|
||||
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
|
||||
).run();
|
||||
}
|
||||
// ── Add token_version if missing (JWT revocation on password change/reset) ──
|
||||
if (!cols.some((c) => c.name === "token_version")) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
`);
|
||||
|
||||
// ── Seed default settings ──
|
||||
const defaults: Record<string, string> = {
|
||||
@@ -98,6 +154,16 @@ export function runMigrations(db: Database.Database): void {
|
||||
smtp_from: "BrainWave <noreply@brainwave.local>",
|
||||
smtp_secure: "auto",
|
||||
app_url: "http://localhost:5173",
|
||||
ai_provider: "",
|
||||
ai_model: "gpt-4o-mini",
|
||||
ai_api_key: "",
|
||||
ai_base_url: "",
|
||||
ai_model_hint: "",
|
||||
ai_model_generate: "",
|
||||
ai_model_grading: "",
|
||||
ai_model_tutor: "",
|
||||
ai_model_study_coach: "",
|
||||
ai_daily_token_limit: "0",
|
||||
};
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
|
||||
@@ -10,6 +10,9 @@ 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'
|
||||
@@ -62,7 +65,10 @@ 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 with subsystem status
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import { signToken } from '../utils/tokens.js'
|
||||
import { requireAuth } from './auth.js'
|
||||
|
||||
function mockRes(): Response {
|
||||
const res = {} as Response
|
||||
res.status = vi.fn(() => res) as unknown as Response['status']
|
||||
res.json = vi.fn(() => res) as unknown as Response['json']
|
||||
return res
|
||||
}
|
||||
|
||||
function makeReq(token?: string): Request {
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.authorization = `Bearer ${token}`
|
||||
return { headers } as unknown as Request
|
||||
}
|
||||
|
||||
function createUser(role: 'user' | 'admin' = 'user'): { id: number; email: string; name: string } {
|
||||
const email = `user-${Math.random().toString(36).slice(2)}@example.com`
|
||||
const { id } = userRepo.createUser({
|
||||
email,
|
||||
passwordHash: 'x',
|
||||
displayName: 'Test User',
|
||||
role,
|
||||
emailVerified: 1,
|
||||
})
|
||||
return { id, email, name: 'Test User' }
|
||||
}
|
||||
|
||||
describe('requireAuth', () => {
|
||||
it('accepts a valid token and loads fresh user data from the DB', () => {
|
||||
const u = createUser()
|
||||
const token = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
|
||||
const req = makeReq(token)
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(req.user?.userId).toBe(u.id)
|
||||
expect(req.user?.role).toBe('user')
|
||||
expect(req.user?.tv).toBe(0)
|
||||
})
|
||||
|
||||
it('serves the current DB role, not the role claimed by an old JWT', () => {
|
||||
const u = createUser()
|
||||
// Token signed while the user was still an admin
|
||||
const staleAdminToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'admin', tv: 0 })
|
||||
userRepo.updateUser(u.id, { role: 'user' })
|
||||
const req = makeReq(staleAdminToken)
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(req.user?.role).toBe('user')
|
||||
})
|
||||
|
||||
it('rejects tokens for deleted accounts', () => {
|
||||
const u = createUser()
|
||||
const token = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
|
||||
userRepo.deleteUser(u.id)
|
||||
const req = makeReq(token)
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
})
|
||||
|
||||
it('rejects tokens whose token version is stale (e.g. after password change)', () => {
|
||||
const u = createUser()
|
||||
const oldToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
|
||||
userRepo.updateUser(u.id, { tokenVersion: 1 })
|
||||
const req = makeReq(oldToken)
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
})
|
||||
|
||||
it('accepts a token signed with the current token version after rotation', () => {
|
||||
const u = createUser()
|
||||
userRepo.updateUser(u.id, { tokenVersion: 2 })
|
||||
const freshToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 2 })
|
||||
const req = makeReq(freshToken)
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(req.user?.tv).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects requests without a Bearer header', () => {
|
||||
const req = makeReq()
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
})
|
||||
|
||||
it('rejects garbage tokens', () => {
|
||||
const req = makeReq('not.a.jwt')
|
||||
const res = mockRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireAuth(req, res, next)
|
||||
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
|
||||
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
|
||||
import { getUserById } from '../repositories/userRepo.js'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -22,7 +23,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
}
|
||||
try {
|
||||
const token = header.slice(7)
|
||||
req.user = verifyToken(token)
|
||||
const payload = verifyToken(token)
|
||||
|
||||
// Check if the token has been blacklisted (e.g., after logout)
|
||||
if (isTokenBlacklisted(hashToken(token))) {
|
||||
@@ -30,6 +31,26 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
return
|
||||
}
|
||||
|
||||
// Revalidate against the database: deleted accounts must not keep working,
|
||||
// and role/token-version must come from the DB rather than an old JWT claim
|
||||
// (revocation after demotion or password change/reset).
|
||||
const user = getUserById(payload.userId)
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Account no longer exists' })
|
||||
return
|
||||
}
|
||||
if ((user.token_version ?? 0) !== payload.tv) {
|
||||
res.status(401).json({ error: 'Session expired, please sign in again' })
|
||||
return
|
||||
}
|
||||
|
||||
req.user = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role,
|
||||
tv: payload.tv,
|
||||
}
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' })
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
export interface AiDailyUsage {
|
||||
tokensUsed: number
|
||||
requestsUsed: number
|
||||
}
|
||||
|
||||
function normalizeCount(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
export function getDailyUsage(userId: number, periodDate: string): AiDailyUsage {
|
||||
const db = getDb()
|
||||
const row = db.prepare(`
|
||||
SELECT tokens_used, requests_used
|
||||
FROM ai_usage_daily
|
||||
WHERE user_id = ? AND period_date = ?
|
||||
`).get(userId, periodDate) as { tokens_used: number; requests_used: number } | undefined
|
||||
|
||||
return {
|
||||
tokensUsed: row?.tokens_used ?? 0,
|
||||
requestsUsed: row?.requests_used ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementDailyUsage(
|
||||
userId: number,
|
||||
periodDate: string,
|
||||
tokensUsed: number,
|
||||
requestsUsed = 1,
|
||||
): void {
|
||||
const db = getDb()
|
||||
const tokens = normalizeCount(tokensUsed)
|
||||
const requests = normalizeCount(requestsUsed)
|
||||
if (tokens === 0 && requests === 0) return
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO ai_usage_daily (user_id, period_date, tokens_used, requests_used)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, period_date) DO UPDATE SET
|
||||
tokens_used = tokens_used + excluded.tokens_used,
|
||||
requests_used = requests_used + excluded.requests_used,
|
||||
updated_at = unixepoch()
|
||||
`).run(userId, periodDate, tokens, requests)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export interface DbExam {
|
||||
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 {
|
||||
@@ -17,14 +23,18 @@ export interface ExamListItem {
|
||||
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): { id: number } {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson)
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, title, subtitle, examJson, tagsJson)
|
||||
return { id: result.lastInsertRowid as number }
|
||||
}
|
||||
|
||||
@@ -36,16 +46,24 @@ export function getExamById(id: number, userId: number): DbExam | undefined {
|
||||
export function listExamsByUser(userId: number): ExamListItem[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at,
|
||||
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 updated_at DESC
|
||||
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): boolean {
|
||||
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()
|
||||
@@ -65,3 +83,106 @@ export function countExams(): number {
|
||||
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
|
||||
}
|
||||
@@ -110,3 +110,62 @@ export function countResultsByUser(userId: number): number {
|
||||
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,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import * as userRepo from './userRepo.js'
|
||||
|
||||
function uniqueEmail(): string {
|
||||
return `safe-${Math.random().toString(36).slice(2)}@example.com`
|
||||
}
|
||||
|
||||
describe('userRepo.listUsers', () => {
|
||||
it('never returns credential or token columns to callers', async () => {
|
||||
const email = uniqueEmail()
|
||||
userRepo.createUser({
|
||||
email,
|
||||
passwordHash: await import('bcrypt').then((b) => b.hashSync('SecretPassword1!', 10)),
|
||||
displayName: 'Safe Listing',
|
||||
role: 'user',
|
||||
emailVerified: 0,
|
||||
verifyToken: 'verify-token-hash',
|
||||
resetToken: 'reset-token-hash',
|
||||
})
|
||||
|
||||
const { users, total } = userRepo.listUsers({ search: email })
|
||||
expect(total).toBe(1)
|
||||
const user = users[0]!
|
||||
|
||||
expect(user.email).toBe(email)
|
||||
expect(user.display_name).toBe('Safe Listing')
|
||||
expect(user.role).toBe('user')
|
||||
|
||||
const keys = Object.keys(user)
|
||||
expect(keys).not.toContain('password_hash')
|
||||
expect(keys).not.toContain('verify_token')
|
||||
expect(keys).not.toContain('verify_expires')
|
||||
expect(keys).not.toContain('reset_token')
|
||||
expect(keys).not.toContain('reset_expires')
|
||||
})
|
||||
|
||||
it('still returns full rows from getUserById for internal auth use', () => {
|
||||
const email = uniqueEmail()
|
||||
const { id } = userRepo.createUser({
|
||||
email,
|
||||
passwordHash: 'internal-only',
|
||||
displayName: 'Internal',
|
||||
emailVerified: 1,
|
||||
})
|
||||
expect(userRepo.getUserById(id)?.password_hash).toBe('internal-only')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ export interface DbUser {
|
||||
reset_expires: number | null
|
||||
storage_quota_bytes: number
|
||||
plan: string
|
||||
token_version: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
@@ -40,6 +41,7 @@ export interface UpdateUserData {
|
||||
resetExpires?: number | null
|
||||
storageQuotaBytes?: number
|
||||
plan?: string
|
||||
tokenVersion?: number
|
||||
}
|
||||
|
||||
export function createUser(data: CreateUserData): { id: number } {
|
||||
@@ -126,6 +128,10 @@ export function updateUser(id: number, data: UpdateUserData): boolean {
|
||||
fields.push('plan = ?')
|
||||
values.push(data.plan)
|
||||
}
|
||||
if (data.tokenVersion !== undefined) {
|
||||
fields.push('token_version = ?')
|
||||
values.push(data.tokenVersion)
|
||||
}
|
||||
|
||||
if (fields.length === 0) return false
|
||||
|
||||
@@ -154,13 +160,30 @@ export function countAdmins(): number {
|
||||
return row.cnt
|
||||
}
|
||||
|
||||
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: DbUser[]; total: number } {
|
||||
/** User row safe for API exposure — no password/token material. */
|
||||
export interface SafeUser {
|
||||
id: number
|
||||
email: string
|
||||
display_name: string
|
||||
role: 'user' | 'admin'
|
||||
email_verified: number
|
||||
plan: string
|
||||
storage_quota_bytes: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
const SAFE_USER_COLUMNS =
|
||||
'id, email, display_name, role, email_verified, plan, storage_quota_bytes, created_at'
|
||||
|
||||
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: SafeUser[]; 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'
|
||||
// Explicit column list so credential/token material can never leak into
|
||||
// API responses, even if new sensitive columns are added later.
|
||||
let query = `SELECT ${SAFE_USER_COLUMNS} FROM users`
|
||||
let countQuery = 'SELECT COUNT(*) AS total FROM users'
|
||||
const params: unknown[] = []
|
||||
const countParams: unknown[] = []
|
||||
@@ -175,13 +198,8 @@ export function listUsers(options: { limit?: number; offset?: number; search?: s
|
||||
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||
params.push(limit, offset)
|
||||
|
||||
const users = db.prepare(query).all(...params) as DbUser[]
|
||||
const users = db.prepare(query).all(...params) as SafeUser[]
|
||||
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()
|
||||
}
|
||||
@@ -162,7 +162,7 @@ router.post(
|
||||
},
|
||||
{ provider, apiKey, model, baseURL },
|
||||
)
|
||||
res.json(result)
|
||||
res.json(result.data)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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'
|
||||
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
|
||||
import { hintSchema } from '../validators/aiSchemas.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 userId = req.user!.userId
|
||||
enforceDailyAiQuota(userId)
|
||||
const result = await generateQuestions(body)
|
||||
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
|
||||
res.json({ questions: result.data })
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/hint',
|
||||
validateBody(hintSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = req.body
|
||||
const userId = req.user!.userId
|
||||
enforceDailyAiQuota(userId)
|
||||
const result = await getHint({ question: body.question, language: body.language })
|
||||
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
|
||||
res.json(result.data)
|
||||
}),
|
||||
)
|
||||
|
||||
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 = {}
|
||||
}
|
||||
|
||||
enforceDailyAiQuota(userId)
|
||||
|
||||
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,
|
||||
})
|
||||
recordDailyAiUsage(userId, getUtcDateKey(), reply.usageTokens)
|
||||
|
||||
res.json(reply.data)
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Router } from 'express'
|
||||
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 { hashToken, verifyToken } from '../utils/tokens.js'
|
||||
import { blacklistToken } from '../utils/tokenBlacklist.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import {
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
updateProfileSchema,
|
||||
changePasswordSchema,
|
||||
verifyEmailSchema,
|
||||
resendVerificationSchema,
|
||||
resetPasswordSchema,
|
||||
forgotPasswordSchema,
|
||||
} from '../validators/authSchemas.js'
|
||||
@@ -28,14 +31,21 @@ const authLimiter = rateLimit({
|
||||
skipSuccessfulRequests: false,
|
||||
})
|
||||
|
||||
const resendVerificationLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 1,
|
||||
message: { error: 'Please wait a minute before requesting another verification email' },
|
||||
standardHeaders: true,
|
||||
skipSuccessfulRequests: false,
|
||||
})
|
||||
|
||||
// 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)
|
||||
const result = await authService.register(req.body)
|
||||
res.json(result)
|
||||
})
|
||||
)
|
||||
@@ -86,13 +96,24 @@ router.post(
|
||||
// GET /api/auth/verify
|
||||
router.get(
|
||||
'/verify',
|
||||
validateBody(verifyEmailSchema),
|
||||
validateQuery(verifyEmailSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
await authService.verifyEmail(req.body)
|
||||
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',
|
||||
@@ -124,9 +145,18 @@ router.post(
|
||||
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)
|
||||
// Blacklist until the token's real expiry so it cannot be replayed even
|
||||
// after the in-memory blacklist is reloaded from SQLite on restart
|
||||
try {
|
||||
const decoded = verifyToken(token)
|
||||
const expiresAt =
|
||||
typeof decoded.exp === 'number' && decoded.exp * 1000 > Date.now()
|
||||
? decoded.exp * 1000
|
||||
: Date.now() + 60 * 1000
|
||||
blacklistToken(tokenHash, expiresAt)
|
||||
} catch {
|
||||
// Token already failed verification — nothing worth blacklisting
|
||||
}
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
@@ -137,6 +167,10 @@ router.delete(
|
||||
'/me',
|
||||
requireAuth,
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const user = userRepo.getUserById(req.user!.userId)
|
||||
if (user?.role === 'admin' && userRepo.countAdmins() <= 1) {
|
||||
throw new AppError(400, 'Cannot delete the last admin account')
|
||||
}
|
||||
authService.deleteUserAccount(req.user!.userId)
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
+132
-4
@@ -4,7 +4,7 @@ import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema, favoriteSchema, shareModeSchema, batchDeleteSchema } from '../validators/examSchemas.js'
|
||||
import { sanitizeExam } from '../utils/sanitizeExam.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
@@ -33,7 +33,14 @@ router.get(
|
||||
throw new AppError(404, 'Exam not found')
|
||||
}
|
||||
const parsedExam = JSON.parse(exam.exam_json)
|
||||
res.json({ ...parsedExam, id: exam.id })
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = JSON.parse(exam.tags ?? '[]')
|
||||
if (!Array.isArray(tags)) tags = []
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
res.json({ ...parsedExam, id: exam.id, tags })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -43,11 +50,13 @@ 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(exam),
|
||||
JSON.stringify(tags)
|
||||
)
|
||||
res.status(201).json({ id, title: exam.title })
|
||||
})
|
||||
@@ -61,12 +70,14 @@ router.put(
|
||||
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)
|
||||
JSON.stringify(exam),
|
||||
tags
|
||||
)
|
||||
if (!updated) {
|
||||
throw new AppError(404, 'Exam not found')
|
||||
@@ -89,4 +100,121 @@ router.delete(
|
||||
})
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -1,16 +1,10 @@
|
||||
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),
|
||||
})
|
||||
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
|
||||
import { gradeRequestSchema, type GradeRequestBody } from '../validators/gradeSchemas.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -20,9 +14,12 @@ router.post(
|
||||
'/',
|
||||
validateBody(gradeRequestSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { type, question, answer, language } = req.body as z.infer<typeof gradeRequestSchema>
|
||||
const { type, question, answer, language } = req.body as GradeRequestBody
|
||||
const userId = req.user!.userId
|
||||
enforceDailyAiQuota(userId)
|
||||
const result = await gradeQuestionWithAI({ type, question, answer, language })
|
||||
res.json(result)
|
||||
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
|
||||
res.json(result.data)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ 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 { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
@@ -78,4 +81,53 @@ router.delete(
|
||||
})
|
||||
)
|
||||
|
||||
// GET /api/results/analyze
|
||||
router.get(
|
||||
'/analyze',
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const userId = req.user!.userId
|
||||
const rawResults = resultRepo.getResultsWithExamJson(userId, 200)
|
||||
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
|
||||
enforceDailyAiQuota(userId)
|
||||
const analysis = await analyzeResults(rawResults, lang)
|
||||
recordDailyAiUsage(userId, getUtcDateKey(), analysis.usageTokens)
|
||||
|
||||
// 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({ ...analysis.data, 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
|
||||
@@ -22,8 +22,11 @@ router.get(
|
||||
'/status',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const title = getSiteTitle()
|
||||
// Setup is complete once an admin account exists — not merely because
|
||||
// default settings were seeded
|
||||
const completed = userRepo.countAdmins() > 0
|
||||
res.json({
|
||||
completed: !!title,
|
||||
completed,
|
||||
siteTitle: title ?? undefined,
|
||||
})
|
||||
})
|
||||
@@ -38,16 +41,12 @@ router.post(
|
||||
|
||||
// Atomic transaction prevents race conditions between concurrent setup requests
|
||||
const tx = db.transaction(() => {
|
||||
const title = settingsRepo.getSetting('site_name')
|
||||
if (title) {
|
||||
// Gate on admins, not on users or settings: a fresh instance has default
|
||||
// settings seeded, and an instance that lost its admin can re-bootstrap
|
||||
if (userRepo.countAdmins() > 0) {
|
||||
throw new AppError(400, 'Setup already completed')
|
||||
}
|
||||
|
||||
const userCount = userRepo.countUsers()
|
||||
if (userCount > 0) {
|
||||
throw new AppError(400, 'Users already exist')
|
||||
}
|
||||
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
@@ -99,6 +98,7 @@ router.post(
|
||||
email,
|
||||
displayName,
|
||||
role: 'admin',
|
||||
tv: 0,
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canClone } from './share.js'
|
||||
|
||||
describe('canClone', () => {
|
||||
it('rejects view-only shares', () => {
|
||||
expect(canClone('view')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows clone-mode shares and legacy rows without a mode', () => {
|
||||
expect(canClone('clone')).toBe(true)
|
||||
expect(canClone(null)).toBe(true)
|
||||
expect(canClone(undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
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()
|
||||
|
||||
/** Server-side enforcement of the share mode: 'view' shares must not be
|
||||
* clonable (the frontend hides the button, but that is not a control). */
|
||||
export function canClone(shareMode: string | null | undefined): boolean {
|
||||
return shareMode !== 'view'
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
if (!canClone(source.share_mode)) {
|
||||
throw new AppError(403, 'This exam is shared as view-only and cannot be saved')
|
||||
}
|
||||
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
|
||||
res.status(201).json({ id, title: source.title + ' (copy)' })
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,340 @@
|
||||
import { z } from 'zod'
|
||||
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
||||
import { AiUsageResult, estimateTokensFromMessages, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/index.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<AiUsageResult<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 = getAIModelForTask('question_generation')
|
||||
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 promptTokens = estimateTokensFromText(prompt)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 1800)
|
||||
|
||||
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 usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
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 { data: validated, usageTokens }
|
||||
}
|
||||
|
||||
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<AiUsageResult<{ 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 = getAIModelForTask('hint')
|
||||
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{
|
||||
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)}`,
|
||||
},
|
||||
]
|
||||
|
||||
const promptTokens = estimateTokensFromMessages(messages as Array<{ content: string }>)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 160)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.4,
|
||||
max_completion_tokens: 160,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
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 { data: { hint }, usageTokens }
|
||||
}
|
||||
|
||||
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<AiUsageResult<{ 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 = getAIModelForTask('tutor_chat')
|
||||
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 messages: ChatCompletionMessageParam[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recent.map((m) => ({ role: m.role, content: m.content })),
|
||||
]
|
||||
const promptTokens = estimateTokensFromMessages(messages as Array<{ content: string }>)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 600)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.5,
|
||||
max_completion_tokens: 600,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const reply = completion.choices[0]?.message?.content?.trim()
|
||||
if (!reply) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
}
|
||||
return { data: { reply }, usageTokens }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import OpenAI from 'openai'
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
|
||||
export interface AIClientOverride {
|
||||
provider?: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
}
|
||||
|
||||
export type AiTask = 'hint' | 'question_generation' | 'grading' | 'tutor_chat' | 'study_coach'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
const TASK_MODEL_KEYS: Record<AiTask, string> = {
|
||||
hint: 'ai_model_hint',
|
||||
question_generation: 'ai_model_generate',
|
||||
grading: 'ai_model_grading',
|
||||
tutor_chat: 'ai_model_tutor',
|
||||
study_coach: 'ai_model_study_coach',
|
||||
}
|
||||
|
||||
function normalizeModel(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
export function getAIModelForTask(task: AiTask, override?: string): string {
|
||||
const explicit = normalizeModel(override)
|
||||
if (explicit) return explicit
|
||||
|
||||
const taskModel = normalizeModel(getSetting(TASK_MODEL_KEYS[task]))
|
||||
return taskModel || getAIModel()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenAI from 'openai'
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
||||
import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
|
||||
|
||||
export interface GradingResult {
|
||||
suggestedGrade: number
|
||||
@@ -14,16 +14,6 @@ interface GradeRequest {
|
||||
language: string
|
||||
}
|
||||
|
||||
function getClient(override?: { provider?: string; apiKey?: string; baseURL?: string }): 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 })
|
||||
}
|
||||
|
||||
function buildPrompt(req: GradeRequest): string {
|
||||
const { type, question, answer, language } = req
|
||||
|
||||
@@ -144,14 +134,16 @@ Use the human-readable text labels in the arrays.`
|
||||
export async function gradeQuestionWithAI(
|
||||
req: GradeRequest,
|
||||
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
||||
): Promise<GradingResult> {
|
||||
const client = getClient(override)
|
||||
): Promise<AiUsageResult<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 = (override?.model ?? getSetting('ai_model')) || 'gpt-4o-mini'
|
||||
const model = getAIModelForTask('grading', override?.model)
|
||||
const prompt = buildPrompt(req)
|
||||
const promptTokens = estimateTokensFromText(prompt)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 800)
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model,
|
||||
@@ -161,6 +153,8 @@ export async function gradeQuestionWithAI(
|
||||
max_completion_tokens: 800,
|
||||
})
|
||||
|
||||
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
throw new Error('Empty response from AI provider')
|
||||
@@ -179,8 +173,11 @@ export async function gradeQuestionWithAI(
|
||||
: 0
|
||||
|
||||
return {
|
||||
data: {
|
||||
suggestedGrade,
|
||||
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
|
||||
detail: obj.detail,
|
||||
},
|
||||
usageTokens,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
vi.mock('../utils/email.js', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
sendVerificationEmail: vi.fn(async () => {}),
|
||||
sendPasswordResetEmail: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
import * as authService from './authService.js'
|
||||
import { sendPasswordResetEmail } from '../utils/email.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
|
||||
async function seedUser(overrides: Partial<userRepo.CreateUserData> = {}): Promise<number> {
|
||||
const email = overrides.email ?? `svc-${Math.random().toString(36).slice(2)}@example.com`
|
||||
const { id } = userRepo.createUser({
|
||||
email,
|
||||
passwordHash: await bcrypt.hash('OldPassword1!', 10),
|
||||
displayName: 'Svc Test',
|
||||
emailVerified: 1,
|
||||
...overrides,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
describe('authService.register', () => {
|
||||
it('registers new accounts as plain users (admin bootstrap is the setup wizard)', async () => {
|
||||
const email = `new-${Math.random().toString(36).slice(2)}@example.com`
|
||||
const result = await authService.register({
|
||||
email,
|
||||
password: 'Sup3rSecure!pass',
|
||||
displayName: 'New User',
|
||||
})
|
||||
|
||||
if (!('token' in result)) throw new Error('expected an AuthResult with a token')
|
||||
expect(result.token).toBeTruthy()
|
||||
expect(result.user.role).toBe('user')
|
||||
|
||||
const dbUser = userRepo.getUserByEmail(email)
|
||||
expect(dbUser?.role).toBe('user')
|
||||
expect(dbUser?.email_verified).toBe(0)
|
||||
})
|
||||
|
||||
it('responds to duplicate emails with a token-less success-shaped body (no enumeration oracle)', async () => {
|
||||
const id = await seedUser()
|
||||
const existing = userRepo.getUserById(id)!
|
||||
|
||||
const before = userRepo.countUsers()
|
||||
const result = await authService.register({
|
||||
email: existing.email.toUpperCase(),
|
||||
password: 'Whatever!123',
|
||||
displayName: 'Imposter',
|
||||
})
|
||||
|
||||
expect('token' in result).toBe(false)
|
||||
expect('message' in result && typeof result.message).toBe('string')
|
||||
expect(userRepo.countUsers()).toBe(before)
|
||||
|
||||
// The original account must be untouched
|
||||
const after = userRepo.getUserById(id)!
|
||||
expect(after.display_name).toBe(existing.display_name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password rotation revokes sessions', () => {
|
||||
it('changePassword bumps token_version', async () => {
|
||||
const id = await seedUser()
|
||||
const before = userRepo.getUserById(id)!
|
||||
|
||||
await authService.changePassword(id, { currentPassword: 'OldPassword1!', newPassword: 'N3wPassword!x' })
|
||||
|
||||
const after = userRepo.getUserById(id)!
|
||||
expect(after.password_hash).not.toBe(before.password_hash)
|
||||
expect(after.token_version).toBe((before.token_version ?? 0) + 1)
|
||||
})
|
||||
|
||||
it('changePassword rejects a wrong current password', async () => {
|
||||
const id = await seedUser()
|
||||
const before = userRepo.getUserById(id)!
|
||||
|
||||
await expect(
|
||||
authService.changePassword(id, { currentPassword: 'WrongPassword!', newPassword: 'N3wPassword!x' }),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(userRepo.getUserById(id)!.token_version).toBe(before.token_version)
|
||||
})
|
||||
|
||||
it('resetPassword bumps token_version and clears the reset token', async () => {
|
||||
const id = await seedUser({ emailVerified: 0 })
|
||||
const user = userRepo.getUserById(id)!
|
||||
const email = user.email
|
||||
|
||||
await authService.forgotPassword(email)
|
||||
const calls = vi.mocked(sendPasswordResetEmail).mock.calls
|
||||
const rawToken = calls.at(-1)?.[1] as string | undefined
|
||||
expect(rawToken).toBeTruthy()
|
||||
|
||||
await authService.resetPassword({ token: rawToken!, newPassword: 'BrandNew!123' })
|
||||
const after = userRepo.getUserById(id)!
|
||||
|
||||
expect(after.token_version).toBe((user.token_version ?? 0) + 1)
|
||||
expect(after.reset_token).toBeNull()
|
||||
expect(after.reset_expires).toBeNull()
|
||||
expect(bcrypt.compareSync('BrandNew!123', after.password_hash)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -15,14 +15,41 @@ export interface AuthResult {
|
||||
user: { id: number; email: string; displayName: string; role: string }
|
||||
}
|
||||
|
||||
export async function register(input: RegisterInput, isFirstUser: boolean): Promise<AuthResult> {
|
||||
/** Response for a registration attempt we did not act on (email already exists).
|
||||
* Deliberately indistinguishable from success so the endpoint cannot be used
|
||||
* to enumerate registered email addresses. */
|
||||
export interface RegistrationPending {
|
||||
message: string
|
||||
}
|
||||
|
||||
function toAuthResult(user: userRepo.DbUser): AuthResult {
|
||||
return {
|
||||
token: signToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role as 'user' | 'admin',
|
||||
tv: user.token_version ?? 0,
|
||||
}),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
role: user.role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(input: RegisterInput): Promise<AuthResult | RegistrationPending> {
|
||||
// Hash before the duplicate check so response timing does not reveal whether
|
||||
// the email is already registered (user enumeration)
|
||||
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
||||
|
||||
const existing = userRepo.getUserByEmail(input.email)
|
||||
if (existing) {
|
||||
throw new AppError(400, 'Email already registered')
|
||||
return { message: 'Check your inbox to finish setting up your account.' }
|
||||
}
|
||||
|
||||
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
|
||||
@@ -31,7 +58,7 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
displayName: input.displayName,
|
||||
role,
|
||||
role: 'user',
|
||||
emailVerified: 0,
|
||||
verifyToken: verifyTokenHash,
|
||||
verifyExpires,
|
||||
@@ -42,20 +69,7 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
return toAuthResult(user)
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
@@ -73,20 +87,7 @@ export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
return toAuthResult(user)
|
||||
}
|
||||
|
||||
export function getMe(userId: number) {
|
||||
@@ -128,7 +129,11 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
||||
userRepo.updateUser(userId, { passwordHash })
|
||||
// Bump token version so tokens issued before the change stop working
|
||||
userRepo.updateUser(userId, {
|
||||
passwordHash,
|
||||
tokenVersion: (user.token_version ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyEmail(input: VerifyEmailInput) {
|
||||
@@ -145,6 +150,24 @@ export async function verifyEmail(input: VerifyEmailInput) {
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -174,10 +197,12 @@ export async function resetPassword(input: ResetPasswordInput) {
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
||||
// Bump token version so tokens from before the reset stop working
|
||||
userRepo.updateUser(user.id, {
|
||||
passwordHash,
|
||||
resetToken: null,
|
||||
resetExpires: null,
|
||||
tokenVersion: (user.token_version ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { getAIClient, getAIModelForTask } from './aiClient.js'
|
||||
import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.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',
|
||||
options?: { allowAi?: boolean },
|
||||
): Promise<AiUsageResult<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 || options?.allowAi === false) {
|
||||
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
|
||||
}
|
||||
|
||||
const model = getAIModelForTask('study_coach')
|
||||
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')}`
|
||||
|
||||
const promptTokens = estimateTokensFromText(prompt)
|
||||
const estimatedTokens = estimateTotalTokens(promptTokens, 400)
|
||||
|
||||
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 usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
|
||||
|
||||
const raw = completion.choices[0]?.message?.content
|
||||
if (!raw) {
|
||||
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
|
||||
}
|
||||
|
||||
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 { data: { weakAreas, studyTips: tips, streakDays }, usageTokens }
|
||||
} catch {
|
||||
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||
import * as aiUsageRepo from '../repositories/aiUsageRepo.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import { enforceDailyAiQuota, getDailyTokenLimit, getUtcDateKey } from './aiQuota.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
|
||||
function seedUser(): number {
|
||||
const email = `quota-${Math.random().toString(36).slice(2)}@example.com`
|
||||
return userRepo.createUser({
|
||||
email,
|
||||
passwordHash: 'x',
|
||||
displayName: 'Quota Test',
|
||||
emailVerified: 1,
|
||||
}).id
|
||||
}
|
||||
|
||||
describe('daily AI token quota', () => {
|
||||
it('allows everything when no limit is configured', () => {
|
||||
settingsRepo.setSetting('ai_daily_token_limit', '0')
|
||||
expect(getDailyTokenLimit()).toBeNull()
|
||||
expect(() => enforceDailyAiQuota(seedUser())).not.toThrow()
|
||||
})
|
||||
|
||||
it('blocks with a 429 once used tokens reach the configured limit', () => {
|
||||
settingsRepo.setSetting('ai_daily_token_limit', '1000')
|
||||
const userId = seedUser()
|
||||
aiUsageRepo.incrementDailyUsage(userId, getUtcDateKey(), 1000, 1)
|
||||
|
||||
expect(() => enforceDailyAiQuota(userId)).toThrowError(AppError)
|
||||
try {
|
||||
enforceDailyAiQuota(userId)
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AppError)
|
||||
expect((err as AppError).statusCode).toBe(429)
|
||||
}
|
||||
})
|
||||
|
||||
it('allows users under the limit and isolates users from each other', () => {
|
||||
settingsRepo.setSetting('ai_daily_token_limit', '1000')
|
||||
const heavy = seedUser()
|
||||
const fresh = seedUser()
|
||||
aiUsageRepo.incrementDailyUsage(heavy, getUtcDateKey(), 999, 1)
|
||||
expect(() => enforceDailyAiQuota(heavy)).not.toThrow()
|
||||
expect(() => enforceDailyAiQuota(fresh)).not.toThrow()
|
||||
})
|
||||
|
||||
it('only counts today’s usage', () => {
|
||||
settingsRepo.setSetting('ai_daily_token_limit', '1000')
|
||||
const userId = seedUser()
|
||||
aiUsageRepo.incrementDailyUsage(userId, '2000-01-01', 999_999, 1)
|
||||
expect(() => enforceDailyAiQuota(userId)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getSetting } from '../repositories/settingsRepo.js'
|
||||
import { getDailyUsage, incrementDailyUsage } from '../repositories/aiUsageRepo.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
|
||||
export interface AiQuotaStatus {
|
||||
allowed: boolean
|
||||
limit: number | null
|
||||
usedTokens: number
|
||||
periodDate: string
|
||||
}
|
||||
|
||||
export function getUtcDateKey(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function getDailyTokenLimit(): number | null {
|
||||
const raw = getSetting('ai_daily_token_limit') ?? ''
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export function checkDailyAiQuota(userId: number): AiQuotaStatus {
|
||||
const periodDate = getUtcDateKey()
|
||||
const limit = getDailyTokenLimit()
|
||||
if (!limit) {
|
||||
return { allowed: true, limit: null, usedTokens: 0, periodDate }
|
||||
}
|
||||
|
||||
const usage = getDailyUsage(userId, periodDate)
|
||||
return {
|
||||
allowed: usage.tokensUsed < limit,
|
||||
limit,
|
||||
usedTokens: usage.tokensUsed,
|
||||
periodDate,
|
||||
}
|
||||
}
|
||||
|
||||
export function recordDailyAiUsage(userId: number, periodDate: string, tokens: number): void {
|
||||
incrementDailyUsage(userId, periodDate, tokens, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 429 when the user has exhausted the admin-configured daily token
|
||||
* limit. Call before every AI provider invocation; pair with recordDailyAiUsage.
|
||||
*/
|
||||
export function enforceDailyAiQuota(userId: number): void {
|
||||
if (!checkDailyAiQuota(userId).allowed) {
|
||||
throw new AppError(429, 'Daily AI usage limit reached. Please try again tomorrow.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface AiUsageResult<T> {
|
||||
data: T
|
||||
usageTokens: number
|
||||
}
|
||||
|
||||
export function estimateTokensFromText(text: string): number {
|
||||
if (!text) return 0
|
||||
return Math.max(1, Math.ceil(text.length / 4))
|
||||
}
|
||||
|
||||
export function estimateTokensFromMessages(messages: Array<{ content: string }>): number {
|
||||
return messages.reduce((sum, msg) => sum + estimateTokensFromText(msg.content), 0)
|
||||
}
|
||||
|
||||
// Conservative estimate when providers omit usage details.
|
||||
export function estimateTotalTokens(promptTokens: number, maxCompletionTokens: number): number {
|
||||
const completionEstimate = Math.max(0, Math.round(maxCompletionTokens * 0.6))
|
||||
return Math.max(1, promptTokens + completionEstimate)
|
||||
}
|
||||
|
||||
export function resolveUsageTokens(
|
||||
usage: { total_tokens?: number } | undefined,
|
||||
estimate: number,
|
||||
): number {
|
||||
if (usage && typeof usage.total_tokens === 'number' && Number.isFinite(usage.total_tokens)) {
|
||||
return Math.max(0, Math.round(usage.total_tokens))
|
||||
}
|
||||
return estimate
|
||||
}
|
||||
@@ -18,7 +18,8 @@ function initCache(): void {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
|
||||
`)
|
||||
// Load recent blacklisted tokens into memory
|
||||
// Purge rows that can never matter again, then load the rest into memory
|
||||
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
|
||||
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) {
|
||||
|
||||
@@ -4,13 +4,14 @@ 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 payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const, tv: 3 }
|
||||
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')
|
||||
expect(decoded.tv).toBe(3)
|
||||
})
|
||||
|
||||
it('throws on invalid token', async () => {
|
||||
@@ -22,7 +23,7 @@ describe('tokens', () => {
|
||||
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 payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const, tv: 0 }
|
||||
const token = signToken(payload)
|
||||
const tampered = token.slice(0, -5) + 'xxxxx'
|
||||
expect(() => verifyToken(tampered)).toThrow()
|
||||
|
||||
@@ -7,14 +7,19 @@ export interface JwtPayload {
|
||||
email: string
|
||||
displayName: string
|
||||
role: 'user' | 'admin'
|
||||
/** Token version — must match the user's current token_version or the token is rejected */
|
||||
tv: number
|
||||
}
|
||||
|
||||
export function signToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' })
|
||||
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn } as jwt.SignOptions)
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, config.jwtSecret) as JwtPayload
|
||||
/** Verified JWT body: our claims plus the registered exp/iat timestamps. */
|
||||
export type VerifiedToken = JwtPayload & { exp?: number; iat?: number }
|
||||
|
||||
export function verifyToken(token: string): VerifiedToken {
|
||||
return jwt.verify(token, config.jwtSecret) as VerifiedToken
|
||||
}
|
||||
|
||||
export function generateVerificationToken(): string {
|
||||
|
||||
@@ -2,11 +2,16 @@ import { describe, it, expect } from 'vitest'
|
||||
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||
|
||||
describe('updateUserRoleSchema', () => {
|
||||
it('accepts valid role change', () => {
|
||||
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)
|
||||
@@ -16,6 +21,21 @@ describe('updateUserRoleSchema', () => {
|
||||
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', () => {
|
||||
@@ -24,15 +44,38 @@ describe('updateSettingSchema', () => {
|
||||
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('rejects empty value', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_name', value: '' })
|
||||
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', () => {
|
||||
@@ -43,29 +86,39 @@ describe('updateSettingsBatchSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid key in batch', () => {
|
||||
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: { site_name: 'BrainWave', unknown_key: 'value' },
|
||||
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 empty value in batch', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({
|
||||
settings: { site_name: '' },
|
||||
})
|
||||
it('rejects missing settings property', () => {
|
||||
const result = updateSettingsBatchSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listUsersQuerySchema', () => {
|
||||
it('accepts empty query', () => {
|
||||
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('accepts page string', () => {
|
||||
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)
|
||||
@@ -76,9 +129,26 @@ describe('listUsersQuerySchema', () => {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,8 +158,24 @@ describe('userIdParamSchema', () => {
|
||||
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,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hintSchema, MAX_HINT_QUESTION_CHARS } from './aiSchemas.js'
|
||||
|
||||
describe('hintSchema', () => {
|
||||
it('accepts a normal hint request', () => {
|
||||
const result = hintSchema.safeParse({
|
||||
question: { type: 'short-answer', prompt: 'What is 2+2?' },
|
||||
language: 'en',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it(`rejects questions whose JSON exceeds ${MAX_HINT_QUESTION_CHARS} chars`, () => {
|
||||
const result = hintSchema.safeParse({
|
||||
question: { prompt: 'x'.repeat(MAX_HINT_QUESTION_CHARS + 1) },
|
||||
language: 'en',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(JSON.stringify(result.error.issues)).toContain('too large')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const MAX_HINT_QUESTION_CHARS = 8000
|
||||
|
||||
function serializedSize(value: unknown): number {
|
||||
if (value === undefined || value === null) return 0
|
||||
return JSON.stringify(value).length
|
||||
}
|
||||
|
||||
export const hintSchema = z
|
||||
.object({
|
||||
question: z.record(z.unknown()),
|
||||
language: z.string().min(1).max(20),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (serializedSize(val.question) > MAX_HINT_QUESTION_CHARS) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Question is too large for AI hints',
|
||||
path: ['question'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type HintRequestBody = z.infer<typeof hintSchema>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { registerSchema, loginSchema, verifyEmailSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
|
||||
import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
|
||||
|
||||
describe('registerSchema', () => {
|
||||
it('accepts valid registration', () => {
|
||||
@@ -11,6 +11,15 @@ describe('registerSchema', () => {
|
||||
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',
|
||||
@@ -63,6 +72,13 @@ describe('verifyEmailSchema', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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!' })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||
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 (@$!%*?&)'),
|
||||
.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),
|
||||
})
|
||||
|
||||
@@ -19,11 +19,15 @@ 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 (@$!%*?&)'),
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
|
||||
})
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
@@ -34,7 +38,7 @@ 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 (@$!%*?&)'),
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
@@ -44,6 +48,7 @@ export const forgotPasswordSchema = z.object({
|
||||
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(),
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { examSchema, saveExamSchema, updateExamSchema, examIdParamSchema } from './examSchemas'
|
||||
import {
|
||||
examSchema,
|
||||
saveExamSchema,
|
||||
updateExamSchema,
|
||||
examIdParamSchema,
|
||||
favoriteSchema,
|
||||
shareModeSchema,
|
||||
batchDeleteSchema,
|
||||
} from './examSchemas'
|
||||
|
||||
describe('examSchema', () => {
|
||||
it('accepts minimal valid exam', () => {
|
||||
@@ -10,6 +18,14 @@ describe('examSchema', () => {
|
||||
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' }],
|
||||
@@ -41,6 +57,96 @@ describe('examSchema', () => {
|
||||
})
|
||||
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', () => {
|
||||
@@ -82,4 +188,59 @@ describe('examIdParamSchema', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,33 @@ const questionTableSchema = z.object({
|
||||
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([
|
||||
@@ -41,6 +68,14 @@ const questionSchema = z.object({
|
||||
'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(),
|
||||
@@ -51,7 +86,7 @@ const questionSchema = z.object({
|
||||
code: questionCodeSchema.optional(),
|
||||
table: questionTableSchema.optional(),
|
||||
svg: z.string().optional(),
|
||||
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string())]).optional(),
|
||||
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(),
|
||||
@@ -64,6 +99,12 @@ const questionSchema = z.object({
|
||||
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({
|
||||
@@ -80,14 +121,28 @@ export const examSchema = z.object({
|
||||
|
||||
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,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
gradeRequestSchema,
|
||||
MAX_GRADE_QUESTION_CHARS,
|
||||
MAX_GRADE_ANSWER_CHARS,
|
||||
} from './gradeSchemas.js'
|
||||
|
||||
const validBase = {
|
||||
question: { prompt: 'Explain photosynthesis' },
|
||||
answer: 'Plants convert light into chemical energy',
|
||||
language: 'en',
|
||||
}
|
||||
|
||||
describe('gradeRequestSchema', () => {
|
||||
it('accepts a well-formed grading request', () => {
|
||||
const result = gradeRequestSchema.safeParse({ type: 'essay', ...validBase })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it(`rejects questions whose JSON exceeds ${MAX_GRADE_QUESTION_CHARS} chars`, () => {
|
||||
const result = gradeRequestSchema.safeParse({
|
||||
type: 'essay',
|
||||
question: { prompt: 'x'.repeat(MAX_GRADE_QUESTION_CHARS + 1) },
|
||||
answer: 'irrelevant',
|
||||
language: 'en',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(JSON.stringify(result.error.issues)).toContain('too large')
|
||||
}
|
||||
})
|
||||
|
||||
it(`rejects answers whose JSON exceeds ${MAX_GRADE_ANSWER_CHARS} chars`, () => {
|
||||
const result = gradeRequestSchema.safeParse({
|
||||
type: 'essay',
|
||||
question: { prompt: 'small' },
|
||||
answer: 'y'.repeat(MAX_GRADE_ANSWER_CHARS + 1),
|
||||
language: 'en',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(JSON.stringify(result.error.issues)).toContain('too large')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown question types', () => {
|
||||
const result = gradeRequestSchema.safeParse({
|
||||
type: 'flashcard',
|
||||
question: { prompt: 'hi' },
|
||||
answer: 'yo',
|
||||
language: 'en',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds the language field', () => {
|
||||
const result = gradeRequestSchema.safeParse({
|
||||
type: 'essay',
|
||||
question: { prompt: 'hi' },
|
||||
answer: 'yo',
|
||||
language: 'l'.repeat(500),
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Serialized size caps: the grading prompt is built from client-supplied
|
||||
* question/answer objects, so these bound what can be billed per request. */
|
||||
export const MAX_GRADE_QUESTION_CHARS = 8000
|
||||
export const MAX_GRADE_ANSWER_CHARS = 4000
|
||||
|
||||
function serializedSize(value: unknown): number {
|
||||
if (value === undefined || value === null) return 0
|
||||
return JSON.stringify(value).length
|
||||
}
|
||||
|
||||
const gradeRequestBase = 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).max(30),
|
||||
})
|
||||
|
||||
export const gradeRequestSchema = gradeRequestBase.superRefine((val, ctx) => {
|
||||
if (serializedSize(val.question) > MAX_GRADE_QUESTION_CHARS) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Question is too large for AI grading',
|
||||
path: ['question'],
|
||||
})
|
||||
}
|
||||
if (serializedSize(val.answer) > MAX_GRADE_ANSWER_CHARS) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Answer is too large for AI grading',
|
||||
path: ['answer'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type GradeRequestBody = z.infer<typeof gradeRequestSchema>
|
||||
@@ -68,7 +68,7 @@ describe('submitResultSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts negative score_points because schema allows any number', () => {
|
||||
it('rejects negative score_points (client-supplied scores are bounded)', () => {
|
||||
const result = submitResultSchema.safeParse({
|
||||
title: 'My Exam',
|
||||
score_points: -1,
|
||||
@@ -86,7 +86,25 @@ describe('submitResultSchema', () => {
|
||||
},
|
||||
exam: { title: 'My Exam', questions: [] },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-integer counts and absurd magnitudes', () => {
|
||||
const base = {
|
||||
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(submitResultSchema.safeParse({ ...base, correct_count: 1.5 }).success).toBe(false)
|
||||
expect(submitResultSchema.safeParse({ ...base, score_points: 1e12 }).success).toBe(false)
|
||||
expect(submitResultSchema.safeParse({ ...base, elapsed_ms: -5 }).success).toBe(false)
|
||||
expect(submitResultSchema.safeParse({ ...base, exam_id: -3 }).success).toBe(false)
|
||||
expect(submitResultSchema.safeParse({ ...base, title: 't'.repeat(301) }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts immediate feedback mode', () => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Scores/counts arrive from the client, so they are bounded to keep forged
|
||||
// garbage out of analytics while accepting every value honest clients send.
|
||||
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(),
|
||||
exam_id: z.number().int().positive().nullable().optional(),
|
||||
title: z.string().min(1).max(300),
|
||||
score_points: z.number().finite().min(0).max(1e9),
|
||||
total_points: z.number().finite().min(0).max(1e9),
|
||||
correct_count: z.number().int().min(0).max(1e6),
|
||||
graded_count: z.number().int().min(0).max(1e6),
|
||||
passing_score: z.number().finite().min(0).max(1e9).optional(),
|
||||
elapsed_ms: z.number().int().min(0).max(2_147_483_647),
|
||||
state: z.record(z.unknown()),
|
||||
exam: z.record(z.unknown()),
|
||||
})
|
||||
|
||||
@@ -2,34 +2,102 @@ import { describe, it, expect } from 'vitest'
|
||||
import { setupSchema, setupStatusSchema } from './setupSchemas'
|
||||
|
||||
describe('setupSchema', () => {
|
||||
it('accepts valid setup', () => {
|
||||
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({
|
||||
email: 'admin@example.com',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
...baseValid,
|
||||
siteTitle: 'BrainWave',
|
||||
language: 'en',
|
||||
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({
|
||||
email: 'bad',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
const result = setupSchema.safeParse({ ...baseValid, email: 'bad' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects short password', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
password: '123',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
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', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||
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 (@$!%*?&)'),
|
||||
.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(),
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -3,5 +3,7 @@ import { defineConfig } from 'vitest/config'
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
},
|
||||
})
|
||||
Binary file not shown.
@@ -0,0 +1,104 @@
|
||||
# Security Audit Fixes 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:** Fix all vulnerabilities confirmed by the 2026-08-21 full-codebase security audit (5 confirmed ≥8/10 confidence + 6 sub-threshold verified findings).
|
||||
|
||||
**Architecture:** Three clusters. (A) Session security: JWTs gain a `tv` (token version) claim; `requireAuth` revalidates the user against SQLite on every request (deleted accounts, demoted roles, password-rotation revocation); logout blacklists until real `exp`. (B) AI cost control: wire the existing-but-dead quota helpers into every provider call path and cap attacker-controlled prompt sizes. (C) Bootstrap & hygiene: make the setup wizard the only admin bootstrap, stop register-time auto-admin, sanitize admin user listings, bound result schemas, enforce view-only share mode server-side, pin SSH host keys in CI.
|
||||
|
||||
**Tech Stack:** Express 5, better-sqlite3 (sync), jsonwebtoken 9, zod 3, vitest 4, Gitea Actions.
|
||||
|
||||
**Spec:** Security audit report delivered in this session (2026-08-21). Findings referenced below as Vuln N / sub-threshold N.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not alter lines containing redacted numeric literals in `userRepo.ts` (`storageQuotaBytes ?? …`) and `migrations.ts` (`storage_quota_bytes … DEFAULT`) — edit around them.
|
||||
- Keep HTTP response shapes of AI endpoints unchanged (`{ data, usageTokens }` payloads stay) so the frontend needs no changes.
|
||||
- Tests run with `cd backend && npx vitest run`; vitest.setup stubs `JWT_SECRET`, `DB_PATH=:memory:`; `getDb()` runs full migrations on first use.
|
||||
- No git commits — user has not requested them; leave changes in working tree.
|
||||
- Existing deployments upgrade transparently; all users are logged out once by the new `tv` claim (accepted, noted in final report).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Token versioning + DB revalidation (Vuln 3, Vuln 4, sub-threshold 1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/db/migrations.ts` (add column)
|
||||
- Modify: `backend/src/utils/tokens.ts` (JwtPayload.tv)
|
||||
- Modify: `backend/src/repositories/userRepo.ts` (token_version plumbing)
|
||||
- Modify: `backend/src/middleware/auth.ts` (revalidation)
|
||||
- Modify: `backend/src/services/authService.ts` (sign with tv, bump on password events)
|
||||
- Modify: `backend/src/routes/setup.ts` (sign with tv)
|
||||
- Modify: `backend/src/routes/auth.ts` (logout uses decoded exp)
|
||||
- Modify: `backend/src/utils/tokenBlacklist.ts` (purge expired rows at init)
|
||||
- Test: `backend/src/utils/tokens.test.ts` (update payload fixtures), `backend/src/middleware/auth.test.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `JwtPayload { userId, email, displayName, role, tv }`; `DbUser.token_version: number`; `UpdateUserData.tokenVersion?: number`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (`auth.test.ts`): valid token passes and `req.user.role` comes from DB; deleted user → 401; tv mismatch → 401. Update `tokens.test.ts` payload fixtures to include `tv: 0` (type error until implemented = failing).
|
||||
- [ ] **Step 2: Run** `npx vitest run src/middleware/auth.test.ts src/utils/tokens.test.ts` → FAIL (missing tv / column).
|
||||
- [ ] **Step 3: Implement** per code in execution notes: migration guard block; `token_version INTEGER NOT NULL DEFAULT 0`; JwtPayload gains required `tv`; requireAuth loads user via `getUserById`, 401 on missing user, 401 on `user.token_version !== payload.tv`, rebuilds `req.user` from the DB row; changePassword/resetPassword bump tokenVersion; logout blacklists `(decoded.exp ?? now+86400)*1000`; initCache purges expired rows before loading.
|
||||
- [ ] **Step 4: Run** → PASS.
|
||||
- [ ] **Step 5: Full suite** `npx vitest run` → PASS.
|
||||
|
||||
### Task 2: Setup-gated bootstrap, no register auto-admin, enumeration-safe register, last-admin guard (sub-threshold 2, 3, 4)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/routes/setup.ts` (status + POST gate on countAdmins)
|
||||
- Modify: `backend/src/routes/auth.ts` (drop isFirstUser; last-admin self-delete guard)
|
||||
- Modify: `backend/src/services/authService.ts` (register signature, role always 'user', duplicate → generic success after timing-equalized hash)
|
||||
- Modify: `backend/src/db/migrations.ts` (remove auto-promote block)
|
||||
- Modify: `backend/src/repositories/userRepo.ts` (remove dead promoteFirstUserToAdmin)
|
||||
- Test: `backend/src/services/authService.test.ts` (new)
|
||||
|
||||
- [ ] **Step 1: Failing tests**: duplicate-email register resolves `{ message }` with no token; fresh register returns token with role 'user'; both take ~equal time (hash-before-check asserted structurally, not by timing).
|
||||
- [ ] **Step 2: Run** → FAIL.
|
||||
- [ ] **Step 3: Implement**: setup status/gate switch to `countAdmins() > 0`; register drops isFirstUser param, hashes password before duplicate check, returns `{ message: 'Check your inbox…' }` on duplicates; `DELETE /api/auth/me` refuses when `role==='admin' && countAdmins()<=1`; migrations/userRepo promotion code deleted.
|
||||
- [ ] **Step 4: Run** → PASS. **Step 5: Full suite** → PASS.
|
||||
|
||||
### Task 3: AI quota enforcement + prompt size caps (Vuln 1, Vuln 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/utils/aiQuota.ts` (add enforceDailyAiQuota throwing AppError 429)
|
||||
- Create: `backend/src/validators/gradeSchemas.ts` (schema with serialized-size superRefine caps), `backend/src/validators/aiSchemas.ts` (hintSchema moved + cap)
|
||||
- Modify: `backend/src/routes/ai.ts`, `backend/src/routes/grade.ts`, `backend/src/routes/results.ts` (enforce before provider call; recordDailyAiUsage after, using returned usageTokens)
|
||||
- Test: `backend/src/utils/aiQuota.test.ts`, `backend/src/validators/gradeSchemas.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `recordDailyAiUsage(userId, getUtcDateKey(), tokens)`; services' existing `AiUsageResult.usageTokens`.
|
||||
- Produces: `enforceDailyAiQuota(userId): void` (throws `AppError(429)`).
|
||||
|
||||
- [ ] **Step 1: Failing tests**: quota throws 429 when used ≥ limit; passes under limit; passes when limit unset ("0"/absent → null → unlimited). Grade schema rejects question JSON > 8000 chars / answer > 4000 chars.
|
||||
- [ ] **Step 2: Run** → FAIL. **Step 3: Implement. Step 4: Run → PASS. Step 5: Full suite → PASS.**
|
||||
|
||||
### Task 4: Admin user DTO + result schema bounds (sub-threshold 5, 6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/repositories/userRepo.ts` (`SafeUser`, explicit SELECT columns in listUsers)
|
||||
- Modify: `backend/src/validators/resultSchemas.ts` (finite/int/min/max bounds, title ≤300)
|
||||
- Test: `backend/src/validators/resultSchemas.test.ts`, repo test for sanitized listing
|
||||
|
||||
- [ ] **Step 1: Failing tests**: listUsers never includes password_hash/verify_token/reset_token/reset_expires keys; schema rejects negative counts, non-integers, huge floats, oversized titles.
|
||||
- [ ] **Step 2–5:** implement → PASS → full suite PASS.
|
||||
|
||||
### Task 5: View-only share clone block (Vuln 5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/routes/share.ts`
|
||||
- Test: `backend/src/routes/share.test.ts`
|
||||
|
||||
- [ ] Export pure `canClone(shareMode?: string | null): boolean` ('view' → false; 'clone'/null/undefined → true). Failing test first, then guard `if (!canClone(source.share_mode)) throw new AppError(403, 'This exam is shared as view-only and cannot be saved')` before `cloneExam`. PASS + full suite.
|
||||
|
||||
### Task 6: CI SSH host-key pinning (sub-threshold 7)
|
||||
|
||||
**Files:** Modify `.gitea/workflows/deploy.yml`
|
||||
|
||||
- [ ] Replace keyscan step: write `${{ vars.SSH_HOST_PUBLIC_KEY }}` via env-indirection into known_hosts; hard-fail with setup instructions when unset. Validate YAML parses.
|
||||
|
||||
### Task 7: Verification
|
||||
|
||||
- [ ] `cd backend && npx tsc --noEmit && npx vitest run` — all green.
|
||||
- [ ] `cd frontend && npm run build` (or tsc -b) — typecheck green (no frontend changes expected).
|
||||
- [ ] Ultracode adversarial review workflow over the full diff; fix anything CONFIRMED.
|
||||
- [ ] Final report mapping each finding → fix → evidence.
|
||||
Generated
+13
@@ -12,6 +12,7 @@
|
||||
"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-i18next": "^15.4.1",
|
||||
@@ -2898,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",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"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-i18next": "^15.4.1",
|
||||
|
||||
@@ -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 |
+27
-13
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -49,6 +50,7 @@ function AppInner() {
|
||||
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);
|
||||
@@ -94,23 +96,29 @@ function AppInner() {
|
||||
|
||||
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/review/") && !exam) {
|
||||
if (pathname.startsWith("/review/")) {
|
||||
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]);
|
||||
}, [pathname]);
|
||||
|
||||
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
|
||||
(nextState) => {
|
||||
@@ -255,7 +263,7 @@ function AppInner() {
|
||||
setExamState(null);
|
||||
savedExamIdRef.current = null;
|
||||
sessionStorage.removeItem('brainwave.pendingResultId');
|
||||
navigate("/");
|
||||
navigate(user && isAuthenticated() ? "/workspace" : "/");
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
@@ -335,7 +343,7 @@ function AppInner() {
|
||||
}, [pathname, exam, examState, user, navigate, pendingResultId]);
|
||||
|
||||
const handleAuthFlowDone = () => {
|
||||
navigate("/");
|
||||
navigate(user && isAuthenticated() ? "/workspace" : "/");
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -473,14 +481,18 @@ function AppInner() {
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Landing
|
||||
onLoadExam={loadExam}
|
||||
history={history}
|
||||
onResume={resume}
|
||||
onClearHistory={clearAllHistory}
|
||||
onReviewAttempt={reviewAttempt}
|
||||
onRetakeAttempt={retakeAttempt}
|
||||
/>
|
||||
user && isAuthenticated() ? (
|
||||
<Navigate to="/workspace" replace />
|
||||
) : (
|
||||
<Landing
|
||||
onLoadExam={loadExam}
|
||||
history={history}
|
||||
onResume={resume}
|
||||
onClearHistory={clearAllHistory}
|
||||
onReviewAttempt={reviewAttempt}
|
||||
onRetakeAttempt={retakeAttempt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -525,6 +537,7 @@ function AppInner() {
|
||||
onRestart={restart}
|
||||
onExit={exit}
|
||||
reviewOnly={true}
|
||||
resultId={reviewResultId}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
@@ -541,9 +554,10 @@ function AppInner() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/workspace" element={<Navigate to="/workspace/exams" 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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -38,7 +38,11 @@ export function changePassword(data: { currentPassword: string; newPassword: str
|
||||
}
|
||||
|
||||
export function verifyEmailToken(token: string): Promise<{ message: string }> {
|
||||
return apiFetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
||||
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 }> {
|
||||
@@ -52,3 +56,7 @@ export function resetPassword(data: { token: string; newPassword: string }): Pro
|
||||
export function deleteAccount(): Promise<{ success: boolean }> {
|
||||
return apiFetch('/api/auth/me', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return apiFetch('/api/auth/logout', { method: 'POST' })
|
||||
}
|
||||
@@ -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 }) })
|
||||
}
|
||||
@@ -8,24 +8,85 @@ export interface ExamListItem {
|
||||
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> {
|
||||
export function getExam(id: number): Promise<Exam & { tags?: string[] }> {
|
||||
return apiFetch(`/api/exams/${id}`)
|
||||
}
|
||||
|
||||
export function createExam(exam: Exam): Promise<{ id: number; title: string }> {
|
||||
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam }) })
|
||||
export function 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): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam }) })
|
||||
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 }) })
|
||||
}
|
||||
@@ -41,3 +41,31 @@ export function submitResult(data: SubmitResultData): Promise<{ id: number }> {
|
||||
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')
|
||||
}
|
||||
@@ -1,7 +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
|
||||
@@ -10,6 +11,9 @@ 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()
|
||||
@@ -20,22 +24,50 @@ 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) {
|
||||
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)
|
||||
@@ -45,6 +77,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('auth.passwordsDoNotMatch'))
|
||||
return
|
||||
@@ -60,6 +93,23 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
}
|
||||
}
|
||||
|
||||
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()}>
|
||||
@@ -94,6 +144,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
|
||||
{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">
|
||||
@@ -133,6 +184,25 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as examApi from '../api/examApi'
|
||||
import * as aiApi from '../api/aiApi'
|
||||
import type { Exam, Question, QuestionType, Choice } from '../types'
|
||||
|
||||
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||
@@ -13,6 +14,14 @@ const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||
{ value: 'essay', labelKey: 'editor.essay' },
|
||||
{ value: 'match', labelKey: 'editor.match' },
|
||||
{ value: 'order', labelKey: 'editor.order' },
|
||||
{ value: 'fill-in-the-blanks', labelKey: 'editor.fillInTheBlanks' },
|
||||
{ value: 'true-false', labelKey: 'editor.trueFalse' },
|
||||
{ value: 'code-submission', labelKey: 'editor.codeSubmission' },
|
||||
{ value: 'dropdown-completion', labelKey: 'editor.dropdownCompletion' },
|
||||
{ value: 'numeric-range', labelKey: 'editor.numericRange' },
|
||||
{ value: 'dictation', labelKey: 'editor.dictation' },
|
||||
{ value: 'grammar-correction', labelKey: 'editor.grammarCorrection' },
|
||||
{ value: 'flashcard', labelKey: 'editor.flashcard' },
|
||||
]
|
||||
|
||||
const BLANK_EXAM: Exam = {
|
||||
@@ -39,6 +48,22 @@ function makeBlankQuestion(type: QuestionType, index: number): Question {
|
||||
return { ...base, left: [], right: [], answer: {} }
|
||||
case 'order':
|
||||
return { ...base, items: [], answer: [] }
|
||||
case 'true-false':
|
||||
return { ...base, answer: true }
|
||||
case 'numeric-range':
|
||||
return { ...base, numericConfig: { answer: 0, tolerance: 0 } }
|
||||
case 'fill-in-the-blanks':
|
||||
return { ...base, textSegments: ['The capital of France is ', '.'], blanks: [{ id: 'b1', acceptedAnswers: ['Paris'] }] }
|
||||
case 'dropdown-completion':
|
||||
return { ...base, textSegments: ['The sky is ', ' during the day.'], dropdowns: [{ id: 'd1', options: [{ id: 'a', text: 'blue' }, { id: 'b', text: 'green' }], correctOptionId: 'a' }] }
|
||||
case 'code-submission':
|
||||
return { ...base, codeConfig: { language: 'python', starterCode: '' }, rubric: [] }
|
||||
case 'dictation':
|
||||
return { ...base, audio: { src: '' }, answer: '', acceptedForms: [], caseSensitive: false }
|
||||
case 'grammar-correction':
|
||||
return { ...base, textToCorrect: '', answer: '', acceptedForms: [], caseSensitive: false }
|
||||
case 'flashcard':
|
||||
return { ...base, answer: '' }
|
||||
default:
|
||||
return base
|
||||
}
|
||||
@@ -58,6 +83,8 @@ export default function ExamEditor() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(isNew)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
|
||||
// Load existing exam
|
||||
useEffect(() => {
|
||||
@@ -66,6 +93,7 @@ export default function ExamEditor() {
|
||||
examApi.getExam(examId!)
|
||||
.then((data) => {
|
||||
setExam(data)
|
||||
setTags(data.tags ?? [])
|
||||
setHasChanges(false)
|
||||
})
|
||||
.catch((e) => setError((e as Error).message))
|
||||
@@ -90,6 +118,22 @@ export default function ExamEditor() {
|
||||
setSelectedIdx(exam.questions.length)
|
||||
}
|
||||
|
||||
const appendGeneratedQuestions = useCallback((generated: aiApi.GeneratedQuestion[]) => {
|
||||
let firstNewIdx = -1
|
||||
updateExam((draft) => {
|
||||
firstNewIdx = draft.questions.length
|
||||
for (const g of generated) {
|
||||
const idx = draft.questions.length
|
||||
const merged: Question = {
|
||||
...(g as Omit<Question, 'id'>),
|
||||
id: `q${idx + 1}`,
|
||||
}
|
||||
draft.questions.push(merged)
|
||||
}
|
||||
})
|
||||
if (firstNewIdx >= 0) setSelectedIdx(firstNewIdx)
|
||||
}, [updateExam])
|
||||
|
||||
const removeQuestion = (idx: number) => {
|
||||
updateExam((draft) => {
|
||||
draft.questions.splice(idx, 1)
|
||||
@@ -113,12 +157,12 @@ export default function ExamEditor() {
|
||||
setError(null)
|
||||
try {
|
||||
if (isNew) {
|
||||
const data = await examApi.createExam(exam)
|
||||
const data = await examApi.createExam(exam, tags)
|
||||
setSaveMsg(t('editor.examCreated'))
|
||||
setHasChanges(false)
|
||||
navigate(`/exam-editor/${data.id}`, { replace: true })
|
||||
} else {
|
||||
await examApi.updateExam(examId!, exam)
|
||||
await examApi.updateExam(examId!, exam, tags)
|
||||
setSaveMsg(t('editor.examSaved'))
|
||||
setHasChanges(false)
|
||||
}
|
||||
@@ -165,6 +209,12 @@ export default function ExamEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div style={{ padding: '8px 16px', borderBottom: '1px solid var(--line-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<TagsEditor tags={tags} onChange={(newTags) => { setTags(newTags); setHasChanges(true) }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="exam-editor-loading">{t('common.loading')}</div>
|
||||
) : (
|
||||
@@ -194,6 +244,21 @@ export default function ExamEditor() {
|
||||
|
||||
<div className="exam-editor-add">
|
||||
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
onClick={() => setAiPanelOpen((v) => !v)}
|
||||
>
|
||||
{aiPanelOpen ? t('editor.aiClose') : t('editor.aiGenerate')}
|
||||
</button>
|
||||
{aiPanelOpen && (
|
||||
<AIGeneratePanel
|
||||
onGenerated={(qs) => {
|
||||
appendGeneratedQuestions(qs)
|
||||
setAiPanelOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="exam-editor-type-grid">
|
||||
{QUESTION_TYPES.map((tType) => (
|
||||
<button
|
||||
@@ -234,6 +299,175 @@ export default function ExamEditor() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Tags Editor ─── */
|
||||
|
||||
function TagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: string[]) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
const addTag = () => {
|
||||
const raw = input.trim().toLowerCase().replace(/[^a-z0-9\-_]/g, '')
|
||||
if (!raw) return
|
||||
if (tags.includes(raw)) return
|
||||
onChange([...tags, raw])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
onChange(tags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', flex: 1 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.tagsLabel')}</span>
|
||||
{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)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTag(tag)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 11, color: 'var(--wrong)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
placeholder={t('editor.tagsPlaceholder')}
|
||||
style={{ width: 140, fontSize: 12 }}
|
||||
/>
|
||||
<button className="btn btn-sm" onClick={addTag} style={{ fontSize: 11 }}>
|
||||
{t('editor.addTag')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── AI Generate Panel ─── */
|
||||
|
||||
const AI_GENERATE_TYPES: { value: QuestionType; labelKey: string }[] = QUESTION_TYPES
|
||||
|
||||
function AIGeneratePanel({ onGenerated }: { onGenerated: (qs: aiApi.GeneratedQuestion[]) => void }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [topic, setTopic] = useState('')
|
||||
const [type, setType] = useState<QuestionType>('short-answer')
|
||||
const [count, setCount] = useState(3)
|
||||
const [context, setContext] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const submit = async () => {
|
||||
if (!topic.trim()) {
|
||||
setErr(t('editor.aiTopicRequired'))
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const resp = await aiApi.generateQuestions({
|
||||
topic: topic.trim(),
|
||||
type,
|
||||
count,
|
||||
language: i18n.language || 'en',
|
||||
context: context.trim() || undefined,
|
||||
})
|
||||
onGenerated(resp.questions)
|
||||
setTopic('')
|
||||
setContext('')
|
||||
} catch (e) {
|
||||
setErr((e as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
border: '1px solid var(--line-2)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--paper)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.aiPanelTitle')}</div>
|
||||
<input
|
||||
className="input"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder={t('editor.aiTopicPlaceholder')}
|
||||
disabled={busy}
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={t('editor.aiContextPlaceholder')}
|
||||
disabled={busy}
|
||||
rows={3}
|
||||
style={{ fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<select
|
||||
className="input"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as QuestionType)}
|
||||
disabled={busy}
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
>
|
||||
{AI_GENERATE_TYPES.map((tt) => (
|
||||
<option key={tt.value} value={tt.value}>
|
||||
{t(tt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Math.max(1, Math.min(10, Number(e.target.value) || 1)))}
|
||||
disabled={busy}
|
||||
style={{ width: 60, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 11, color: 'var(--wrong)' }}>{err}</div>}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? t('editor.aiGenerating') : t('editor.aiGenerateNow')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Question Editor ─── */
|
||||
|
||||
interface QEProps {
|
||||
@@ -319,6 +553,14 @@ function QuestionEditor({ question, onChange }: QEProps) {
|
||||
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'true-false' && <TrueFalseEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'numeric-range' && <NumericRangeEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'fill-in-the-blanks' && <FillInTheBlanksEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'dropdown-completion' && <DropdownCompletionEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'code-submission' && <CodeSubmissionEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'dictation' && <DictationEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'grammar-correction' && <GrammarCorrectionEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'flashcard' && <FlashcardEditor q={question} onChange={onChange} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -332,13 +574,12 @@ function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps[
|
||||
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
|
||||
|
||||
const addChoice = () => {
|
||||
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
|
||||
const nextId = String.fromCharCode(97 + choices.length)
|
||||
setChoices([...choices, { id: nextId, text: '' }])
|
||||
}
|
||||
|
||||
const removeChoice = (idx: number) => {
|
||||
const next = choices.filter((_, i) => i !== idx)
|
||||
// Renumber
|
||||
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
|
||||
setChoices(renumbered)
|
||||
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
|
||||
@@ -686,6 +927,323 @@ function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── True/False ─── */
|
||||
|
||||
function TrueFalseEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||||
<div className="choice-editor-row">
|
||||
<button
|
||||
className={`btn btn-sm${q.answer === true ? ' btn-primary' : ''}`}
|
||||
onClick={() => onChange((q) => { q.answer = true })}
|
||||
type="button"
|
||||
>
|
||||
True
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm${q.answer === false ? ' btn-primary' : ''}`}
|
||||
onClick={() => onChange((q) => { q.answer = false })}
|
||||
type="button"
|
||||
>
|
||||
False
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Numeric Range ─── */
|
||||
|
||||
function NumericRangeEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
const cfg = q.numericConfig ?? { answer: 0, tolerance: 0 }
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row-inline">
|
||||
<div>
|
||||
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
value={cfg.answer}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, answer: Number(e.target.value) } })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
<label className="question-form-label">Tolerance</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
min={0}
|
||||
value={cfg.tolerance}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, tolerance: Math.max(0, Number(e.target.value)) } })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
<label className="question-form-label">Min (opt)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
value={cfg.min ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, min: e.target.value ? Number(e.target.value) : undefined } })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 120 }}>
|
||||
<label className="question-form-label">Max (opt)</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="any"
|
||||
value={cfg.max ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.numericConfig = { ...cfg, max: e.target.value ? Number(e.target.value) : undefined } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Fill in the Blanks ─── */
|
||||
|
||||
function FillInTheBlanksEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const segments = q.textSegments ?? []
|
||||
const blanks = q.blanks ?? []
|
||||
|
||||
const updateSegment = (i: number, text: string) => {
|
||||
const next = [...segments]
|
||||
next[i] = text
|
||||
onChange((q) => { q.textSegments = next })
|
||||
}
|
||||
|
||||
const addBlank = () => {
|
||||
const id = `b${blanks.length + 1}`
|
||||
onChange((q) => {
|
||||
q.textSegments = [...(q.textSegments ?? []), '']
|
||||
q.blanks = [...(q.blanks ?? []), { id, acceptedAnswers: [''] }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeBlank = (idx: number) => {
|
||||
onChange((q) => {
|
||||
q.textSegments = segments.filter((_, i) => i !== idx)
|
||||
q.blanks = blanks.filter((_, i) => i !== idx)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Text segments & blanks</label>
|
||||
<p className="faint" style={{ fontSize: 12 }}>Text before blank 1, then blank 1 answer, then text before blank 2, etc.</p>
|
||||
{segments.map((seg, i) => (
|
||||
<div key={i} style={{ marginBottom: 12 }}>
|
||||
<div className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 60 }}>Text {i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={seg}
|
||||
onChange={(e) => updateSegment(i, e.target.value)}
|
||||
placeholder={`Text before blank ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
{i < blanks.length && (
|
||||
<div className="choice-editor-row" style={{ marginBottom: 6, paddingLeft: 20 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 60 }}>Blank {i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={blanks[i].acceptedAnswers[0] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blanks]
|
||||
next[i] = { ...next[i], acceptedAnswers: [e.target.value] }
|
||||
onChange((q) => { q.blanks = next })
|
||||
}}
|
||||
placeholder="Accepted answer"
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeBlank(i)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addBlank} style={{ marginTop: 4 }}>
|
||||
+ Add Blank
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Dropdown Completion ─── */
|
||||
|
||||
function DropdownCompletionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const segments = q.textSegments ?? []
|
||||
const dropdowns = q.dropdowns ?? []
|
||||
|
||||
const updateSegment = (i: number, text: string) => {
|
||||
const next = [...segments]
|
||||
next[i] = text
|
||||
onChange((q) => { q.textSegments = next })
|
||||
}
|
||||
|
||||
const addDropdown = () => {
|
||||
const id = `d${dropdowns.length + 1}`
|
||||
onChange((q) => {
|
||||
q.textSegments = [...(q.textSegments ?? []), '']
|
||||
q.dropdowns = [...(q.dropdowns ?? []), { id, options: [{ id: 'a', text: '' }, { id: 'b', text: '' }], correctOptionId: 'a' }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeDropdown = (idx: number) => {
|
||||
onChange((q) => {
|
||||
q.textSegments = segments.filter((_, i) => i !== idx)
|
||||
q.dropdowns = dropdowns.filter((_, i) => i !== idx)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Text segments & dropdowns</label>
|
||||
<p className="faint" style={{ fontSize: 12 }}>Text before dropdown 1, then dropdown 1 options, etc.</p>
|
||||
{segments.map((seg, i) => (
|
||||
<div key={i} style={{ marginBottom: 12 }}>
|
||||
<div className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 80 }}>Text {i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={seg}
|
||||
onChange={(e) => updateSegment(i, e.target.value)}
|
||||
placeholder={`Text before dropdown ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
{i < dropdowns.length && (
|
||||
<div style={{ paddingLeft: 20, marginBottom: 6 }}>
|
||||
<div className="choice-editor-row" style={{ marginBottom: 4 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 80 }}>Dropdown {i + 1}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeDropdown(i)}>✕</button>
|
||||
</div>
|
||||
{dropdowns[i].options.map((opt, j) => (
|
||||
<div key={opt.id} className="choice-editor-row" style={{ marginBottom: 4, paddingLeft: 20 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`dd-correct-${i}`}
|
||||
checked={dropdowns[i].correctOptionId === opt.id}
|
||||
onChange={() => {
|
||||
const next = [...dropdowns]
|
||||
next[i] = { ...next[i], correctOptionId: opt.id }
|
||||
onChange((q) => { q.dropdowns = next })
|
||||
}}
|
||||
title="Mark as correct"
|
||||
/>
|
||||
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{opt.id}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={opt.text}
|
||||
onChange={(e) => {
|
||||
const next = [...dropdowns]
|
||||
const opts = [...next[i].options]
|
||||
opts[j] = { ...opts[j], text: e.target.value }
|
||||
next[i] = { ...next[i], options: opts }
|
||||
onChange((q) => { q.dropdowns = next })
|
||||
}}
|
||||
placeholder={`Option ${opt.id}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ marginLeft: 20, marginTop: 4 }}
|
||||
onClick={() => {
|
||||
const next = [...dropdowns]
|
||||
const opts = [...next[i].options]
|
||||
const nextId = String.fromCharCode(97 + opts.length)
|
||||
opts.push({ id: nextId, text: '' })
|
||||
next[i] = { ...next[i], options: opts }
|
||||
onChange((q) => { q.dropdowns = next })
|
||||
}}
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addDropdown} style={{ marginTop: 4 }}>
|
||||
+ Add Dropdown
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Code Submission ─── */
|
||||
|
||||
function CodeSubmissionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
const cfg = q.codeConfig ?? { language: 'python', starterCode: '' }
|
||||
const rubric = q.rubric ?? []
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row-inline">
|
||||
<div style={{ width: 160 }}>
|
||||
<label className="question-form-label">Language</label>
|
||||
<input
|
||||
className="input"
|
||||
value={cfg.language}
|
||||
onChange={(e) => onChange((q) => { q.codeConfig = { ...cfg, language: e.target.value } })}
|
||||
placeholder="e.g. python, javascript"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Starter Code</label>
|
||||
<textarea
|
||||
className="textarea mono"
|
||||
value={cfg.starterCode ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.codeConfig = { ...cfg, starterCode: e.target.value } })}
|
||||
rows={6}
|
||||
placeholder="Code shown to the student at start…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">{t('editor.rubricCriteria')}</label>
|
||||
{rubric.map((item, i) => (
|
||||
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const next = [...rubric]
|
||||
next[i] = e.target.value
|
||||
onChange((q) => { q.rubric = next })
|
||||
}}
|
||||
placeholder={t('editor.criterion')}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
const next = rubric.filter((_, idx) => idx !== i)
|
||||
onChange((q) => { q.rubric = next })
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{t('editor.addCriterion')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Order ─── */
|
||||
|
||||
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
@@ -752,7 +1310,6 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
{t('editor.correctOrderHint')}
|
||||
</p>
|
||||
{/* Sync answer to current item order */}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -765,3 +1322,62 @@ function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Dictation ─── */
|
||||
|
||||
function DictationEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">{t('editor.dictation')}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={q.audio?.src ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.audio = { src: e.target.value } })}
|
||||
placeholder="https://example.com/audio.mp3"
|
||||
/>
|
||||
</div>
|
||||
<TextAnswerEditor q={q} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Grammar Correction ─── */
|
||||
|
||||
function GrammarCorrectionEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">{t('editor.textToCorrect')}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={q.textToCorrect ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.textToCorrect = e.target.value })}
|
||||
placeholder={t('editor.textToCorrectPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<TextAnswerEditor q={q} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Flashcard ─── */
|
||||
|
||||
function FlashcardEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">{t('editor.correctAnswer')}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={(q.answer as string) ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
|
||||
placeholder="The answer revealed on the back of the card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -27,7 +28,7 @@ export default function ExamRunner({
|
||||
onNavigate,
|
||||
n,
|
||||
}: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
||||
const { answers, flags, startedAt, feedbackMode } = state;
|
||||
const currentIdx = urlIdx;
|
||||
@@ -40,6 +41,35 @@ export default function ExamRunner({
|
||||
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 } }));
|
||||
|
||||
@@ -212,7 +242,7 @@ export default function ExamRunner({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<Tex tag="div" className="q-prompt">
|
||||
<Tex tag="div" className="q-prompt" markdown>
|
||||
{q.prompt}
|
||||
</Tex>
|
||||
<QuestionMedia q={q} />
|
||||
@@ -225,6 +255,26 @@ export default function ExamRunner({
|
||||
graded={graded}
|
||||
/>
|
||||
|
||||
{(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">
|
||||
@@ -259,6 +309,20 @@ export default function ExamRunner({
|
||||
<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 &&
|
||||
|
||||
@@ -4,6 +4,7 @@ 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, saveToWorkspace?: boolean) => void
|
||||
@@ -53,327 +54,6 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -11,27 +12,60 @@ interface SavedExam {
|
||||
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
|
||||
}
|
||||
|
||||
export default function MyExams({ onLoadExam }: Props) {
|
||||
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 loadExams = useCallback(async () => {
|
||||
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 data = await examApi.listExams()
|
||||
setExams(data)
|
||||
const [examData, collectionData] = await Promise.all([
|
||||
examApi.listExams(),
|
||||
collectionApi.listCollections(),
|
||||
])
|
||||
setExams(examData)
|
||||
setCollections(collectionData)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
@@ -40,14 +74,14 @@ export default function MyExams({ onLoadExam }: Props) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadExams()
|
||||
}, [loadExams])
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const deleteExam = async (id: number) => {
|
||||
try {
|
||||
await examApi.deleteExam(id)
|
||||
setDeleteConfirm(null)
|
||||
void loadExams()
|
||||
void loadData()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
@@ -68,97 +102,725 @@ export default function MyExams({ onLoadExam }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
|
||||
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||||
+ {t('workspace.newExam')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="workspace-alert workspace-alert-error">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="workspace-empty">{t('common.loading')}</div>
|
||||
) : exams.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">{t('workspace.noSavedExams')}</div>
|
||||
<div className="workspace-empty-desc">
|
||||
{t('workspace.noSavedExamsDesc')}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||
{t('workspace.createFirstExam')}
|
||||
<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>
|
||||
) : (
|
||||
<div className="workspace-list">
|
||||
{exams.map((exam) => (
|
||||
<div key={exam.id} className="workspace-card">
|
||||
<div className="workspace-card-body">
|
||||
<div className="workspace-card-title">{exam.title}</div>
|
||||
{exam.subtitle && (
|
||||
<div className="workspace-card-sub">{exam.subtitle}</div>
|
||||
)}
|
||||
<div className="workspace-card-meta">
|
||||
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
|
||||
<span>·</span>
|
||||
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
<div className="workspace-card-actions">
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void startExam(exam.id)}
|
||||
disabled={startingId === exam.id}
|
||||
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 }}
|
||||
>
|
||||
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
|
||||
<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>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||||
disabled={startingId === exam.id}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
{deleteConfirm === exam.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||||
)}
|
||||
{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-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
<button className="btn btn-sm btn-ghost" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => setDeleteCollectionId(null)}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => setDeleteConfirm(exam.id)}
|
||||
title={t('common.delete')}
|
||||
disabled={startingId === exam.id}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export default function MyResults({ onLoadExam }: Props) {
|
||||
...(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)
|
||||
@@ -157,7 +158,7 @@ export default function MyResults({ onLoadExam }: Props) {
|
||||
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
||||
{t('common.edit')}
|
||||
{t('results.review')}
|
||||
</button>
|
||||
{deleteConfirm === r.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
onExit: () => void
|
||||
onRedoFailed?: () => void
|
||||
reviewOnly?: boolean
|
||||
resultId?: number | null
|
||||
}
|
||||
|
||||
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
|
||||
@@ -35,7 +37,7 @@ function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
|
||||
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
|
||||
@@ -167,6 +169,8 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultId != null && <TutorChatPanel resultId={resultId} />}
|
||||
|
||||
<h2 className="review-heading">{t('results.review')}</h2>
|
||||
{exam.questions.map((q, i) => {
|
||||
const g = graded[i]
|
||||
@@ -196,7 +200,7 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
{openIdx === i && (
|
||||
<div className="review-body">
|
||||
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
||||
<Tex>{q.prompt}</Tex>
|
||||
<Tex markdown>{q.prompt}</Tex>
|
||||
</div>
|
||||
<QuestionMedia q={q} />
|
||||
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
|
||||
@@ -364,3 +368,121 @@ function AiDetail({ detail, type }: { detail: unknown; type: string }) {
|
||||
|
||||
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,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,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,19 +1,34 @@
|
||||
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 = 'exams' | 'results' | 'storage' | 'settings'
|
||||
type TabKey = 'overview' | 'exams' | 'shared' | 'results' | 'storage' | 'settings'
|
||||
|
||||
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
|
||||
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: '⚙' },
|
||||
@@ -28,9 +43,10 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { tab } = useParams<{ tab: string }>()
|
||||
const activeTab = (tab as TabKey) ?? 'exams'
|
||||
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 {
|
||||
@@ -38,6 +54,7 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
setExamCount(exams.length)
|
||||
const results = await resultApi.listResults()
|
||||
setResultCount(results.total)
|
||||
setRecentResults(results.results.slice(0, 5))
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
@@ -47,11 +64,11 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
if (user) void loadCounts()
|
||||
}, [user, loadCounts])
|
||||
|
||||
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
|
||||
|
||||
if (authLoading) return null
|
||||
if (!user) return <Navigate to="/" replace />
|
||||
|
||||
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
|
||||
|
||||
return (
|
||||
<div className="workspace-shell">
|
||||
<aside className="workspace-sidebar">
|
||||
@@ -60,7 +77,6 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="workspace-name">{user.displayName}</div>
|
||||
<div className="workspace-email">{user.email}</div>
|
||||
</div>
|
||||
|
||||
<nav className="workspace-nav">
|
||||
@@ -81,10 +97,33 @@ export default function Workspace({ onLoadExam }: Props) {
|
||||
</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 />}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface CodeValue {
|
||||
code?: string
|
||||
selfGrade?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: CodeValue | string | null | undefined
|
||||
onChange: (val: CodeValue) => void
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export default function CodeSubmission({ q, value, onChange, locked }: Props) {
|
||||
const cfg = q.codeConfig
|
||||
const language = cfg?.language ?? 'text'
|
||||
const starterCode = cfg?.starterCode ?? ''
|
||||
|
||||
const val = typeof value === 'string' ? { code: value } : ((value as CodeValue) ?? {})
|
||||
const code = val.code ?? starterCode
|
||||
|
||||
return (
|
||||
<div className="code-submission-wrap" style={{ maxWidth: 720 }}>
|
||||
<div className="code-submission-bar">
|
||||
<span className="code-lang-badge">{language}</span>
|
||||
{cfg?.starterCode && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => onChange({ ...val, code: cfg.starterCode })}
|
||||
disabled={locked}
|
||||
type="button"
|
||||
>
|
||||
Reset code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
className="code-submission-area"
|
||||
value={code}
|
||||
onChange={(e) => onChange({ ...val, code: e.target.value })}
|
||||
placeholder="Write your code here…"
|
||||
disabled={locked}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<div className="code-submission-hint mono faint">
|
||||
Self-graded · see rubric on review
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: string | null | undefined
|
||||
onChange: (val: string) => void
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export default function Dictation({ q, value = '', onChange, locked }: Props) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [playbackRate, setPlaybackRate] = useState(1)
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return
|
||||
if (playing) {
|
||||
audioRef.current.pause()
|
||||
} else {
|
||||
audioRef.current.playbackRate = playbackRate
|
||||
audioRef.current.play()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="answer-input-wrap">
|
||||
{q.audio?.src && (
|
||||
<div className="dictation-audio-wrap">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={q.audio.src}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
preload="metadata"
|
||||
/>
|
||||
<button
|
||||
className={`btn btn-sm dictation-play-btn${playing ? ' playing' : ''}`}
|
||||
onClick={togglePlay}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{playing ? '⏸ Pause' : '▶ Play'}
|
||||
</button>
|
||||
<div className="dictation-speed">
|
||||
<label className="faint" style={{ fontSize: 11 }}>Speed</label>
|
||||
{[0.5, 0.75, 1, 1.25, 1.5].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`btn btn-ghost btn-sm dictation-speed-btn${playbackRate === rate ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setPlaybackRate(rate)
|
||||
if (audioRef.current) audioRef.current.playbackRate = rate
|
||||
}}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Type what you hear…"
|
||||
disabled={locked}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: Record<string, string> | null | undefined
|
||||
onChange: (val: Record<string, string>) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function DropdownCompletion({ q, value, onChange, locked, graded }: Props) {
|
||||
const current = value ?? {}
|
||||
const segments = q.textSegments ?? []
|
||||
const dropdowns = q.dropdowns ?? []
|
||||
|
||||
const handleChange = (id: string, optionId: string) => {
|
||||
onChange({ ...current, [id]: optionId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cloze-text" style={{ lineHeight: 2.2 }}>
|
||||
{segments.map((segment, i) => (
|
||||
<span key={`s-${i}`}>
|
||||
<span>{segment}</span>
|
||||
{i < dropdowns.length && (
|
||||
<DropdownSelect
|
||||
dropdown={dropdowns[i]}
|
||||
value={current[dropdowns[i].id] ?? ''}
|
||||
onChange={(v) => handleChange(dropdowns[i].id, v)}
|
||||
locked={locked}
|
||||
graded={graded}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownSelect({
|
||||
dropdown,
|
||||
value,
|
||||
onChange,
|
||||
locked,
|
||||
graded,
|
||||
}: {
|
||||
dropdown: { id: string; options: Array<{ id: string; text: string }>; correctOptionId: string }
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}) {
|
||||
let cls = 'cloze-dropdown'
|
||||
if (graded) {
|
||||
cls += value === dropdown.correctOptionId ? ' correct' : value ? ' wrong' : ''
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
className={cls}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={locked}
|
||||
>
|
||||
<option value="" disabled>
|
||||
—
|
||||
</option>
|
||||
{dropdown.options.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: Record<string, string> | null | undefined
|
||||
onChange: (val: Record<string, string>) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function FillInTheBlanks({ q, value, onChange, locked, graded }: Props) {
|
||||
const current = value ?? {}
|
||||
const blanks = q.blanks ?? []
|
||||
const segments = q.textSegments ?? []
|
||||
|
||||
const handleChange = (blankId: string, text: string) => {
|
||||
onChange({ ...current, [blankId]: text })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cloze-text" style={{ lineHeight: 2.2 }}>
|
||||
{segments.map((segment, i) => (
|
||||
<span key={`s-${i}`}>
|
||||
<span>{segment}</span>
|
||||
{i < blanks.length && (
|
||||
<BlankInput
|
||||
blank={blanks[i]}
|
||||
value={current[blanks[i].id] ?? ''}
|
||||
onChange={(v) => handleChange(blanks[i].id, v)}
|
||||
locked={locked}
|
||||
graded={graded}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlankInput({
|
||||
blank,
|
||||
value,
|
||||
onChange,
|
||||
locked,
|
||||
graded,
|
||||
}: {
|
||||
blank: { id: string; acceptedAnswers: string[] }
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}) {
|
||||
let cls = 'cloze-blank'
|
||||
if (graded) {
|
||||
// Simple check: if the normalized value matches any accepted answer
|
||||
const normalized = value.toLowerCase().trim()
|
||||
const ok = blank.acceptedAnswers.some((a) => a.toLowerCase().trim() === normalized)
|
||||
cls += ok ? ' correct' : ' wrong'
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cls}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={locked}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: Math.max(80, (value.length + 2) * 10),
|
||||
minWidth: 80,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Tex from '../Tex'
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: { revealed?: boolean; selfGrade?: number } | null | undefined
|
||||
onChange: (val: { revealed?: boolean; selfGrade?: number }) => void
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export default function Flashcard({ q, value, onChange, locked }: Props) {
|
||||
const { revealed = false, selfGrade } = value ?? {}
|
||||
|
||||
const handleReveal = () => {
|
||||
if (locked) return
|
||||
onChange({ ...value, revealed: true })
|
||||
}
|
||||
|
||||
const handleGrade = (grade: number) => {
|
||||
if (locked) return
|
||||
onChange({ revealed: true, selfGrade: grade })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flashcard-wrap">
|
||||
<div className="flashcard-prompt">
|
||||
<Tex markdown>{q.prompt}</Tex>
|
||||
</div>
|
||||
|
||||
{!revealed ? (
|
||||
<button
|
||||
className="btn flashcard-reveal-btn"
|
||||
onClick={handleReveal}
|
||||
disabled={locked}
|
||||
type="button"
|
||||
>
|
||||
Reveal Answer
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flashcard-answer">
|
||||
{q.answer as string}
|
||||
</div>
|
||||
{q.explanation && (
|
||||
<div className="flashcard-explanation">
|
||||
{q.explanation}
|
||||
</div>
|
||||
)}
|
||||
{selfGrade == null ? (
|
||||
<div className="flashcard-grade">
|
||||
<div className="faint" style={{ fontSize: 12, marginBottom: 6 }}>How well did you know this?</div>
|
||||
<div className="flashcard-grade-btns">
|
||||
<button className="btn btn-sm flashcard-grade-wrong" onClick={() => handleGrade(0)} type="button">
|
||||
Didn't know
|
||||
</button>
|
||||
<button className="btn btn-sm flashcard-grade-hard" onClick={() => handleGrade(0.5)} type="button">
|
||||
Hard
|
||||
</button>
|
||||
<button className="btn btn-sm flashcard-grade-good" onClick={() => handleGrade(1)} type="button">
|
||||
Good
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flashcard-grade-result ${selfGrade >= 0.5 ? 'correct' : 'wrong'}`}>
|
||||
{selfGrade >= 0.5 ? '✓ Marked as known' : '✗ Marked as needs practice'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Tex from '../Tex'
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: string | null | undefined
|
||||
onChange: (val: string) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function GrammarCorrection({ q, value = '', onChange, locked, graded: _graded }: Props) {
|
||||
const displayText = q.textToCorrect || q.prompt
|
||||
return (
|
||||
<div className="answer-input-wrap">
|
||||
<div className="grammar-text-wrap">
|
||||
<div className="grammar-label faint" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
Sentence to correct:
|
||||
</div>
|
||||
<div className="grammar-sentence">
|
||||
<Tex markdown>{displayText}</Tex>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Type the corrected sentence…"
|
||||
disabled={locked}
|
||||
autoComplete="off"
|
||||
spellCheck
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: number | string | null | undefined
|
||||
onChange: (val: number) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function NumericRange({ q, value, onChange, locked, graded }: Props) {
|
||||
const cfg = q.numericConfig
|
||||
if (!cfg) {
|
||||
return <div className="muted">Numeric config missing</div>
|
||||
}
|
||||
|
||||
const min = cfg.min
|
||||
const max = cfg.max
|
||||
const numValue = value === '' || value == null ? '' : Number(value)
|
||||
|
||||
let cls = 'input numeric-input'
|
||||
if (graded) {
|
||||
const ok = typeof numValue === 'number' && !Number.isNaN(numValue) && Math.abs(numValue - cfg.answer) <= cfg.tolerance
|
||||
cls += ok ? ' correct' : ' wrong'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="answer-input-wrap">
|
||||
<input
|
||||
className={cls}
|
||||
type="number"
|
||||
value={numValue}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v === '') return
|
||||
const n = Number(v)
|
||||
if (!Number.isNaN(n)) onChange(n)
|
||||
}}
|
||||
placeholder="Enter number…"
|
||||
disabled={locked}
|
||||
min={min}
|
||||
max={max}
|
||||
step="any"
|
||||
/>
|
||||
<div className="mono faint" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
Tolerance: ±{cfg.tolerance}
|
||||
{min != null && max != null ? ` · Range: ${min} – ${max}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import MathInput from './MathInput'
|
||||
import Essay from './Essay'
|
||||
import Match from './Match'
|
||||
import OrderQuestion from './OrderQuestion'
|
||||
import FillInTheBlanks from './FillInTheBlanks'
|
||||
import TrueFalse from './TrueFalse'
|
||||
import CodeSubmission from './CodeSubmission'
|
||||
import DropdownCompletion from './DropdownCompletion'
|
||||
import NumericRange from './NumericRange'
|
||||
import Dictation from './Dictation'
|
||||
import GrammarCorrection from './GrammarCorrection'
|
||||
import Flashcard from './Flashcard'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
@@ -32,6 +40,22 @@ export default function QuestionRenderer({ q, value, onChange, locked, graded }:
|
||||
return <Match q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
|
||||
case 'order':
|
||||
return <OrderQuestion q={q} value={value as string[]} onChange={onChange as (v: string[]) => void} locked={locked} graded={graded} />
|
||||
case 'fill-in-the-blanks':
|
||||
return <FillInTheBlanks q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
|
||||
case 'true-false':
|
||||
return <TrueFalse q={q} value={value as boolean} onChange={onChange as (v: boolean) => void} locked={locked} graded={graded} />
|
||||
case 'code-submission':
|
||||
return <CodeSubmission q={q} value={value as Record<string, unknown>} onChange={onChange as (v: Record<string, unknown>) => void} locked={locked} />
|
||||
case 'dropdown-completion':
|
||||
return <DropdownCompletion q={q} value={value as Record<string, string>} onChange={onChange as (v: Record<string, string>) => void} locked={locked} graded={graded} />
|
||||
case 'numeric-range':
|
||||
return <NumericRange q={q} value={value as number} onChange={onChange as (v: number) => void} locked={locked} graded={graded} />
|
||||
case 'dictation':
|
||||
return <Dictation q={q} value={value as string} onChange={onChange as (v: string) => void} locked={locked} />
|
||||
case 'grammar-correction':
|
||||
return <GrammarCorrection q={q} value={value as string} onChange={onChange as (v: string) => void} locked={locked} graded={graded} />
|
||||
case 'flashcard':
|
||||
return <Flashcard q={q} value={value as { revealed?: boolean; selfGrade?: number }} onChange={onChange as (v: { revealed?: boolean; selfGrade?: number }) => void} locked={locked} />
|
||||
default:
|
||||
return <div className="muted">Unsupported question type: <code>{q.type}</code></div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Question } from '../../types'
|
||||
|
||||
interface Props {
|
||||
q: Question
|
||||
value: boolean | null | undefined
|
||||
onChange: (val: boolean) => void
|
||||
locked?: boolean
|
||||
graded?: boolean
|
||||
}
|
||||
|
||||
export default function TrueFalse({ q, value, onChange, locked, graded }: Props) {
|
||||
const correctAnswer = q.answer as boolean
|
||||
|
||||
return (
|
||||
<div className="tf-group" role="radiogroup">
|
||||
{[true, false].map((val) => {
|
||||
const isSelected = value === val
|
||||
let cls = 'tf-btn'
|
||||
if (isSelected) cls += ' selected'
|
||||
if (graded) {
|
||||
if (val === correctAnswer) cls += ' correct'
|
||||
else if (isSelected) cls += ' wrong'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={val ? 't' : 'f'}
|
||||
className={cls}
|
||||
onClick={() => !locked && onChange(val)}
|
||||
disabled={locked}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
>
|
||||
<span className="tf-label">{val ? 'True' : 'False'}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ interface AuthContextValue {
|
||||
setUser: (user: User | null) => void
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, displayName: string) => Promise<string>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
refreshUser: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -87,7 +87,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return 'Registration successful! Please check your email to verify your account.'
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// ignore errors — we still want to clear local state
|
||||
}
|
||||
clearToken()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
+103
-4
@@ -30,7 +30,7 @@
|
||||
"downloadInstructions": "Download Instructions — How to create your own exam JSON",
|
||||
"downloadInstructionsIcon": "↓",
|
||||
"supportedTypes": "Supported types",
|
||||
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order",
|
||||
"supportedTypesList": "multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order · fill-in-the-blanks · true-false · code-submission · dropdown-completion · numeric-range",
|
||||
"trySample": "Try a sample",
|
||||
"resumeTitle": "Resume in-progress exam",
|
||||
"resumeProgress": "{{answered}}/{{total}} answered",
|
||||
@@ -62,6 +62,10 @@
|
||||
"loginFailed": "Login failed",
|
||||
"registrationFailed": "Registration failed",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"verificationResendHint": "Didn’t get the email? You can resend the verification link after the countdown finishes.",
|
||||
"resendVerificationLink": "Resend verification link",
|
||||
"resendVerificationLinkIn": "Resend verification link in {{seconds}}s",
|
||||
"verificationLinkSent": "Verification link sent. Please check your email.",
|
||||
"myWorkspace": "My Workspace",
|
||||
"adminDashboard": "Admin Dashboard",
|
||||
"userMenu": "User menu",
|
||||
@@ -108,7 +112,12 @@
|
||||
"flagHint": "flag",
|
||||
"selectHint": "select",
|
||||
"exit": "Exit",
|
||||
"untimed": "Untimed"
|
||||
"untimed": "Untimed",
|
||||
"needHint": "💡 Need a hint?",
|
||||
"hintLoading": "Thinking…",
|
||||
"hintShown": "💡 Hint shown",
|
||||
"hintTitle": "Get a hint without revealing the answer",
|
||||
"hintLabel": "Hint"
|
||||
},
|
||||
"results": {
|
||||
"ai": {
|
||||
@@ -120,6 +129,13 @@
|
||||
"rubricBreakdown": "Rubric breakdown",
|
||||
"error": "AI grading failed — please grade manually"
|
||||
},
|
||||
"tutor": {
|
||||
"title": "💬 Ask the tutor",
|
||||
"subtitle": "Ask follow-up questions about this attempt — explanations, similar problems, or rubric details.",
|
||||
"placeholder": "e.g. \"Why was Q2 wrong?\" or \"Give me a similar problem to practice.\"",
|
||||
"inputPlaceholder": "Ask a question…",
|
||||
"send": "Send"
|
||||
},
|
||||
"reviewing": "Reviewing past attempt",
|
||||
"examComplete": "Exam complete",
|
||||
"scored": "You scored ",
|
||||
@@ -154,6 +170,7 @@
|
||||
},
|
||||
"workspace": {
|
||||
"myWorkspace": "My Workspace",
|
||||
"overview": "Overview",
|
||||
"myExams": "My Exams",
|
||||
"results": "Results",
|
||||
"settings": "Settings",
|
||||
@@ -162,6 +179,31 @@
|
||||
"noSavedExamsDesc": "Upload an exam from the home page, or create a new one to get started.",
|
||||
"createFirstExam": "Create Your First Exam",
|
||||
"takeExam": "Take exam",
|
||||
"totalAttempts": "Total Attempts",
|
||||
"avgScore": "Avg. Score",
|
||||
"questionsAnswered": "Questions Answered",
|
||||
"uploadExam": "Upload Exam",
|
||||
"uploadDropzone": "Drop JSON or click to browse",
|
||||
"studyCoachTitle": "Study Coach",
|
||||
"streakDay": "day streak",
|
||||
"streakDays": "day streak",
|
||||
"studyTips": "Study Tips",
|
||||
"retakeWeakest": "Retake Weakest Exam",
|
||||
"performanceTrends": "Performance Trends",
|
||||
"sharedWithMe": "Shared with Me",
|
||||
"noSharedExams": "No shared exams yet",
|
||||
"noSharedExamsDesc": "Exams shared by others will appear here when you save them to your workspace.",
|
||||
"shareMode": "Share mode",
|
||||
"shareModeClone": "Cloneable",
|
||||
"shareModeView": "View only",
|
||||
"importFromUrl": "Import from URL",
|
||||
"importUrlPlaceholder": "Paste a raw JSON URL…",
|
||||
"import": "Import",
|
||||
"importing": "Importing…",
|
||||
"viewAllResults": "View all results",
|
||||
"recentResults": "Recent Results",
|
||||
"downloadInstructions": "Download Instructions",
|
||||
"viewAll": "View all",
|
||||
"noAttempts": "No attempts yet",
|
||||
"noAttemptsDesc": "Take an exam to see your results here.",
|
||||
"attempts": "attempt",
|
||||
@@ -211,7 +253,27 @@
|
||||
"deleteExamConfirm": "Delete this exam? This cannot be undone.",
|
||||
"deleteResultConfirm": "Delete this result? This cannot be undone.",
|
||||
"storageWarningHigh": "You are using over 80% of your storage.",
|
||||
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space."
|
||||
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space.",
|
||||
"searchExams": "Search exams…",
|
||||
"noSearchResults": "No exams match your search",
|
||||
"noSearchResultsDesc": "Try a different keyword or clear the search.",
|
||||
"pin": "Pin to top",
|
||||
"unpin": "Unpin",
|
||||
"duplicate": "Duplicate",
|
||||
"export": "Export JSON",
|
||||
"collections": "Collections",
|
||||
"newCollection": "New Collection",
|
||||
"allExams": "All Exams",
|
||||
"uncategorized": "Uncategorized",
|
||||
"moveTo": "Move to",
|
||||
"removeFromCollection": "Remove from collection",
|
||||
"collectionNamePlaceholder": "Collection name…",
|
||||
"create": "Create",
|
||||
"rename": "Rename",
|
||||
"deleteCollectionConfirm": "Delete this collection? Exams will be moved to uncategorized.",
|
||||
"batchSelectAll": "Select all",
|
||||
"batchSelected": "{{count}} selected",
|
||||
"batchDeleteConfirm": "Delete selected exams? This cannot be undone."
|
||||
},
|
||||
"admin": {
|
||||
"aiGrading": {
|
||||
@@ -323,6 +385,23 @@
|
||||
"essay": "Essay",
|
||||
"match": "Match",
|
||||
"order": "Order",
|
||||
"fillInTheBlanks": "Fill in the Blanks",
|
||||
"trueFalse": "True / False",
|
||||
"codeSubmission": "Code Submission",
|
||||
"dropdownCompletion": "Dropdown Completion",
|
||||
"numericRange": "Numeric Range",
|
||||
"dictation": "Dictation",
|
||||
"grammarCorrection": "Grammar Correction",
|
||||
"flashcard": "Flashcard",
|
||||
"textToCorrect": "Text to Correct",
|
||||
"textToCorrectPlaceholder": "Enter the grammatically incorrect sentence…",
|
||||
"playbackSpeed": "Speed",
|
||||
"revealAnswer": "Reveal Answer",
|
||||
"didntKnow": "Didn't know",
|
||||
"hard": "Hard",
|
||||
"good": "Good",
|
||||
"flashcardFront": "Prompt (front of card)",
|
||||
"flashcardFrontPlaceholder": "Enter the prompt…",
|
||||
"correctAnswer": "Correct Answer",
|
||||
"correctAnswerPlaceholder": "Enter the correct answer",
|
||||
"acceptedForms": "Accepted Forms (optional alternatives)",
|
||||
@@ -346,6 +425,9 @@
|
||||
"correctOrder": "Correct Order",
|
||||
"correctOrderHint": "The correct order is the order shown above. Reorder items to set the correct sequence.",
|
||||
"setCurrentOrder": "Set Current Order as Correct",
|
||||
"tagsLabel": "Tags",
|
||||
"tagsPlaceholder": "Add tag…",
|
||||
"addTag": "Add",
|
||||
"examTitleRequired": "Exam title is required",
|
||||
"atLeastOneQuestion": "Add at least one question",
|
||||
"sourceLanguage": "Source Language",
|
||||
@@ -356,7 +438,15 @@
|
||||
"choicePlaceholder": "Choice {{id}}",
|
||||
"addItem": "+ Add Item",
|
||||
"leftItemPlaceholder": "Left item {{index}}",
|
||||
"rightItemPlaceholder": "Right item {{index}}"
|
||||
"rightItemPlaceholder": "Right item {{index}}",
|
||||
"aiGenerate": "✨ Generate with AI",
|
||||
"aiClose": "✕ Close AI",
|
||||
"aiPanelTitle": "Generate questions",
|
||||
"aiTopicPlaceholder": "Topic, e.g. \"Pythagorean theorem\"",
|
||||
"aiContextPlaceholder": "Optional notes or source material to ground the questions in…",
|
||||
"aiGenerateNow": "Generate",
|
||||
"aiGenerating": "Generating…",
|
||||
"aiTopicRequired": "Please enter a topic"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Welcome to BrainWave",
|
||||
@@ -398,6 +488,15 @@
|
||||
"backToHome": "Back to Home",
|
||||
"noToken": "No verification token found."
|
||||
},
|
||||
"share": {
|
||||
"share": "Share",
|
||||
"shared": "Shared",
|
||||
"generateLink": "Generate share link",
|
||||
"copyLink": "Copy link",
|
||||
"revokeLink": "Revoke link",
|
||||
"takeExam": "Take this exam",
|
||||
"saveToWorkspace": "Save to my workspace"
|
||||
},
|
||||
"resetPassword": {
|
||||
"resetPassword": "Reset Password",
|
||||
"enterEmail": "Enter your email address and we'll send you a link to reset your password.",
|
||||
|
||||
+102
-4
@@ -30,7 +30,7 @@
|
||||
"downloadInstructions": "Download Instructies — Hoe maak je je eigen examen-JSON",
|
||||
"downloadInstructionsIcon": "↓",
|
||||
"supportedTypes": "Ondersteunde types",
|
||||
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken",
|
||||
"supportedTypesList": "meerkeuze · meervoudige-selectie · kort-antwoord · vertaling · wiskunde-invoer (LaTeX) · opstel · koppelen · rangschikken · invulvragen · waar-onwaar · code-inzending · dropdown-vervolledigen · numeriek-bereik",
|
||||
"trySample": "Probeer een voorbeeld",
|
||||
"resumeTitle": "Hervat lopend examen",
|
||||
"resumeProgress": "{{answered}}/{{total}} beantwoord",
|
||||
@@ -62,6 +62,10 @@
|
||||
"loginFailed": "Inloggen mislukt",
|
||||
"registrationFailed": "Registratie mislukt",
|
||||
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
|
||||
"verificationResendHint": "Geen e-mail ontvangen? Je kunt de verificatielink opnieuw versturen zodra de aftelling voorbij is.",
|
||||
"resendVerificationLink": "Verificatielink opnieuw versturen",
|
||||
"resendVerificationLinkIn": "Verificatielink opnieuw versturen over {{seconds}}s",
|
||||
"verificationLinkSent": "Verificatielink verzonden. Controleer je e-mail.",
|
||||
"myWorkspace": "Mijn Werkruimte",
|
||||
"adminDashboard": "Admin Dashboard",
|
||||
"userMenu": "Gebruikersmenu",
|
||||
@@ -108,7 +112,12 @@
|
||||
"flagHint": "markeren",
|
||||
"selectHint": "selecteren",
|
||||
"exit": "Afsluiten",
|
||||
"untimed": "Ongetimed"
|
||||
"untimed": "Ongetimed",
|
||||
"needHint": "💡 Hint nodig?",
|
||||
"hintLoading": "Aan het denken…",
|
||||
"hintShown": "💡 Hint getoond",
|
||||
"hintTitle": "Krijg een hint zonder dat het antwoord wordt verklapt",
|
||||
"hintLabel": "Hint"
|
||||
},
|
||||
"results": {
|
||||
"ai": {
|
||||
@@ -120,6 +129,13 @@
|
||||
"rubricBreakdown": "Rubric-uitsplitsing",
|
||||
"error": "AI-beoordeling mislukt — beoordeel handmatig"
|
||||
},
|
||||
"tutor": {
|
||||
"title": "💬 Vraag de tutor",
|
||||
"subtitle": "Stel vervolgvragen over deze poging — uitleg, vergelijkbare opgaven of rubric-details.",
|
||||
"placeholder": "bijv. \"Waarom was vraag 2 fout?\" of \"Geef me een vergelijkbare opgave.\"",
|
||||
"inputPlaceholder": "Stel een vraag…",
|
||||
"send": "Verstuur"
|
||||
},
|
||||
"reviewing": "Eerdere poging bekijken",
|
||||
"examComplete": "Examen voltooid",
|
||||
"scored": "Je score: ",
|
||||
@@ -162,6 +178,31 @@
|
||||
"noSavedExamsDesc": "Upload een examen vanaf de homepagina, of maak een nieuw examen aan om te beginnen.",
|
||||
"createFirstExam": "Maak je eerste examen",
|
||||
"takeExam": "Examen maken",
|
||||
"totalAttempts": "Totaal Pogingen",
|
||||
"avgScore": "Gem. Score",
|
||||
"questionsAnswered": "Beantwoorde Vragen",
|
||||
"uploadExam": "Examen Uploaden",
|
||||
"uploadDropzone": "Sleep JSON of klik om te bladeren",
|
||||
"studyCoachTitle": "Study Coach",
|
||||
"streakDay": "dag streak",
|
||||
"streakDays": "dagen streak",
|
||||
"studyTips": "Studietips",
|
||||
"retakeWeakest": "Zwakste Examen Opnieuw",
|
||||
"performanceTrends": "Prestatietrends",
|
||||
"sharedWithMe": "Gedeeld met mij",
|
||||
"noSharedExams": "Nog geen gedeelde examens",
|
||||
"noSharedExamsDesc": "Examens die anderen met je delen, verschijnen hier wanneer je ze opslaat in je werkruimte.",
|
||||
"shareMode": "Deelmodus",
|
||||
"shareModeClone": "Kloonbaar",
|
||||
"shareModeView": "Alleen bekijken",
|
||||
"importFromUrl": "Importeren vanaf URL",
|
||||
"importUrlPlaceholder": "Plak een ruwe JSON-URL…",
|
||||
"import": "Importeren",
|
||||
"importing": "Importeren…",
|
||||
"viewAllResults": "Alle resultaten bekijken",
|
||||
"recentResults": "Recente Resultaten",
|
||||
"downloadInstructions": "Download Instructies",
|
||||
"viewAll": "Alles bekijken",
|
||||
"noAttempts": "Nog geen pogingen",
|
||||
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
|
||||
"attempts": "poging",
|
||||
@@ -211,7 +252,27 @@
|
||||
"deleteExamConfirm": "Dit examen verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"deleteResultConfirm": "Dit resultaat verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"storageWarningHigh": "Je gebruikt meer dan 80% van je opslag.",
|
||||
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken."
|
||||
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken.",
|
||||
"searchExams": "Zoek examens…",
|
||||
"noSearchResults": "Geen examens gevonden",
|
||||
"noSearchResultsDesc": "Probeer een ander trefwoord of wis de zoekopdracht.",
|
||||
"pin": "Vastpinnen",
|
||||
"unpin": "Losmaken",
|
||||
"duplicate": "Dupliceren",
|
||||
"export": "Exporteer JSON",
|
||||
"collections": "Collecties",
|
||||
"newCollection": "Nieuwe Collectie",
|
||||
"allExams": "Alle Examens",
|
||||
"uncategorized": "Niet-gecategoriseerd",
|
||||
"moveTo": "Verplaats naar",
|
||||
"removeFromCollection": "Verwijder uit collectie",
|
||||
"collectionNamePlaceholder": "Collectienaam…",
|
||||
"create": "Aanmaken",
|
||||
"rename": "Hernoemen",
|
||||
"deleteCollectionConfirm": "Deze collectie verwijderen? Examens worden niet-gecategoriseerd.",
|
||||
"batchSelectAll": "Alles selecteren",
|
||||
"batchSelected": "{{count}} geselecteerd",
|
||||
"batchDeleteConfirm": "Geselecteerde examens verwijderen? Dit kan niet ongedaan worden gemaakt."
|
||||
},
|
||||
"admin": {
|
||||
"aiGrading": {
|
||||
@@ -322,6 +383,23 @@
|
||||
"essay": "Opstel",
|
||||
"match": "Koppelen",
|
||||
"order": "Rangschikken",
|
||||
"fillInTheBlanks": "Invulvragen",
|
||||
"trueFalse": "Waar / Onwaar",
|
||||
"codeSubmission": "Code Inzending",
|
||||
"dropdownCompletion": "Dropdown Vervolledigen",
|
||||
"numericRange": "Numeriek Bereik",
|
||||
"dictation": "Dictee",
|
||||
"grammarCorrection": "Grammaticacorrectie",
|
||||
"flashcard": "Flashcard",
|
||||
"textToCorrect": "Tekst te corrigeren",
|
||||
"textToCorrectPlaceholder": "Voer de grammaticaal incorrecte zin in…",
|
||||
"playbackSpeed": "Snelheid",
|
||||
"revealAnswer": "Toon antwoord",
|
||||
"didntKnow": "Wist ik niet",
|
||||
"hard": "Moeilijk",
|
||||
"good": "Goed",
|
||||
"flashcardFront": "Prompt (voorkant kaart)",
|
||||
"flashcardFrontPlaceholder": "Voer de prompt in…",
|
||||
"correctAnswer": "Correct antwoord",
|
||||
"correctAnswerPlaceholder": "Voer het correcte antwoord in",
|
||||
"acceptedForms": "Geaccepteerde vormen (optionele alternatieven)",
|
||||
@@ -345,6 +423,9 @@
|
||||
"correctOrder": "Correcte volgorde",
|
||||
"correctOrderHint": "De correcte volgorde is de volgorde hierboven. Rangschik de items om de juiste volgorde in te stellen.",
|
||||
"setCurrentOrder": "Huidige volgorde instellen als correct",
|
||||
"tagsLabel": "Labels",
|
||||
"tagsPlaceholder": "Label toevoegen…",
|
||||
"addTag": "Toevoegen",
|
||||
"examTitleRequired": "Examennaam is verplicht",
|
||||
"atLeastOneQuestion": "Voeg minimaal één vraag toe",
|
||||
"sourceLanguage": "Brontaal",
|
||||
@@ -355,7 +436,15 @@
|
||||
"choicePlaceholder": "Keuze {{id}}",
|
||||
"addItem": "+ Item toevoegen",
|
||||
"leftItemPlaceholder": "Link item {{index}}",
|
||||
"rightItemPlaceholder": "Recht item {{index}}"
|
||||
"rightItemPlaceholder": "Recht item {{index}}",
|
||||
"aiGenerate": "✨ Genereer met AI",
|
||||
"aiClose": "✕ AI sluiten",
|
||||
"aiPanelTitle": "Vragen genereren",
|
||||
"aiTopicPlaceholder": "Onderwerp, bijv. \"Stelling van Pythagoras\"",
|
||||
"aiContextPlaceholder": "Optionele aantekeningen of bronmateriaal om de vragen op te baseren…",
|
||||
"aiGenerateNow": "Genereer",
|
||||
"aiGenerating": "Bezig met genereren…",
|
||||
"aiTopicRequired": "Voer een onderwerp in"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Welkom bij BrainWave",
|
||||
@@ -397,6 +486,15 @@
|
||||
"backToHome": "Terug naar home",
|
||||
"noToken": "Geen verificatietoken gevonden."
|
||||
},
|
||||
"share": {
|
||||
"share": "Delen",
|
||||
"shared": "Gedeeld",
|
||||
"generateLink": "Deellink genereren",
|
||||
"copyLink": "Link kopiëren",
|
||||
"revokeLink": "Link intrekken",
|
||||
"takeExam": "Dit examen maken",
|
||||
"saveToWorkspace": "Opslaan in mijn werkruimte"
|
||||
},
|
||||
"resetPassword": {
|
||||
"resetPassword": "Wachtwoord resetten",
|
||||
"enterEmail": "Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.",
|
||||
|
||||
@@ -993,6 +993,185 @@ select {
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- True / False ---------- */
|
||||
.tf-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.tf-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
text-align: center;
|
||||
}
|
||||
.tf-btn:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.tf-btn.selected {
|
||||
border-color: var(--ink);
|
||||
background: var(--paper-2);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tf-btn.correct {
|
||||
border-color: var(--correct);
|
||||
background: var(--correct-weak);
|
||||
}
|
||||
.tf-btn.wrong {
|
||||
border-color: var(--wrong);
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- Cloze (Fill-in-the-blanks & Dropdown) ---------- */
|
||||
.cloze-text {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
line-height: 2.2;
|
||||
color: var(--ink);
|
||||
}
|
||||
.cloze-blank {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
width 120ms ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cloze-blank:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.cloze-blank.correct {
|
||||
border-color: var(--correct);
|
||||
background: var(--correct-weak);
|
||||
}
|
||||
.cloze-blank.wrong {
|
||||
border-color: var(--wrong);
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
.cloze-dropdown {
|
||||
display: inline-block;
|
||||
padding: 5px 28px 5px 12px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
vertical-align: middle;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
}
|
||||
.cloze-dropdown:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.cloze-dropdown.correct {
|
||||
border-color: var(--correct);
|
||||
background-color: var(--correct-weak);
|
||||
}
|
||||
.cloze-dropdown.wrong {
|
||||
border-color: var(--wrong);
|
||||
background-color: var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- Numeric Range ---------- */
|
||||
.numeric-input {
|
||||
max-width: 200px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
}
|
||||
.numeric-input.correct {
|
||||
border-color: var(--correct);
|
||||
background: var(--correct-weak);
|
||||
box-shadow: 0 0 0 3px var(--correct-weak);
|
||||
}
|
||||
.numeric-input.wrong {
|
||||
border-color: var(--wrong);
|
||||
background: var(--wrong-weak);
|
||||
box-shadow: 0 0 0 3px var(--wrong-weak);
|
||||
}
|
||||
|
||||
/* ---------- Code Submission ---------- */
|
||||
.code-submission-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-submission-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.code-lang-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--paper-3);
|
||||
color: var(--ink-3);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.code-submission-area {
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 180px;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
tab-size: 4;
|
||||
}
|
||||
.code-submission-area:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.code-submission-hint {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Short / Math input ---------- */
|
||||
.answer-input-wrap {
|
||||
display: flex;
|
||||
@@ -2350,6 +2529,25 @@ select {
|
||||
top: 57px;
|
||||
height: calc(100vh - 57px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.workspace-sidebar-footer .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.workspace-sidebar-footer .btn:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
|
||||
.workspace-profile {
|
||||
@@ -2532,9 +2730,107 @@ select {
|
||||
|
||||
.workspace-delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Overflow (⋯) menu trigger */
|
||||
.workspace-menu-trigger {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 4px 10px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* Click-away backdrop for the overflow menu */
|
||||
.workspace-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Overflow menu panel */
|
||||
.workspace-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 10;
|
||||
min-width: 220px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 6px;
|
||||
overflow: hidden;
|
||||
animation: workspace-menu-in 120ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes workspace-menu-in {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.workspace-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
transition: background 80ms ease, color 80ms ease;
|
||||
}
|
||||
|
||||
.workspace-menu-item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.workspace-menu-icon {
|
||||
width: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-menu-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--ink-4);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workspace-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 4px 6px;
|
||||
}
|
||||
|
||||
.workspace-menu-item-danger {
|
||||
color: var(--wrong);
|
||||
}
|
||||
.workspace-menu-item-danger .workspace-menu-icon {
|
||||
color: var(--wrong);
|
||||
}
|
||||
.workspace-menu-item-danger:hover {
|
||||
background: var(--wrong-weak);
|
||||
color: var(--wrong);
|
||||
}
|
||||
|
||||
.workspace-menu-back {
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.workspace-empty {
|
||||
text-align: center;
|
||||
@@ -3117,3 +3413,156 @@ select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Dictation ---------- */
|
||||
.dictation-audio-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 18px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dictation-play-btn {
|
||||
min-width: 90px;
|
||||
justify-content: center;
|
||||
}
|
||||
.dictation-play-btn.playing {
|
||||
background: var(--accent-weak);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.dictation-speed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dictation-speed-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dictation-speed-btn.active {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Grammar Correction ---------- */
|
||||
.grammar-text-wrap {
|
||||
padding: 14px 18px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grammar-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.grammar-sentence {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---------- Flashcard ---------- */
|
||||
.flashcard-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.flashcard-prompt {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink);
|
||||
padding: 20px 24px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
text-align: center;
|
||||
}
|
||||
.flashcard-reveal-btn {
|
||||
align-self: center;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.flashcard-answer {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
color: var(--accent-ink);
|
||||
padding: 16px 20px;
|
||||
background: var(--accent-weak);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r-md);
|
||||
text-align: center;
|
||||
}
|
||||
.flashcard-explanation {
|
||||
font-size: 13.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
padding: 12px 16px;
|
||||
background: var(--paper-2);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.flashcard-grade {
|
||||
padding: 14px 18px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.flashcard-grade-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.flashcard-grade-wrong {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.flashcard-grade-wrong:hover {
|
||||
background: var(--wrong-weak);
|
||||
border-color: var(--wrong);
|
||||
color: var(--wrong);
|
||||
}
|
||||
.flashcard-grade-hard {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.flashcard-grade-hard:hover {
|
||||
background: var(--warn-weak);
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.flashcard-grade-good {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.flashcard-grade-good:hover {
|
||||
background: var(--correct-weak);
|
||||
border-color: var(--correct);
|
||||
color: var(--correct);
|
||||
}
|
||||
.flashcard-grade-result {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
.flashcard-grade-result.correct {
|
||||
background: var(--correct-weak);
|
||||
color: var(--correct);
|
||||
border: 1px solid var(--correct);
|
||||
}
|
||||
.flashcard-grade-result.wrong {
|
||||
background: var(--wrong-weak);
|
||||
color: var(--wrong);
|
||||
border: 1px solid var(--wrong);
|
||||
}
|
||||
+43
-2
@@ -8,7 +8,42 @@ export type QuestionType =
|
||||
| "math-input"
|
||||
| "essay"
|
||||
| "match"
|
||||
| "order";
|
||||
| "order"
|
||||
| "fill-in-the-blanks"
|
||||
| "true-false"
|
||||
| "code-submission"
|
||||
| "dropdown-completion"
|
||||
| "numeric-range"
|
||||
| "dictation"
|
||||
| "grammar-correction"
|
||||
| "flashcard";
|
||||
|
||||
export interface Blank {
|
||||
id: string;
|
||||
acceptedAnswers: string[];
|
||||
acceptedForms?: string[];
|
||||
caseSensitive?: boolean;
|
||||
ignorePunctuation?: boolean;
|
||||
ignoreAccents?: boolean;
|
||||
}
|
||||
|
||||
export interface Dropdown {
|
||||
id: string;
|
||||
options: Choice[];
|
||||
correctOptionId: string;
|
||||
}
|
||||
|
||||
export interface CodeSubmissionConfig {
|
||||
language: string;
|
||||
starterCode?: string;
|
||||
}
|
||||
|
||||
export interface NumericConfig {
|
||||
answer: number;
|
||||
tolerance: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
id: string;
|
||||
@@ -52,7 +87,7 @@ export interface Question {
|
||||
code?: QuestionCode;
|
||||
table?: QuestionTable;
|
||||
svg?: string;
|
||||
answer?: string | string[] | Record<string, string>;
|
||||
answer?: string | string[] | Record<string, string> | boolean | number;
|
||||
acceptedForms?: string[];
|
||||
caseSensitive?: boolean;
|
||||
ignorePunctuation?: boolean;
|
||||
@@ -65,6 +100,12 @@ export interface Question {
|
||||
left?: MatchItem[];
|
||||
right?: MatchItem[];
|
||||
items?: MatchItem[];
|
||||
textSegments?: string[];
|
||||
blanks?: Blank[];
|
||||
dropdowns?: Dropdown[];
|
||||
codeConfig?: CodeSubmissionConfig;
|
||||
numericConfig?: NumericConfig;
|
||||
textToCorrect?: string;
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ApiError } from './api'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { apiFetch, ApiError } from './api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('extends Error', () => {
|
||||
@@ -18,3 +22,19 @@ describe('ApiError', () => {
|
||||
expect(err.name).toBe('ApiError')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('uses message from error responses', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: vi.fn().mockResolvedValue({ message: 'Please verify your email before logging in' }),
|
||||
}))
|
||||
|
||||
await expect(apiFetch('/api/auth/login')).rejects.toMatchObject({
|
||||
status: 403,
|
||||
message: 'Please verify your email before logging in',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,8 +16,25 @@ export function clearToken(): void {
|
||||
localStorage.removeItem('brainwave.auth.token')
|
||||
}
|
||||
|
||||
function isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1] ?? ''))
|
||||
const exp = payload.exp
|
||||
if (typeof exp !== 'number') return false
|
||||
return Date.now() >= exp * 1000
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
if (isTokenExpired(token)) {
|
||||
clearToken()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
@@ -40,7 +57,8 @@ export async function apiFetch<T = unknown>(
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new ApiError(res.status, (body as { error?: string }).error ?? 'Request failed')
|
||||
const parsedBody = body as { error?: string; message?: string }
|
||||
throw new ApiError(res.status, parsedBody.error ?? parsedBody.message ?? 'Request failed')
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Question, GradeResult } from '../types'
|
||||
import type { Question, GradeResult, Blank } from '../types'
|
||||
|
||||
interface NormalizeOpts {
|
||||
caseSensitive?: boolean
|
||||
@@ -6,6 +6,12 @@ interface NormalizeOpts {
|
||||
ignorePunctuation?: boolean
|
||||
}
|
||||
|
||||
const blankOpts = (b: Blank): NormalizeOpts => ({
|
||||
caseSensitive: b.caseSensitive === true,
|
||||
ignorePunctuation: b.ignorePunctuation !== false,
|
||||
ignoreAccents: b.ignoreAccents !== false,
|
||||
})
|
||||
|
||||
export const normalizeString = (s: unknown, opts: NormalizeOpts = {}): string => {
|
||||
if (s == null) return ''
|
||||
let out = String(s)
|
||||
@@ -100,6 +106,73 @@ export const gradeQuestion = (q: Question, userAnswer: unknown): GradeResult =>
|
||||
}
|
||||
return { status: 'ungraded', correct: false, points: 0 }
|
||||
}
|
||||
case 'true-false': {
|
||||
const ok = userAnswer === q.answer
|
||||
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
|
||||
}
|
||||
case 'numeric-range': {
|
||||
const cfg = q.numericConfig
|
||||
if (!cfg) return { status: 'unanswered', correct: false, points: 0 }
|
||||
const num = Number(userAnswer)
|
||||
if (Number.isNaN(num)) return { status: 'wrong', correct: false, points: 0 }
|
||||
const ok = Math.abs(num - cfg.answer) <= cfg.tolerance
|
||||
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
|
||||
}
|
||||
case 'dropdown-completion': {
|
||||
const correct = (q.dropdowns ?? []).reduce<Record<string, string>>((acc, d) => {
|
||||
acc[d.id] = d.correctOptionId
|
||||
return acc
|
||||
}, {})
|
||||
const given = (userAnswer as Record<string, string>) ?? {}
|
||||
const keys = Object.keys(correct)
|
||||
if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 }
|
||||
const allOk = keys.every((k) => given[k] === correct[k])
|
||||
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 }
|
||||
}
|
||||
case 'fill-in-the-blanks': {
|
||||
const blanks = q.blanks ?? []
|
||||
if (blanks.length === 0) return { status: 'unanswered', correct: false, points: 0 }
|
||||
const given = (userAnswer as Record<string, string>) ?? {}
|
||||
let allOk = true
|
||||
for (const b of blanks) {
|
||||
const target = normalizeString(given[b.id], blankOpts(b))
|
||||
const accepted = [...b.acceptedAnswers, ...(b.acceptedForms ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((a) => normalizeString(a, blankOpts(b)))
|
||||
if (!accepted.includes(target)) allOk = false
|
||||
}
|
||||
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 }
|
||||
}
|
||||
case 'code-submission': {
|
||||
const val = userAnswer as { code?: string; selfGrade?: number }
|
||||
if (val?.selfGrade != null) {
|
||||
const g = val.selfGrade
|
||||
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) }
|
||||
}
|
||||
return { status: 'ungraded', correct: false, points: 0 }
|
||||
}
|
||||
case 'dictation':
|
||||
case 'grammar-correction': {
|
||||
const opts: NormalizeOpts = {
|
||||
caseSensitive: q.caseSensitive === true,
|
||||
ignorePunctuation: q.ignorePunctuation !== false,
|
||||
ignoreAccents: q.ignoreAccents !== false,
|
||||
}
|
||||
const target = normalizeString(userAnswer, opts)
|
||||
const accepted = [q.answer as string, ...(q.acceptedForms ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((a) => normalizeString(a, opts))
|
||||
const ok = accepted.includes(target)
|
||||
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 }
|
||||
}
|
||||
case 'flashcard': {
|
||||
const val = userAnswer as { selfGrade?: number }
|
||||
if (val?.selfGrade != null) {
|
||||
const g = val.selfGrade
|
||||
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) }
|
||||
}
|
||||
return { status: 'ungraded', correct: false, points: 0 }
|
||||
}
|
||||
default:
|
||||
return { status: 'unanswered', correct: false, points: 0 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
export function 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"]
|
||||
}
|
||||
|
||||
9. FILL-IN-THE-BLANKS ("fill-in-the-blanks")
|
||||
Student completes blanks embedded in a text passage.
|
||||
|
||||
Required fields:
|
||||
"textSegments": ["Before blank", " between blanks", " after blank"] — Array of text strings to interleave with blanks. Must be blanks.length + 1.
|
||||
"blanks": [
|
||||
{ "id": "b1", "acceptedAnswers": ["correct word", "alt word"] },
|
||||
...
|
||||
]
|
||||
|
||||
Optional per-blank fields:
|
||||
"acceptedForms": ["alt1"] — Additional accepted forms
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
"ignoreAccents": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q9",
|
||||
"type": "fill-in-the-blanks",
|
||||
"prompt": "Fill in the missing words.",
|
||||
"textSegments": ["The capital of France is ", ", and the capital of Japan is ", "."],
|
||||
"blanks": [
|
||||
{ "id": "b1", "acceptedAnswers": ["Paris"], "caseSensitive": false },
|
||||
{ "id": "b2", "acceptedAnswers": ["Tokyo"], "caseSensitive": false }
|
||||
]
|
||||
}
|
||||
|
||||
10. TRUE / FALSE ("true-false")
|
||||
Student answers a statement with true or false.
|
||||
|
||||
Required fields:
|
||||
"answer": true or false
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q10",
|
||||
"type": "true-false",
|
||||
"prompt": "The Earth is the third planet from the Sun.",
|
||||
"answer": true
|
||||
}
|
||||
|
||||
11. CODE SUBMISSION ("code-submission")
|
||||
Student writes code in a specific programming language.
|
||||
|
||||
Required fields:
|
||||
"codeConfig": { "language": "python" } — Programming language identifier
|
||||
|
||||
Optional fields:
|
||||
"codeConfig": { "language": "python", "starterCode": "def hello():\\n pass" } — Initial code shown in editor
|
||||
"rubric": ["criterion 1", "criterion 2"] — Criteria for grading
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q11",
|
||||
"type": "code-submission",
|
||||
"prompt": "Write a function that returns the factorial of n.",
|
||||
"points": 5,
|
||||
"codeConfig": { "language": "python", "starterCode": "def factorial(n):\\n pass" },
|
||||
"rubric": [
|
||||
"Handles base case (0! = 1)",
|
||||
"Uses recursion or iteration correctly",
|
||||
"Returns correct result for positive integers"
|
||||
]
|
||||
}
|
||||
|
||||
12. DROPDOWN COMPLETION ("dropdown-completion")
|
||||
Student selects from dropdown menus embedded in a text passage.
|
||||
|
||||
Required fields:
|
||||
"textSegments": ["Before dropdown", " between dropdowns", " after dropdown"] — Must be dropdowns.length + 1.
|
||||
"dropdowns": [
|
||||
{
|
||||
"id": "d1",
|
||||
"options": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }],
|
||||
"correctOptionId": "a"
|
||||
}
|
||||
]
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q12",
|
||||
"type": "dropdown-completion",
|
||||
"prompt": "Choose the correct words.",
|
||||
"textSegments": ["Water boils at ", " degrees Celsius at sea level, and freezes at ", "."],
|
||||
"dropdowns": [
|
||||
{
|
||||
"id": "d1",
|
||||
"options": [
|
||||
{ "id": "a", "text": "100" },
|
||||
{ "id": "b", "text": "90" },
|
||||
{ "id": "c", "text": "110" }
|
||||
],
|
||||
"correctOptionId": "a"
|
||||
},
|
||||
{
|
||||
"id": "d2",
|
||||
"options": [
|
||||
{ "id": "a", "text": "0" },
|
||||
{ "id": "b", "text": "-10" },
|
||||
{ "id": "c", "text": "10" }
|
||||
],
|
||||
"correctOptionId": "a"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
13. NUMERIC RANGE ("numeric-range")
|
||||
Student enters a numeric answer within an acceptable tolerance.
|
||||
|
||||
Required fields:
|
||||
"numericConfig": { "answer": 42, "tolerance": 0.5 } — Correct value and allowed deviation
|
||||
|
||||
Optional fields:
|
||||
"numericConfig": { "answer": 42, "tolerance": 0.5, "min": 0, "max": 100 } — Bounds for validation
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q13",
|
||||
"type": "numeric-range",
|
||||
"prompt": "What is the approximate speed of sound in air at 20°C (in m/s)?",
|
||||
"points": 2,
|
||||
"numericConfig": { "answer": 343, "tolerance": 5, "min": 300, "max": 400 }
|
||||
}
|
||||
|
||||
14. DICTATION ("dictation")
|
||||
Student transcribes audio they listen to.
|
||||
|
||||
Required fields:
|
||||
"audio": { "src": "https://example.com/audio.mp3" } — URL to the audio file
|
||||
"answer": "string" — Correct transcription
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt transcription 1"] — Other accepted forms
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
"ignoreAccents": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q14",
|
||||
"type": "dictation",
|
||||
"prompt": "Listen to the audio and type what you hear.",
|
||||
"audio": { "src": "https://example.com/dictation.mp3" },
|
||||
"answer": "The quick brown fox jumps over the lazy dog.",
|
||||
"caseSensitive": false,
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
|
||||
15. GRAMMAR CORRECTION ("grammar-correction")
|
||||
Student corrects errors in a provided sentence.
|
||||
|
||||
Required fields:
|
||||
"textToCorrect": "string" — The sentence containing errors
|
||||
"answer": "string" — The corrected sentence
|
||||
|
||||
Optional fields:
|
||||
"acceptedForms": ["alt correction 1"] — Other accepted corrected forms
|
||||
"caseSensitive": true|false (default: false)
|
||||
"ignorePunctuation": true|false (default: false)
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q15",
|
||||
"type": "grammar-correction",
|
||||
"prompt": "Correct the grammar in the following sentence.",
|
||||
"textToCorrect": "She don't like apples.",
|
||||
"answer": "She doesn't like apples.",
|
||||
"caseSensitive": false
|
||||
}
|
||||
|
||||
16. FLASHCARD ("flashcard")
|
||||
Self-study card with front and back. Not graded — student self-assesses.
|
||||
|
||||
Required fields:
|
||||
"answer": "string" — The back of the card (translation, definition, etc.)
|
||||
|
||||
Optional fields:
|
||||
"explanation": "string" — Extra info shown after reveal
|
||||
|
||||
Example:
|
||||
{
|
||||
"id": "q16",
|
||||
"type": "flashcard",
|
||||
"prompt": "Bonjour",
|
||||
"answer": "Hello (French)",
|
||||
"explanation": "Informal greeting used throughout the day."
|
||||
}
|
||||
|
||||
────────────────────────────────────
|
||||
LANGUAGE GUIDELINES
|
||||
────────────────────────────────────
|
||||
When writing user-facing text (prompts, choices, explanations, rubric items, hints):
|
||||
- Write ALL user-facing text in the language the user is asking for (their native/request language).
|
||||
- Do NOT default to the subject language for explanations, prompts, or rubric items.
|
||||
|
||||
Examples:
|
||||
- If the user asks for a "French vocabulary test" in English, write prompts and explanations in ENGLISH. Only the target vocabulary (the word being tested) should be in French.
|
||||
- If the user asks for a "biology exam" in Dutch, write everything in DUTCH, even if the source material is in English.
|
||||
- Explanations should always be in the user's request language so they can understand why an answer is correct or incorrect.
|
||||
|
||||
────────────────────────────────────
|
||||
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
|
||||
7. The language you want all user-facing text written in
|
||||
|
||||
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. Write everything in English and include explanations
|
||||
for each answer."
|
||||
|
||||
────────────────────────────────────
|
||||
CLARIFYING QUESTIONS
|
||||
────────────────────────────────────
|
||||
Before generating the exam JSON, you MAY ask 1–3 short clarifying questions if
|
||||
anything is ambiguous or would significantly improve the quality of the output.
|
||||
Only ask questions when the user's request is genuinely unclear — do not delay
|
||||
trivial requests.
|
||||
|
||||
Good clarifying questions include:
|
||||
- "What is the target difficulty level (beginner, intermediate, advanced)?"
|
||||
- "Who is the intended audience (age group, class level, professional field)?"
|
||||
- "Should explanations be in [user's language] even if the subject is a foreign language?"
|
||||
- "Do you want a mix of question types, or focus on one type?"
|
||||
- "Should I include a time limit or passing score?"
|
||||
|
||||
Once the user answers, generate the full exam JSON in a single response.
|
||||
`
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import katex from 'katex'
|
||||
import { escapeHtml } from './latex'
|
||||
|
||||
marked.use({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
'p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a',
|
||||
'span', 'div', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr',
|
||||
'sub', 'sup', 'del', 's', 'ins',
|
||||
]
|
||||
|
||||
const ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class', 'style']
|
||||
|
||||
/**
|
||||
* Render markdown text to HTML, preserving LaTeX math expressions.
|
||||
* Math ($inline$ and $$block$$) is extracted before markdown parsing
|
||||
* and rendered with KaTeX afterwards. The final HTML is sanitized
|
||||
* with DOMPurify.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
let rest = String(text)
|
||||
const mathPlaceholders: { type: 'block' | 'inline'; tex: string }[] = []
|
||||
|
||||
// Extract block math $$...$$
|
||||
rest = rest.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex: string) => {
|
||||
const idx = mathPlaceholders.length
|
||||
mathPlaceholders.push({ type: 'block', tex })
|
||||
return `\x00MATH${idx}\x00`
|
||||
})
|
||||
|
||||
// Extract inline math $...$
|
||||
rest = rest.replace(/\$([^$\n]+?)\$/g, (_, tex: string) => {
|
||||
const idx = mathPlaceholders.length
|
||||
mathPlaceholders.push({ type: 'inline', tex })
|
||||
return `\x00MATH${idx}\x00`
|
||||
})
|
||||
|
||||
// Render markdown (synchronous)
|
||||
const mdHtml = marked.parse(rest, { async: false }) as string
|
||||
|
||||
// Replace placeholders with rendered math
|
||||
let result = mdHtml.replace(/\x00MATH(\d+)\x00/g, (_, i) => {
|
||||
const { type, tex } = mathPlaceholders[Number(i)]
|
||||
try {
|
||||
const opts = { displayMode: type === 'block', throwOnError: false }
|
||||
return katex.renderToString(tex, opts)
|
||||
} catch {
|
||||
return `<code>${escapeHtml(tex)}</code>`
|
||||
}
|
||||
})
|
||||
|
||||
// Sanitize the final HTML
|
||||
return DOMPurify.sanitize(result, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.442099999999982,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.80680000000001,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":12.700600000000009,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.035500000000013,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":5.9454000000000065,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.9056999999999675,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":7.014700000000005,"failed":false}],[":backend/dist/config.test.js",{"duration":4.3396999999999935,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.116100000000017,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":11.073699999999974,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":6.721499999999992,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0815000000000055,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.222700000000032,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":166.78979999999999,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.239000000000004,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":118.4222,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.551799999999986,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.303799999999995,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.920199999999994,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":6.263800000000003,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.775499999999994,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.524900000000002,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.508899999999983,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.431699999999978,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.2516999999999996,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":10.521900000000016,"failed":false}]]}
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":9.265338999999983,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/validators/authSchemas.test.ts",{"duration":22.575676000000044,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":9.748771000000033,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/storage.test.ts",{"duration":6.310062000000016,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/config.test.js",{"duration":0,"failed":true}],[":backend/src/validators/adminSchemas.test.ts",{"duration":33.639384000000064,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/adminSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/config.test.ts",{"duration":14.69092599999999,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":33.61004700000001,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/validation.test.ts",{"duration":11.680594999999983,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":44.34940499999999,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.085611999999969,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.737424999999973,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":28.528040000000033,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/middleware/error.test.js",{"duration":0,"failed":true}],[":backend/dist/utils/asyncHandler.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/formatting.test.ts",{"duration":4.583577999999989,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":8.906761999999958,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":9.23972999999998,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":23.65549999999996,"failed":false}],[":backend/dist/repositories/storageRepo.test.js",{"duration":0,"failed":true}],[":backend/src/validators/gradeSchemas.test.ts",{"duration":16.57176800000002,"failed":false}],[":backend/src/utils/aiQuota.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/collectionSchemas.test.ts",{"duration":15.043968000000007,"failed":false}],[":backend/src/validators/aiSchemas.test.ts",{"duration":14.30532999999997,"failed":false}],[":backend/src/services/authService.test.ts",{"duration":0,"failed":true}],[":backend/src/middleware/auth.test.ts",{"duration":0,"failed":true}]]}
|
||||
Generated
+15
@@ -7,10 +7,25 @@
|
||||
"": {
|
||||
"name": "brainwave",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"marked": "^18.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"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/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.3"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user