feat: add collections management and study coach features
Test / backend (push) Failing after 13s
Test / frontend (push) Successful in 25s

- Implemented collection repository with CRUD operations for user collections.
- Created routes for managing collections, including listing, creating, updating, and deleting collections.
- Added sharing functionality for exams, allowing users to share and clone exams.
- Developed a study coach service that analyzes exam results and provides study tips based on weak areas.
- Introduced frontend components for managing collections, sharing exams, and displaying study coach insights.
- Enhanced user experience with loading states and error handling in shared components.
This commit is contained in:
LaurensBartelds committed 2026-05-02 16:55:08 +02:00
1 parent 7755e77039
commit 1ec1633dd9
26 files changed
+2557 -127

No files matched your search

+59 -8
View File
@@ -53,6 +53,19 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(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) ──
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
name: string;
@@ -61,15 +74,53 @@ 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'`);
}
// ── Add is_favorite to saved_exams if missing ──
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_favorite INTEGER DEFAULT 0`);
}
// ── Add tags to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'tags')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN tags TEXT DEFAULT '[]'`);
}
// ── Add collection_id to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'collection_id')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL`);
}
// ── Add share_mode to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'share_mode')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_mode TEXT DEFAULT 'clone'`);
}
// ── Add cloned_from_share_token to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'cloned_from_share_token')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN cloned_from_share_token TEXT`);
}
// ── Collections table ──
db.exec(`
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
`);
// ── Promote first user to admin if no admins exist ──
const adminCount = db
+4
View File
@@ -10,6 +10,8 @@ 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 { errorHandler } from './middleware/error.js'
import { config } from './config.js'
import path from 'path'
@@ -63,6 +65,8 @@ app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes)
app.use('/api/grade', gradeRoutes)
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,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
}
+128 -7
View File
@@ -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
}
+59
View File
@@ -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
}>
}
+97
View File
@@ -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
View File
@@ -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
+48
View File
@@ -4,7 +4,9 @@ import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as resultRepo from '../repositories/resultRepo.js'
import * as examRepo from '../repositories/examRepo.js'
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
import { analyzeResults } from '../services/studyCoach.js'
import type { Request } from 'express'
const router = Router()
@@ -78,4 +80,50 @@ router.delete(
})
)
// GET /api/results/analyze
router.get(
'/analyze',
asyncHandler(async (req: Request, res) => {
const rawResults = resultRepo.getResultsWithExamJson(req.user!.userId, 200)
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
const analysis = await analyzeResults(rawResults, lang)
// Find weakest exam (lowest score that still exists in workspace)
let weakestExamId: number | null = null
if (rawResults.length > 0) {
const sorted = [...rawResults].sort((a, b) => {
const pa = a.total_points > 0 ? a.score_points / a.total_points : 0
const pb = b.total_points > 0 ? b.score_points / b.total_points : 0
return pa - pb
})
for (const r of sorted) {
if (r.exam_id) {
const exam = examRepo.getExamById(r.exam_id, req.user!.userId)
if (exam) {
weakestExamId = r.exam_id
break
}
}
}
}
res.json({ ...analysis, weakestExamId })
})
)
// GET /api/results/trends
router.get(
'/trends',
asyncHandler(async (req: Request, res) => {
const raw = resultRepo.getScoreTrends(req.user!.userId, 30)
const trends = raw.map((r) => ({
completed_at: r.completed_at,
score_pct: r.total_points > 0 ? Math.round((r.score_points / r.total_points) * 100) : 0,
title: r.title,
exam_id: r.exam_id,
}))
res.json(trends)
})
)
export default router
+45
View File
@@ -0,0 +1,45 @@
import { Router } from 'express'
import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js'
import { requireAuth } from '../middleware/auth.js'
import * as examRepo from '../repositories/examRepo.js'
import type { Request } from 'express'
const router = Router()
// GET /api/share/:token — public, no auth required
router.get(
'/:token',
asyncHandler(async (req: Request, res) => {
const token = String(req.params.token)
const exam = examRepo.getExamByShareToken(token)
if (!exam) {
throw new AppError(404, 'Shared exam not found')
}
const parsed = JSON.parse(exam.exam_json)
res.json({
title: exam.title,
subtitle: exam.subtitle,
questionCount: JSON.parse(exam.exam_json).questions?.length ?? 0,
exam: parsed,
shareMode: exam.share_mode ?? 'clone',
})
})
)
// POST /api/share/:token/clone — requires auth
router.post(
'/:token/clone',
requireAuth,
asyncHandler(async (req: Request, res) => {
const token = String(req.params.token)
const source = examRepo.getExamByShareToken(token)
if (!source) {
throw new AppError(404, 'Shared exam not found')
}
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
res.status(201).json({ id, title: source.title + ' (copy)' })
})
)
export default router
+165
View File
@@ -0,0 +1,165 @@
import OpenAI from 'openai'
import { getSetting } from '../repositories/settingsRepo.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 getClient(): OpenAI | null {
const provider = getSetting('ai_provider')
const apiKey = getSetting('ai_api_key')
if (!provider || !apiKey) return null
const customBase = getSetting('ai_base_url')
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
return new OpenAI({ apiKey, baseURL })
}
function calculateStreak(results: Array<{ completed_at: number }>): number {
if (results.length === 0) return 0
const days = new Set<string>()
for (const r of results) {
const d = new Date(r.completed_at * 1000)
days.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
const sorted = Array.from(days).sort()
if (sorted.length === 0) return 0
const today = new Date()
const todayStr = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
let streak = 0
for (let i = sorted.length - 1; i >= 0; i--) {
const expected = new Date(today)
expected.setDate(today.getDate() - (sorted.length - 1 - i))
const expectedStr = `${expected.getFullYear()}-${expected.getMonth()}-${expected.getDate()}`
if (sorted[i] === expectedStr || (i === sorted.length - 1 && sorted[i] === todayStr)) {
streak++
} else if (i === sorted.length - 1) {
// Most recent is not today — check if it was yesterday
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
const yestStr = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`
if (sorted[i] === yestStr) {
streak++
} else {
break
}
} else {
break
}
}
return streak
}
export async function analyzeResults(
results: Array<{
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
completed_at: number
exam_json: string
}>,
language = 'en',
): Promise<StudyCoachResult> {
const streakDays = calculateStreak(results)
// Build per-category stats from exam_json
const categoryStats: Record<string, { score: number; total: number; attempts: number }> = {}
for (const r of results) {
let exam: { questions?: Array<{ category?: string; points?: number }> } | undefined
try {
exam = JSON.parse(r.exam_json)
} catch {
continue
}
const questions = exam?.questions ?? []
// We don't have per-question results in the stored result, so we approximate
// using the overall score distributed across categories proportionally
const totalQuestionPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0)
if (totalQuestionPoints === 0) continue
const overallRatio = r.total_points > 0 ? r.score_points / r.total_points : 0
for (const q of questions) {
const cat = q.category ?? 'Uncategorized'
const pts = q.points ?? 1
if (!categoryStats[cat]) {
categoryStats[cat] = { score: 0, total: 0, attempts: 0 }
}
categoryStats[cat].score += pts * overallRatio
categoryStats[cat].total += pts
}
// Mark attempt for each unique category in this exam
const catsInExam = new Set(questions.map((q) => q.category ?? 'Uncategorized'))
for (const cat of catsInExam) {
categoryStats[cat].attempts++
}
}
const weakAreas: WeakArea[] = Object.entries(categoryStats)
.map(([category, stats]) => ({
category,
avgScore: stats.total > 0 ? Math.round((stats.score / stats.total) * 100) : 0,
attempts: stats.attempts,
}))
.filter((w) => w.attempts >= 1)
.sort((a, b) => a.avgScore - b.avgScore)
.slice(0, 5)
const client = getClient()
if (!client || weakAreas.length === 0) {
return { weakAreas, studyTips: [], streakDays }
}
const model = getSetting('ai_model') || 'gpt-4o-mini'
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
- "studyTips": string[] (2-3 short tips in ${language})
Do not include any text outside the JSON object.
Weak areas (score %, attempts):
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
try {
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.4,
max_completion_tokens: 400,
})
const raw = completion.choices[0]?.message?.content
if (!raw) {
return { weakAreas, studyTips: [], streakDays }
}
const parsed = JSON.parse(raw) as Record<string, unknown>
const tips = Array.isArray(parsed.studyTips)
? parsed.studyTips.filter((t): t is string => typeof t === 'string')
: []
return { weakAreas, studyTips: tips, streakDays }
} catch {
return { weakAreas, studyTips: [], streakDays }
}
}
@@ -0,0 +1,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(),
})
+14
View File
@@ -80,14 +80,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>
+17 -11
View File
@@ -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,
@@ -255,7 +256,7 @@ function AppInner() {
setExamState(null);
savedExamIdRef.current = null;
sessionStorage.removeItem('brainwave.pendingResultId');
navigate("/");
navigate(user && isAuthenticated() ? "/workspace" : "/");
};
const restart = () => {
@@ -335,7 +336,7 @@ function AppInner() {
}, [pathname, exam, examState, user, navigate, pendingResultId]);
const handleAuthFlowDone = () => {
navigate("/");
navigate(user && isAuthenticated() ? "/workspace" : "/");
setAuthModalOpen(true);
};
@@ -473,14 +474,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
@@ -541,9 +546,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>
+29
View File
@@ -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 }) })
}
+66 -5
View File
@@ -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 }) })
}
+28
View File
@@ -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')
}
+78 -5
View File
@@ -58,6 +58,7 @@ 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[]>([])
// Load existing exam
useEffect(() => {
@@ -66,6 +67,7 @@ export default function ExamEditor() {
examApi.getExam(examId!)
.then((data) => {
setExam(data)
setTags(data.tags ?? [])
setHasChanges(false)
})
.catch((e) => setError((e as Error).message))
@@ -113,12 +115,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 +167,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>
) : (
@@ -234,6 +242,73 @@ 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>
)
}
/* ─── Question Editor ─── */
interface QEProps {
@@ -332,13 +407,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 ?? '')
@@ -752,7 +826,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 }}
+719 -78
View File
@@ -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,57 @@ 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 [shareOpenId, setShareOpenId] = 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 [moveMenuExamId, setMoveMenuExamId] = useState<number | null>(null)
const [movingExamId, setMovingExamId] = useState<number | null>(null)
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 loadExams = useCallback(async () => {
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 +71,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 +99,707 @@ 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)
setShareOpenId(null)
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)
setShareOpenId(null)
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)
setMoveMenuExamId(null)
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">
<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>
<button
className="btn btn-sm"
onClick={() => void handleDuplicate(exam.id)}
disabled={duplicatingId === exam.id}
title={t('workspace.duplicate')}
>
{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}
</button>
<button
className="btn btn-sm"
onClick={() => void handleExport(exam.id, exam.title)}
disabled={exportingId === exam.id}
title={t('workspace.export')}
>
{exportingId === exam.id ? t('common.loading') : '↓'}
</button>
{/* Move to collection dropdown */}
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm"
onClick={() => setMoveMenuExamId((id) => (id === exam.id ? null : exam.id))}
disabled={movingExamId === exam.id}
title={t('workspace.moveTo')}
>
{movingExamId === exam.id ? t('common.loading') : '▤'}
</button>
{moveMenuExamId === exam.id && (
<div
style={{
position: 'absolute',
right: 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',
}}
>
{exam.collection_id !== null && (
<button
className="user-menu-item"
style={{ fontSize: 13 }}
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"
style={{ fontSize: 13 }}
onClick={() => void handleMoveExam(exam.id, col.id)}
>
{col.name}
</button>
)
))}
{collections.length === 0 && exam.collection_id === null && (
<div className="user-menu-item" style={{ fontSize: 13, color: 'var(--ink-3)', cursor: 'default' }}>
{t('workspace.noSavedExams')}
</div>
)}
</div>
)}
</div>
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm"
onClick={() => setShareOpenId((id) => (id === exam.id ? null : exam.id))}
disabled={startingId === exam.id || sharingId === exam.id}
title={exam.share_token ? t('share.shared') : t('share.share')}
>
{sharingId === exam.id ? t('common.loading') : (exam.share_token ? t('share.shared') : t('share.share'))}
</button>
{shareOpenId === exam.id && (
<div
style={{
position: 'absolute',
right: 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',
}}
>
{exam.share_token ? (
<>
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => {
const url = `${window.location.origin}/share/${exam.share_token}`
void navigator.clipboard.writeText(url)
setShareOpenId(null)
}}
>
{t('share.copyLink')}
</button>
<button
className="user-menu-item"
style={{ fontSize: 13, color: 'var(--wrong)' }}
onClick={() => void handleUnshare(exam.id)}
>
{t('share.revokeLink')}
</button>
</>
) : (
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleShare(exam.id)}
>
{t('share.generateLink')}
</button>
)}
</div>
)}
</div>
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(exam.id)}
title={t('common.delete')}
disabled={startingId === exam.id}
>
✕
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
+336
View File
@@ -0,0 +1,336 @@
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 [importUrl, setImportUrl] = useState('')
const [importing, setImporting] = useState(false)
const handleImportUrl = async () => {
if (!importUrl.trim()) return
setUploadErr(null)
setImporting(true)
try {
const res = await fetch(importUrl.trim())
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as unknown
const msg = validateExam(data)
if (msg) throw new Error(msg)
onLoadExam(parseExam(data), true)
setImportUrl('')
} catch (e) {
setUploadErr(t('landing.loadError', { message: (e as Error).message }))
} finally {
setImporting(false)
}
}
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>
<div
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}
>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.importFromUrl')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
className="input"
placeholder={t('workspace.importUrlPlaceholder')}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleImportUrl()}
disabled={importing || !importUrl.trim()}
style={{ fontSize: 12 }}
>
{importing ? t('workspace.importing') : t('workspace.import')}
</button>
</div>
</div>
<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>
)
}
+117
View File
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+106
View File
@@ -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>
)
}
+30 -6
View File
@@ -1,4 +1,5 @@
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'
@@ -9,11 +10,24 @@ 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 +42,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 +53,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 +63,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 +76,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">
@@ -84,7 +99,16 @@ export default function Workspace({ onLoadExam }: Props) {
</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 />}
+58 -1
View File
@@ -154,6 +154,7 @@
},
"workspace": {
"myWorkspace": "My Workspace",
"overview": "Overview",
"myExams": "My Exams",
"results": "Results",
"settings": "Settings",
@@ -162,6 +163,30 @@
"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",
"viewAll": "View all",
"noAttempts": "No attempts yet",
"noAttemptsDesc": "Take an exam to see your results here.",
"attempts": "attempt",
@@ -211,7 +236,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": {
@@ -346,6 +391,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",
@@ -398,6 +446,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.",
+57 -1
View File
@@ -162,6 +162,30 @@
"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",
"viewAll": "Alles bekijken",
"noAttempts": "Nog geen pogingen",
"noAttemptsDesc": "Maak een examen om hier je resultaten te zien.",
"attempts": "poging",
@@ -211,7 +235,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": {
@@ -345,6 +389,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",
@@ -397,6 +444,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.",
@@ -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":0,"failed":true}],[":backend/dist/validators/resultSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/validators/authSchemas.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/resultSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/authSchemas.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/storage.test.ts",{"duration":0,"failed":true}],[":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":0,"failed":true}],[":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":0,"failed":true}],[":backend/src/validators/examSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/utils/tokens.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/validation.test.ts",{"duration":0,"failed":true}],[":backend/src/utils/tokens.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/slug.test.ts",{"duration":0,"failed":true}],[":backend/src/utils/asyncHandler.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/setupSchemas.test.ts",{"duration":0,"failed":true}],[":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":0,"failed":true}],[":backend/src/middleware/error.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/api.test.ts",{"duration":0,"failed":true}],[":backend/src/repositories/storageRepo.test.ts",{"duration":0,"failed":true}],[":backend/dist/repositories/storageRepo.test.js",{"duration":0,"failed":true}]]}