Public Access
feat: add MyExams, MyResults, Settings, and Workspace components
Release Pipeline / build-and-release (push) Failing after 41s
Release Pipeline / build-and-release (push) Failing after 41s
- Implemented MyExams component for managing user exams, including loading, deleting, and creating new exams. - Created MyResults component to display user results with options to review and delete results. - Developed Settings component for updating user display name and changing password. - Introduced Workspace component to serve as a container for MyExams, MyResults, and Settings, with navigation between tabs.
This commit is contained in:
1 parent
ad777ef1c3
commit
62af83ce53
16 files changed
+2192
-47
No files matched your search
@@ -266,4 +266,68 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Update Current User ─────────────────────────────────
|
||||
router.patch('/me', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { displayName } = req.body
|
||||
|
||||
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
|
||||
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE users SET display_name = ?, updated_at = unixepoch() WHERE id = ?
|
||||
`).run(displayName.trim(), req.user!.userId)
|
||||
|
||||
res.json({ message: 'Profile updated successfully' })
|
||||
} catch (err) {
|
||||
console.error('Update profile error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Change Password ─────────────────────────────────────
|
||||
router.post('/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Current password and new password are required' })
|
||||
return
|
||||
}
|
||||
if (typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||
res.status(400).json({ error: 'New password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(
|
||||
req.user!.userId
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' })
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword as string, user['password_hash'] as string)
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Current password is incorrect' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
|
||||
db.prepare(`
|
||||
UPDATE users SET password_hash = ?, updated_at = unixepoch() WHERE id = ?
|
||||
`).run(passwordHash, req.user!.userId)
|
||||
|
||||
res.json({ message: 'Password changed successfully' })
|
||||
} catch (err) {
|
||||
console.error('Change password error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -76,6 +76,35 @@ router.post('/', requireAuth, (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Update Exam ─────────────────────────────────────────
|
||||
router.put('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { exam } = req.body
|
||||
|
||||
if (!exam || !exam.title || !exam.questions?.length) {
|
||||
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
UPDATE saved_exams
|
||||
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(exam.title, exam.subtitle ?? null, JSON.stringify(exam), Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ message: 'Exam updated successfully' })
|
||||
} catch (err) {
|
||||
console.error('Update exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Delete Exam ─────────────────────────────────────────
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
|
||||
+51
-16
@@ -18,7 +18,7 @@ import {
|
||||
clearHistory,
|
||||
} from "./utils/storage";
|
||||
import { apiFetch, isAuthenticated } from "./utils/api";
|
||||
import { slugify } from "./utils/slug";
|
||||
import { getExamUrlId } from "./utils/slug";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import Landing from "./components/Landing";
|
||||
import ExamRoute from "./components/ExamRoute";
|
||||
@@ -29,6 +29,8 @@ import EmailVerify from "./components/EmailVerify";
|
||||
import ResetPassword from "./components/ResetPassword";
|
||||
import AdminDashboard from "./components/AdminDashboard";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Workspace from "./components/Workspace";
|
||||
import ExamEditor from "./components/ExamEditor";
|
||||
|
||||
const DEFAULT_TWEAKS: Tweaks = {
|
||||
theme: "light",
|
||||
@@ -124,19 +126,40 @@ function AppInner() {
|
||||
saveState({ exam, state: examState });
|
||||
}, [exam, examState, pathname]);
|
||||
|
||||
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/review/") && !exam) {
|
||||
const reviewExam = localStorage.getItem('brainwave.review.exam')
|
||||
const reviewState = localStorage.getItem('brainwave.review.state')
|
||||
if (reviewExam && reviewState) {
|
||||
try {
|
||||
const parsedExam = JSON.parse(reviewExam) as Exam
|
||||
const parsedState = JSON.parse(reviewState) as ExamState
|
||||
setExam(parsedExam)
|
||||
setExamState(withNotes(parsedState))
|
||||
localStorage.removeItem('brainwave.review.exam')
|
||||
localStorage.removeItem('brainwave.review.state')
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [pathname, exam]);
|
||||
|
||||
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
||||
setTweaks((t) => ({ ...t, [key]: value }));
|
||||
|
||||
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (
|
||||
nextState,
|
||||
) => {
|
||||
setExamState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return typeof nextState === "function"
|
||||
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
||||
: nextState;
|
||||
});
|
||||
};
|
||||
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
|
||||
(nextState) => {
|
||||
setExamState((prev) => {
|
||||
if (!prev) return prev;
|
||||
return typeof nextState === "function"
|
||||
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
||||
: nextState;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const activateExam = (data: Exam, state: ExamState) => {
|
||||
setExam(data);
|
||||
@@ -160,7 +183,7 @@ function AppInner() {
|
||||
feedbackMode: fm,
|
||||
});
|
||||
});
|
||||
navigate(`/exam/${slugify(data.title)}/q1`);
|
||||
navigate(`/exam/${getExamUrlId(data)}/q1`);
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
@@ -171,7 +194,7 @@ function AppInner() {
|
||||
setExamState(withNotes(saved.state));
|
||||
});
|
||||
navigate(
|
||||
`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`,
|
||||
`/exam/${getExamUrlId(saved.exam)}/q${saved.state.currentIdx + 1}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -255,21 +278,21 @@ function AppInner() {
|
||||
setExam(data.exam);
|
||||
setExamState(withNotes(data.state));
|
||||
});
|
||||
navigate(`/review/${slugify(entry.title)}`);
|
||||
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||
})
|
||||
.catch(() => {
|
||||
flushSync(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
});
|
||||
navigate(`/review/${slugify(entry.title)}`);
|
||||
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||
});
|
||||
} else {
|
||||
flushSync(() => {
|
||||
setExam(entry.exam);
|
||||
setExamState(withNotes(entry.state));
|
||||
});
|
||||
navigate(`/review/${slugify(entry.title)}`);
|
||||
navigate(`/review/${getExamUrlId(entry.exam)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -382,6 +405,15 @@ function AppInner() {
|
||||
<div className="user-menu-email">{user.email}</div>
|
||||
</div>
|
||||
<div className="user-menu-divider" />
|
||||
<button
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
navigate("/workspace");
|
||||
}}
|
||||
>
|
||||
My Workspace
|
||||
</button>
|
||||
{user.role === "admin" && (
|
||||
<button
|
||||
className="user-menu-item"
|
||||
@@ -477,6 +509,9 @@ function AppInner() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/workspace" element={<Navigate to="/workspace/exams" replace />} />
|
||||
<Route path="/workspace/:tab" element={<Workspace />} />
|
||||
<Route path="/exam-editor/:id" element={<ExamEditor />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { apiFetch } from '../utils/api'
|
||||
import type { Exam, Question, QuestionType, Choice, MatchItem } from '../types'
|
||||
|
||||
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
|
||||
{ value: 'multiple-choice', label: 'Multiple Choice' },
|
||||
{ value: 'multiple-select', label: 'Multiple Select' },
|
||||
{ value: 'short-answer', label: 'Short Answer' },
|
||||
{ value: 'translation', label: 'Translation' },
|
||||
{ value: 'math-input', label: 'Math Input' },
|
||||
{ value: 'essay', label: 'Essay' },
|
||||
{ value: 'match', label: 'Match' },
|
||||
{ value: 'order', label: 'Order' },
|
||||
]
|
||||
|
||||
const BLANK_EXAM: Exam = {
|
||||
title: 'Untitled Exam',
|
||||
subtitle: '',
|
||||
questions: [],
|
||||
}
|
||||
|
||||
function makeBlankQuestion(type: QuestionType, index: number): Question {
|
||||
const id = `q${index + 1}`
|
||||
const base: Question = { id, type, prompt: '', points: 1 }
|
||||
switch (type) {
|
||||
case 'multiple-choice':
|
||||
return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: 'a' }
|
||||
case 'multiple-select':
|
||||
return { ...base, choices: [{ id: 'a', text: '' }, { id: 'b', text: '' }], answer: [] }
|
||||
case 'short-answer':
|
||||
case 'translation':
|
||||
case 'math-input':
|
||||
return { ...base, answer: '', acceptedForms: [], caseSensitive: false }
|
||||
case 'essay':
|
||||
return { ...base, minWords: 0, rubric: [] }
|
||||
case 'match':
|
||||
return { ...base, left: [], right: [], answer: {} }
|
||||
case 'order':
|
||||
return { ...base, items: [], answer: [] }
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
export default function ExamEditor() {
|
||||
const { id: paramId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isNew = paramId === 'new'
|
||||
const examId = isNew ? null : Number(paramId)
|
||||
|
||||
const [exam, setExam] = useState<Exam>(BLANK_EXAM)
|
||||
const [selectedIdx, setSelectedIdx] = useState(0)
|
||||
const [loading, setLoading] = useState(!isNew)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(isNew)
|
||||
|
||||
// Load existing exam
|
||||
useEffect(() => {
|
||||
if (isNew) return
|
||||
setLoading(true)
|
||||
apiFetch<{ exam: Exam }>(`/api/exams/${examId}`)
|
||||
.then((data) => {
|
||||
setExam(data.exam)
|
||||
setHasChanges(false)
|
||||
})
|
||||
.catch((e) => setError((e as Error).message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [examId, isNew])
|
||||
|
||||
const updateExam = useCallback((fn: (draft: Exam) => void) => {
|
||||
setExam((prev) => {
|
||||
const draft = structuredClone(prev)
|
||||
fn(draft)
|
||||
return draft
|
||||
})
|
||||
setHasChanges(true)
|
||||
setSaveMsg(null)
|
||||
}, [])
|
||||
|
||||
const addQuestion = (type: QuestionType) => {
|
||||
updateExam((draft) => {
|
||||
const q = makeBlankQuestion(type, draft.questions.length)
|
||||
draft.questions.push(q)
|
||||
})
|
||||
setSelectedIdx(exam.questions.length)
|
||||
}
|
||||
|
||||
const removeQuestion = (idx: number) => {
|
||||
updateExam((draft) => {
|
||||
draft.questions.splice(idx, 1)
|
||||
})
|
||||
if (selectedIdx >= idx && selectedIdx > 0) {
|
||||
setSelectedIdx(selectedIdx - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const saveExam = async () => {
|
||||
if (!exam.title.trim()) {
|
||||
setError('Exam title is required')
|
||||
return
|
||||
}
|
||||
if (exam.questions.length === 0) {
|
||||
setError('Add at least one question')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (isNew) {
|
||||
const data = await apiFetch<{ id: number }>('/api/exams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ exam }),
|
||||
})
|
||||
setSaveMsg('Exam created successfully')
|
||||
setHasChanges(false)
|
||||
navigate(`/exam-editor/${data.id}`, { replace: true })
|
||||
} else {
|
||||
await apiFetch(`/api/exams/${examId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ exam }),
|
||||
})
|
||||
setSaveMsg('Exam saved')
|
||||
setHasChanges(false)
|
||||
}
|
||||
setTimeout(() => setSaveMsg(null), 3000)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedQuestion = exam.questions[selectedIdx]
|
||||
|
||||
const truncate = (s: string, n = 40) => (s.length > n ? s.slice(0, n) + '…' : s)
|
||||
|
||||
return (
|
||||
<div className="exam-editor-shell">
|
||||
{/* Header */}
|
||||
<header className="exam-editor-header">
|
||||
<div className="exam-editor-header-left">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/exams')}>
|
||||
← Back
|
||||
</button>
|
||||
<input
|
||||
className="exam-editor-title-input"
|
||||
value={exam.title}
|
||||
onChange={(e) => updateExam((d) => { d.title = e.target.value })}
|
||||
placeholder="Exam title"
|
||||
/>
|
||||
</div>
|
||||
<div className="exam-editor-header-right">
|
||||
{saveMsg && <span className="exam-editor-save-msg">{saveMsg}</span>}
|
||||
{hasChanges && <span className="exam-editor-unsaved">Unsaved changes</span>}
|
||||
<button className="btn btn-primary" onClick={() => void saveExam()} disabled={saving}>
|
||||
{saving ? 'Saving…' : isNew ? 'Create Exam' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="workspace-alert workspace-alert-error exam-editor-alert">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="exam-editor-loading">Loading…</div>
|
||||
) : (
|
||||
<div className="exam-editor-body">
|
||||
{/* Sidebar */}
|
||||
<aside className="exam-editor-sidebar">
|
||||
<div className="sidebar-section-label">Questions</div>
|
||||
<div className="nav-list">
|
||||
{exam.questions.map((q, i) => (
|
||||
<button
|
||||
key={q.id}
|
||||
className={`nav-item${selectedIdx === i ? ' active' : ''}`}
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
>
|
||||
<span className="nav-item-num">{i + 1}</span>
|
||||
<span className="nav-item-title">{truncate(q.prompt) || 'Untitled'}</span>
|
||||
<span
|
||||
className="exam-editor-q-remove"
|
||||
onClick={(e) => { e.stopPropagation(); removeQuestion(i) }}
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="exam-editor-add">
|
||||
<div className="sidebar-section-label" style={{ marginTop: 24 }}>Add Question</div>
|
||||
<div className="exam-editor-type-grid">
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
className="exam-editor-type-btn"
|
||||
onClick={() => addQuestion(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Editor */}
|
||||
<main className="exam-editor-main">
|
||||
{selectedQuestion ? (
|
||||
<QuestionEditor
|
||||
question={selectedQuestion}
|
||||
onChange={(fn) => {
|
||||
updateExam((draft) => {
|
||||
const q = draft.questions[selectedIdx]
|
||||
if (q) fn(q)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">No questions yet</div>
|
||||
<div className="workspace-empty-desc">Select a question type from the sidebar to add your first question.</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Question Editor ─── */
|
||||
|
||||
interface QEProps {
|
||||
question: Question
|
||||
onChange: (fn: (q: Question) => void) => void
|
||||
}
|
||||
|
||||
function QuestionEditor({ question, onChange }: QEProps) {
|
||||
const update = <K extends keyof Question>(key: K, value: Question[K]) => {
|
||||
onChange((q) => { q[key] = value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form">
|
||||
<div className="question-form-header">
|
||||
<span className="mono faint" style={{ fontSize: 12 }}>Question Editor</span>
|
||||
<span className="q-category" style={{ marginLeft: 'auto' }}>{question.type}</span>
|
||||
</div>
|
||||
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Prompt</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={question.prompt}
|
||||
onChange={(e) => update('prompt', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Enter the question prompt..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="question-form-row-inline">
|
||||
<div>
|
||||
<label className="question-form-label">Category (optional)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={question.category ?? ''}
|
||||
onChange={(e) => update('category', e.target.value || undefined)}
|
||||
placeholder="e.g. Algebra"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 100 }}>
|
||||
<label className="question-form-label">Points</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={question.points ?? 1}
|
||||
onChange={(e) => update('points', Math.max(1, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Explanation (optional)</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={question.explanation ?? ''}
|
||||
onChange={(e) => update('explanation', e.target.value || undefined)}
|
||||
rows={2}
|
||||
placeholder="Explanation shown after grading..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific editors */}
|
||||
{question.type === 'multiple-choice' && <MultipleChoiceEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'multiple-select' && <MultipleSelectEditor q={question} onChange={onChange} />}
|
||||
{(question.type === 'short-answer' || question.type === 'translation' || question.type === 'math-input') && (
|
||||
<TextAnswerEditor q={question} onChange={onChange} />
|
||||
)}
|
||||
{question.type === 'translation' && (
|
||||
<div className="question-form-row-inline">
|
||||
<div>
|
||||
<label className="question-form-label">Source Language</label>
|
||||
<input className="input" value={question.sourceLanguage ?? ''} onChange={(e) => update('sourceLanguage', e.target.value || undefined)} placeholder="e.g. English" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="question-form-label">Target Language</label>
|
||||
<input className="input" value={question.targetLanguage ?? ''} onChange={(e) => update('targetLanguage', e.target.value || undefined)} placeholder="e.g. Spanish" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{question.type === 'essay' && <EssayEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'match' && <MatchEditor q={question} onChange={onChange} />}
|
||||
{question.type === 'order' && <OrderEditor q={question} onChange={onChange} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Multiple Choice ─── */
|
||||
|
||||
function MultipleChoiceEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const choices = q.choices ?? []
|
||||
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
|
||||
const setAnswer = (a: string) => onChange((q) => { q.answer = a })
|
||||
|
||||
const addChoice = () => {
|
||||
const nextId = String.fromCharCode(97 + choices.length) // a, b, c...
|
||||
setChoices([...choices, { id: nextId, text: '' }])
|
||||
}
|
||||
|
||||
const removeChoice = (idx: number) => {
|
||||
const next = choices.filter((_, i) => i !== idx)
|
||||
// Renumber
|
||||
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
|
||||
setChoices(renumbered)
|
||||
if (q.answer === choices[idx]?.id) setAnswer(renumbered[0]?.id ?? '')
|
||||
}
|
||||
|
||||
const updateChoice = (idx: number, text: string) => {
|
||||
const next = [...choices]
|
||||
next[idx] = { ...next[idx], text }
|
||||
setChoices(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Choices</label>
|
||||
<div className="choice-editor-list">
|
||||
{choices.map((c, i) => (
|
||||
<div key={c.id} className="choice-editor-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="mc-answer"
|
||||
checked={q.answer === c.id}
|
||||
onChange={() => setAnswer(c.id)}
|
||||
title="Mark as correct"
|
||||
/>
|
||||
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={c.text}
|
||||
onChange={(e) => updateChoice(i, e.target.value)}
|
||||
placeholder={`Choice ${c.id}`}
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
|
||||
+ Add Choice
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Multiple Select ─── */
|
||||
|
||||
function MultipleSelectEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const choices = q.choices ?? []
|
||||
const answer = (q.answer as string[]) ?? []
|
||||
const setChoices = (c: Choice[]) => onChange((q) => { q.choices = c })
|
||||
const toggleAnswer = (id: string) => {
|
||||
const next = answer.includes(id) ? answer.filter((a) => a !== id) : [...answer, id]
|
||||
onChange((q) => { q.answer = next })
|
||||
}
|
||||
|
||||
const addChoice = () => {
|
||||
const nextId = String.fromCharCode(97 + choices.length)
|
||||
setChoices([...choices, { id: nextId, text: '' }])
|
||||
}
|
||||
|
||||
const removeChoice = (idx: number) => {
|
||||
const removedId = choices[idx].id
|
||||
const next = choices.filter((_, i) => i !== idx)
|
||||
const renumbered = next.map((c, i) => ({ ...c, id: String.fromCharCode(97 + i) }))
|
||||
setChoices(renumbered)
|
||||
onChange((q) => { q.answer = (q.answer as string[]).filter((a) => a !== removedId) })
|
||||
}
|
||||
|
||||
const updateChoice = (idx: number, text: string) => {
|
||||
const next = [...choices]
|
||||
next[idx] = { ...next[idx], text }
|
||||
setChoices(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Choices (check correct answers)</label>
|
||||
<div className="choice-editor-list">
|
||||
{choices.map((c, i) => (
|
||||
<div key={c.id} className="choice-editor-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={answer.includes(c.id)}
|
||||
onChange={() => toggleAnswer(c.id)}
|
||||
/>
|
||||
<span className="mono faint" style={{ fontSize: 13, width: 20 }}>{c.id}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={c.text}
|
||||
onChange={(e) => updateChoice(i, e.target.value)}
|
||||
placeholder={`Choice ${c.id}`}
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeChoice(i)} title="Remove">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={addChoice} style={{ marginTop: 8 }}>
|
||||
+ Add Choice
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Text Answer (short, translation, math) ─── */
|
||||
|
||||
function TextAnswerEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const accepted = q.acceptedForms ?? []
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Correct Answer</label>
|
||||
<input
|
||||
className="input"
|
||||
value={(q.answer as string) ?? ''}
|
||||
onChange={(e) => onChange((q) => { q.answer = e.target.value })}
|
||||
placeholder="Enter the correct answer"
|
||||
/>
|
||||
</div>
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Accepted Forms (optional alternatives)</label>
|
||||
{accepted.map((form, i) => (
|
||||
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={form}
|
||||
onChange={(e) => {
|
||||
const next = [...accepted]
|
||||
next[i] = e.target.value
|
||||
onChange((q) => { q.acceptedForms = next })
|
||||
}}
|
||||
placeholder="Alternative answer"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
const next = accepted.filter((_, idx) => idx !== i)
|
||||
onChange((q) => { q.acceptedForms = next })
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onChange((q) => { q.acceptedForms = [...(q.acceptedForms ?? []), ''] })}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
+ Add Alternative
|
||||
</button>
|
||||
</div>
|
||||
<div className="question-form-row-inline" style={{ marginTop: 8 }}>
|
||||
<label className="question-form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={q.caseSensitive ?? false}
|
||||
onChange={(e) => onChange((q) => { q.caseSensitive = e.target.checked })}
|
||||
/>
|
||||
Case sensitive
|
||||
</label>
|
||||
<label className="question-form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={q.ignorePunctuation ?? false}
|
||||
onChange={(e) => onChange((q) => { q.ignorePunctuation = e.target.checked })}
|
||||
/>
|
||||
Ignore punctuation
|
||||
</label>
|
||||
<label className="question-form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={q.ignoreAccents ?? false}
|
||||
onChange={(e) => onChange((q) => { q.ignoreAccents = e.target.checked })}
|
||||
/>
|
||||
Ignore accents
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Essay ─── */
|
||||
|
||||
function EssayEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const rubric = q.rubric ?? []
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="question-form-row-inline">
|
||||
<div style={{ width: 140 }}>
|
||||
<label className="question-form-label">Min Words</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={q.minWords ?? 0}
|
||||
onChange={(e) => onChange((q) => { q.minWords = Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="question-form-row">
|
||||
<label className="question-form-label">Rubric Criteria</label>
|
||||
{rubric.map((item, i) => (
|
||||
<div key={i} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const next = [...rubric]
|
||||
next[i] = e.target.value
|
||||
onChange((q) => { q.rubric = next })
|
||||
}}
|
||||
placeholder="Criterion"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
const next = rubric.filter((_, idx) => idx !== i)
|
||||
onChange((q) => { q.rubric = next })
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onChange((q) => { q.rubric = [...(q.rubric ?? []), ''] })}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
+ Add Criterion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Match ─── */
|
||||
|
||||
function MatchEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const left = q.left ?? []
|
||||
const right = q.right ?? []
|
||||
const answer = (q.answer as Record<string, string>) ?? {}
|
||||
|
||||
const addLeft = () => {
|
||||
const id = `l${left.length + 1}`
|
||||
onChange((q) => { q.left = [...left, { id, text: '' }] })
|
||||
}
|
||||
const addRight = () => {
|
||||
const id = `r${right.length + 1}`
|
||||
onChange((q) => { q.right = [...right, { id, text: '' }] })
|
||||
}
|
||||
const removeLeft = (idx: number) => {
|
||||
const removed = left[idx]
|
||||
onChange((q) => {
|
||||
q.left = left.filter((_, i) => i !== idx)
|
||||
const ans = { ...(q.answer as Record<string, string>) }
|
||||
delete ans[removed.id]
|
||||
q.answer = ans
|
||||
})
|
||||
}
|
||||
const removeRight = (idx: number) => {
|
||||
const removed = right[idx]
|
||||
onChange((q) => {
|
||||
q.right = right.filter((_, i) => i !== idx)
|
||||
const ans = { ...(q.answer as Record<string, string>) }
|
||||
for (const k of Object.keys(ans)) {
|
||||
if (ans[k] === removed.id) delete ans[k]
|
||||
}
|
||||
q.answer = ans
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<div className="match-editor-grid">
|
||||
<div>
|
||||
<label className="question-form-label">Left Column</label>
|
||||
{left.map((item, i) => (
|
||||
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={item.text}
|
||||
onChange={(e) => {
|
||||
const next = [...left]
|
||||
next[i] = { ...next[i], text: e.target.value }
|
||||
onChange((q) => { q.left = next })
|
||||
}}
|
||||
placeholder={`Left item ${i + 1}`}
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeLeft(i)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addLeft} style={{ marginTop: 4 }}>+ Add Left</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="question-form-label">Right Column</label>
|
||||
{right.map((item, i) => (
|
||||
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<input
|
||||
className="input"
|
||||
value={item.text}
|
||||
onChange={(e) => {
|
||||
const next = [...right]
|
||||
next[i] = { ...next[i], text: e.target.value }
|
||||
onChange((q) => { q.right = next })
|
||||
}}
|
||||
placeholder={`Right item ${i + 1}`}
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeRight(i)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addRight} style={{ marginTop: 4 }}>+ Add Right</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{left.length > 0 && right.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="question-form-label">Correct Pairings</label>
|
||||
{left.map((l) => (
|
||||
<div key={l.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<span style={{ minWidth: 120, fontSize: 14 }}>{l.text || l.id}</span>
|
||||
<span className="faint">→</span>
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: 200 }}
|
||||
value={answer[l.id] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = { ...answer, [l.id]: e.target.value }
|
||||
if (!e.target.value) delete next[l.id]
|
||||
onChange((q) => { q.answer = next })
|
||||
}}
|
||||
>
|
||||
<option value="">— Select —</option>
|
||||
{right.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.text || r.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Order ─── */
|
||||
|
||||
function OrderEditor({ q, onChange }: { q: Question; onChange: QEProps['onChange'] }) {
|
||||
const items = q.items ?? []
|
||||
const answer = (q.answer as string[]) ?? []
|
||||
|
||||
const addItem = () => {
|
||||
const id = `s${items.length + 1}`
|
||||
onChange((q) => { q.items = [...items, { id, text: '' }] })
|
||||
}
|
||||
|
||||
const removeItem = (idx: number) => {
|
||||
const removed = items[idx]
|
||||
onChange((q) => {
|
||||
q.items = items.filter((_, i) => i !== idx)
|
||||
q.answer = answer.filter((a) => a !== removed.id)
|
||||
})
|
||||
}
|
||||
|
||||
const moveItem = (idx: number, dir: -1 | 1) => {
|
||||
const next = [...items]
|
||||
const swap = idx + dir
|
||||
if (swap < 0 || swap >= next.length) return
|
||||
;[next[idx], next[swap]] = [next[swap], next[idx]]
|
||||
onChange((q) => { q.items = next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="question-form-section">
|
||||
<label className="question-form-label">Items (drag not implemented, use arrows to reorder)</label>
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="choice-editor-row" style={{ marginBottom: 6 }}>
|
||||
<span className="mono faint" style={{ fontSize: 12, width: 24 }}>{i + 1}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={item.text}
|
||||
onChange={(e) => {
|
||||
const next = [...items]
|
||||
next[i] = { ...next[i], text: e.target.value }
|
||||
onChange((q) => { q.items = next })
|
||||
}}
|
||||
placeholder={`Item ${i + 1}`}
|
||||
/>
|
||||
<div className="order-editor-controls">
|
||||
<button className="btn btn-ghost btn-sm" disabled={i === 0} onClick={() => moveItem(i, -1)}>↑</button>
|
||||
<button className="btn btn-ghost btn-sm" disabled={i === items.length - 1} onClick={() => moveItem(i, 1)}>↓</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => removeItem(i)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={addItem} style={{ marginTop: 4 }}>+ Add Item</button>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<label className="question-form-label">Correct Order</label>
|
||||
<div className="choice-editor-row">
|
||||
{items.map((item, i) => (
|
||||
<span key={item.id} className="mono faint" style={{ fontSize: 13, marginRight: 4 }}>
|
||||
{i + 1}. {item.text || item.id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="faint" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
The correct order is the order shown above. Reorder items to set the correct sequence.
|
||||
</p>
|
||||
{/* Sync answer to current item order */}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => onChange((q) => { q.answer = items.map((it) => it.id) })}
|
||||
>
|
||||
Set Current Order as Correct
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use no memo";
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import ExamRunner from './ExamRunner'
|
||||
import { loadState } from '../utils/storage'
|
||||
import { slugify } from '../utils/slug'
|
||||
import { getExamUrlId } from '../utils/slug'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -25,7 +26,7 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
|
||||
useEffect(() => {
|
||||
if (!exam) {
|
||||
const saved = loadState()
|
||||
if (saved?.exam && slugify(saved.exam.title) === slug) {
|
||||
if (saved?.exam && getExamUrlId(saved.exam) === slug) {
|
||||
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
|
||||
} else {
|
||||
navigate('/', { replace: true })
|
||||
@@ -44,12 +45,14 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
|
||||
|
||||
return (
|
||||
<ExamRunner
|
||||
key={n}
|
||||
exam={exam}
|
||||
state={examState}
|
||||
setState={setExamState}
|
||||
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
|
||||
onSubmit={onSubmit}
|
||||
onExit={onExit}
|
||||
n={n}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
"use no memo";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Exam, ExamState, Question } from "../types";
|
||||
import { gradeQuestion } from "../utils/grading";
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
onSubmit: () => void;
|
||||
onExit: () => void;
|
||||
onNavigate?: (i: number) => void;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
export default function ExamRunner({
|
||||
@@ -22,8 +24,11 @@ export default function ExamRunner({
|
||||
onSubmit,
|
||||
onExit,
|
||||
onNavigate,
|
||||
n,
|
||||
}: Readonly<Props>) {
|
||||
const { currentIdx, answers, flags, startedAt, feedbackMode } = state;
|
||||
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
||||
const { answers, flags, startedAt, feedbackMode } = state;
|
||||
const currentIdx = urlIdx;
|
||||
const q = exam.questions[currentIdx];
|
||||
const total = exam.questions.length;
|
||||
const notes = state.notes ?? "";
|
||||
|
||||
@@ -395,7 +395,11 @@ Set a 20-minute time limit. Include explanations for each answer."
|
||||
if (f) void handleFile(f)
|
||||
}}
|
||||
>
|
||||
<div className="dropzone-icon" />
|
||||
<div className="dropzone-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Drop your exam JSON here</h3>
|
||||
<p>or click to browse · max 5 MB</p>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiFetch } from '../utils/api'
|
||||
import type { Exam } from '../types'
|
||||
|
||||
interface SavedExam {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
question_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export default function MyExams() {
|
||||
const navigate = useNavigate()
|
||||
const [exams, setExams] = useState<SavedExam[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await apiFetch<SavedExam[]>('/api/exams')
|
||||
setExams(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadExams()
|
||||
}, [loadExams])
|
||||
|
||||
const deleteExam = async (id: number) => {
|
||||
try {
|
||||
await apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
|
||||
setDeleteConfirm(null)
|
||||
void loadExams()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const createNewExam = () => {
|
||||
navigate('/exam-editor/new')
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">My Workspace</div>
|
||||
<h2 className="workspace-section-title">My Exams</h2>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
|
||||
+ New Exam
|
||||
</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">Loading…</div>
|
||||
) : exams.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">◇</div>
|
||||
<div className="workspace-empty-title">No saved exams yet</div>
|
||||
<div className="workspace-empty-desc">
|
||||
Upload an exam from the home page, or create a new one to get started.
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
|
||||
Create Your First Exam
|
||||
</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} question{exam.question_count !== 1 ? 's' : ''}</span>
|
||||
<span>·</span>
|
||||
<span>Updated {formatDate(exam.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-card-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => navigate(`/exam-editor/${exam.id}`)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirm === exam.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
|
||||
Confirm
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => setDeleteConfirm(exam.id)}
|
||||
title="Delete exam"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiFetch } from '../utils/api'
|
||||
import { getExamUrlId } from '../utils/slug'
|
||||
import type { Exam, ExamState } from '../types'
|
||||
|
||||
interface ResultItem {
|
||||
id: number
|
||||
title: string
|
||||
score_points: number
|
||||
total_points: number
|
||||
correct_count: number
|
||||
graded_count: number
|
||||
passing_score: number
|
||||
elapsed_ms: number
|
||||
completed_at: number
|
||||
exam_id: number | null
|
||||
}
|
||||
|
||||
export default function MyResults() {
|
||||
const navigate = useNavigate()
|
||||
const [results, setResults] = useState<ResultItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
|
||||
const loadResults = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await apiFetch<{ results: ResultItem[]; total: number }>('/api/results')
|
||||
setResults(data.results)
|
||||
setTotal(data.total)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadResults()
|
||||
}, [loadResults])
|
||||
|
||||
const deleteResult = async (id: number) => {
|
||||
try {
|
||||
await apiFetch(`/api/results/${id}`, { method: 'DELETE' })
|
||||
setDeleteConfirm(null)
|
||||
void loadResults()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const reviewResult = async (item: ResultItem) => {
|
||||
try {
|
||||
const data = await apiFetch<{
|
||||
exam: Exam
|
||||
state: ExamState
|
||||
title: string
|
||||
scorePoints: number
|
||||
totalPoints: number
|
||||
correctCount: number
|
||||
gradedCount: number
|
||||
passingScore: number
|
||||
elapsedMs: number
|
||||
completedAt: number
|
||||
}>(`/api/results/${item.id}`)
|
||||
// Store in session for review - we'll navigate to review route
|
||||
// Use localStorage as a bridge since review expects exam/examState in App state
|
||||
localStorage.setItem('brainwave.review.exam', JSON.stringify(data.exam))
|
||||
localStorage.setItem('brainwave.review.state', JSON.stringify({
|
||||
...data.state,
|
||||
completedAt: data.completedAt * 1000,
|
||||
}))
|
||||
navigate(`/review/${getExamUrlId(data.exam)}`)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.floor((ms % 60000) / 1000)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">My Workspace</div>
|
||||
<h2 className="workspace-section-title">Results</h2>
|
||||
</div>
|
||||
<div className="mono muted" style={{ fontSize: 13 }}>{total} attempt{total !== 1 ? 's' : ''}</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">Loading…</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="workspace-empty">
|
||||
<div className="workspace-empty-icon">✦</div>
|
||||
<div className="workspace-empty-title">No attempts yet</div>
|
||||
<div className="workspace-empty-desc">
|
||||
Take an exam to see your results here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-list">
|
||||
{results.map((r) => {
|
||||
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
|
||||
const passed = pct >= (r.passing_score ?? 60)
|
||||
return (
|
||||
<div key={r.id} className="workspace-card">
|
||||
<div className="workspace-card-body">
|
||||
<div className="workspace-card-title">{r.title}</div>
|
||||
<div className="workspace-card-meta">
|
||||
<span>{formatDate(r.completed_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(r.elapsed_ms)}</span>
|
||||
<span>·</span>
|
||||
<span>{r.correct_count}/{r.graded_count} correct</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-card-actions" style={{ gap: 8 }}>
|
||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
||||
Review
|
||||
</button>
|
||||
{deleteConfirm === r.id ? (
|
||||
<div className="workspace-delete-confirm">
|
||||
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
|
||||
Confirm
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
|
||||
onClick={() => setDeleteConfirm(r.id)}
|
||||
title="Delete result"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { apiFetch } from '../utils/api'
|
||||
|
||||
export default function Settings() {
|
||||
const { user, refreshUser } = useAuth()
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '')
|
||||
const [savingName, setSavingName] = useState(false)
|
||||
const [nameMsg, setNameMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [savingPassword, setSavingPassword] = useState(false)
|
||||
const [passwordMsg, setPasswordMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const updateDisplayName = useCallback(async () => {
|
||||
const trimmed = displayName.trim()
|
||||
if (!trimmed || trimmed.length > 100) {
|
||||
setNameMsg({ ok: false, text: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
setSavingName(true)
|
||||
setNameMsg(null)
|
||||
try {
|
||||
await apiFetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ displayName: trimmed }),
|
||||
})
|
||||
setNameMsg({ ok: true, text: 'Display name updated' })
|
||||
await refreshUser()
|
||||
} catch (e) {
|
||||
setNameMsg({ ok: false, text: (e as Error).message })
|
||||
} finally {
|
||||
setSavingName(false)
|
||||
}
|
||||
}, [displayName, refreshUser])
|
||||
|
||||
const changePassword = useCallback(async () => {
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordMsg({ ok: false, text: 'New password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ ok: false, text: 'Passwords do not match' })
|
||||
return
|
||||
}
|
||||
setSavingPassword(true)
|
||||
setPasswordMsg(null)
|
||||
try {
|
||||
await apiFetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
setPasswordMsg({ ok: true, text: 'Password changed successfully' })
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (e) {
|
||||
setPasswordMsg({ ok: false, text: (e as Error).message })
|
||||
} finally {
|
||||
setSavingPassword(false)
|
||||
}
|
||||
}, [currentPassword, newPassword, confirmPassword])
|
||||
|
||||
return (
|
||||
<div className="workspace-section fade-in">
|
||||
<div className="workspace-section-header">
|
||||
<div>
|
||||
<div className="workspace-section-eyebrow">My Workspace</div>
|
||||
<h2 className="workspace-section-title">Settings</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>Profile</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body workspace-settings-form">
|
||||
<div className="workspace-setting-row">
|
||||
<label className="workspace-setting-label" htmlFor="settings-display-name">
|
||||
<span className="workspace-setting-title">Display Name</span>
|
||||
<span className="workspace-setting-desc">Your name shown throughout the app</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-display-name"
|
||||
className="input workspace-setting-input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="workspace-setting-row">
|
||||
<label className="workspace-setting-label" htmlFor="settings-email">
|
||||
<span className="workspace-setting-title">Email</span>
|
||||
<span className="workspace-setting-desc">Your email address</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-email"
|
||||
className="input workspace-setting-input"
|
||||
value={user?.email ?? ''}
|
||||
readOnly
|
||||
disabled
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
</div>
|
||||
{nameMsg && (
|
||||
<div className={`workspace-alert ${nameMsg.ok ? 'workspace-alert-success' : 'workspace-alert-error'}`}>
|
||||
{nameMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="workspace-settings-footer">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void updateDisplayName()}
|
||||
disabled={savingName || displayName.trim() === user?.displayName}
|
||||
>
|
||||
{savingName ? 'Saving…' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="workspace-panel">
|
||||
<div className="workspace-panel-header">
|
||||
<h3>Security</h3>
|
||||
</div>
|
||||
<div className="workspace-panel-body workspace-settings-form">
|
||||
<div className="workspace-setting-row">
|
||||
<label className="workspace-setting-label" htmlFor="settings-current-password">
|
||||
<span className="workspace-setting-title">Current Password</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-current-password"
|
||||
className="input workspace-setting-input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="workspace-setting-row">
|
||||
<label className="workspace-setting-label" htmlFor="settings-new-password">
|
||||
<span className="workspace-setting-title">New Password</span>
|
||||
<span className="workspace-setting-desc">Minimum 8 characters</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-new-password"
|
||||
className="input workspace-setting-input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="workspace-setting-row">
|
||||
<label className="workspace-setting-label" htmlFor="settings-confirm-password">
|
||||
<span className="workspace-setting-title">Confirm New Password</span>
|
||||
</label>
|
||||
<input
|
||||
id="settings-confirm-password"
|
||||
className="input workspace-setting-input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{passwordMsg && (
|
||||
<div className={`workspace-alert ${passwordMsg.ok ? 'workspace-alert-success' : 'workspace-alert-error'}`}>
|
||||
{passwordMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="workspace-settings-footer">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void changePassword()}
|
||||
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{savingPassword ? 'Changing…' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate, useParams, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { apiFetch } from '../utils/api'
|
||||
import MyExams from './MyExams'
|
||||
import MyResults from './MyResults'
|
||||
import Settings from './Settings'
|
||||
|
||||
type TabKey = 'exams' | 'results' | 'settings'
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'exams', label: 'My Exams', icon: '◇' },
|
||||
{ key: 'results', label: 'Results', icon: '✦' },
|
||||
{ key: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
export default function Workspace() {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { tab } = useParams<{ tab: string }>()
|
||||
const activeTab = (tab as TabKey) ?? 'exams'
|
||||
const [examCount, setExamCount] = useState(0)
|
||||
const [resultCount, setResultCount] = useState(0)
|
||||
|
||||
const loadCounts = useCallback(async () => {
|
||||
try {
|
||||
const exams = await apiFetch<{ id: number }[]>('/api/exams')
|
||||
setExamCount(exams.length)
|
||||
const results = await apiFetch<{ results: unknown[]; total: number }>('/api/results')
|
||||
setResultCount(results.total)
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) void loadCounts()
|
||||
}, [user, loadCounts])
|
||||
|
||||
if (authLoading) return null
|
||||
if (!user) return <Navigate to="/" replace />
|
||||
|
||||
const setTab = (key: TabKey) => navigate(`/workspace/${key}`)
|
||||
|
||||
return (
|
||||
<div className="workspace-shell">
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="workspace-profile">
|
||||
<div className="workspace-avatar">
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="workspace-name">{user.displayName}</div>
|
||||
<div className="workspace-email">{user.email}</div>
|
||||
</div>
|
||||
|
||||
<nav className="workspace-nav">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={`workspace-nav-item${activeTab === t.key ? ' active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
<span className="workspace-nav-icon">{t.icon}</span>
|
||||
<span className="workspace-nav-label">{t.label}</span>
|
||||
{t.key === 'exams' && examCount > 0 && (
|
||||
<span className="workspace-nav-badge">{examCount}</span>
|
||||
)}
|
||||
{t.key === 'results' && resultCount > 0 && (
|
||||
<span className="workspace-nav-badge">{resultCount}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{activeTab === 'exams' && <MyExams />}
|
||||
{activeTab === 'results' && <MyResults />}
|
||||
{activeTab === 'settings' && <Settings />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+667
-23
@@ -316,29 +316,10 @@ select {
|
||||
margin: 0 auto 16px;
|
||||
border: 1.5px solid var(--ink-3);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
.dropzone-icon::before,
|
||||
.dropzone-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
.dropzone-icon::before {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.dropzone-icon::after {
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
transform-origin: left center;
|
||||
box-shadow: -12px 0 0 var(--ink-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.dropzone h3 {
|
||||
margin: 0 0 6px;
|
||||
@@ -2391,3 +2372,666 @@ select {
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Workspace
|
||||
============================================================ */
|
||||
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
padding: 28px 16px;
|
||||
position: sticky;
|
||||
top: 57px;
|
||||
height: calc(100vh - 57px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.workspace-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workspace-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.workspace-email {
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.workspace-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.workspace-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--r-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 100ms ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-nav-item:hover {
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-nav-item.active {
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-nav-icon {
|
||||
font-size: 15px;
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workspace-nav-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workspace-nav-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--paper-3);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
padding: 40px 48px 120px;
|
||||
max-width: 960px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.workspace-section {
|
||||
/* wrapper */
|
||||
}
|
||||
|
||||
.workspace-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.workspace-section-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workspace-section-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: clamp(28px, 4vw, 40px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Cards / List */
|
||||
.workspace-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--paper);
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
|
||||
.workspace-card:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.workspace-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-card-title {
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workspace-card-sub {
|
||||
font-size: 13px;
|
||||
color: var(--ink-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workspace-card-meta {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-4);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-delete-confirm {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.workspace-empty {
|
||||
text-align: center;
|
||||
padding: 64px 20px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.workspace-empty-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.workspace-empty-title {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: var(--ink-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.workspace-empty-desc {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.workspace-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.workspace-panel-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.workspace-panel-header h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.workspace-panel-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Settings form */
|
||||
.workspace-settings-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workspace-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workspace-setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.workspace-setting-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-setting-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.workspace-setting-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.workspace-setting-input {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.workspace-settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.workspace-alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--r-md);
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
animation: admin-fade-in 200ms ease;
|
||||
}
|
||||
|
||||
.workspace-alert-error {
|
||||
background: var(--wrong-weak);
|
||||
color: var(--wrong);
|
||||
border: 1px solid var(--wrong);
|
||||
}
|
||||
|
||||
.workspace-alert-success {
|
||||
background: var(--correct-weak);
|
||||
color: var(--correct);
|
||||
border: 1px solid var(--correct);
|
||||
}
|
||||
|
||||
.workspace-alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.workspace-alert-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Exam Editor
|
||||
============================================================ */
|
||||
|
||||
.exam-editor-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.exam-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
position: sticky;
|
||||
top: 57px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.exam-editor-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exam-editor-title-input {
|
||||
flex: 1;
|
||||
max-width: 480px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.exam-editor-title-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
|
||||
.exam-editor-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exam-editor-save-msg {
|
||||
font-size: 13px;
|
||||
color: var(--correct);
|
||||
}
|
||||
|
||||
.exam-editor-unsaved {
|
||||
font-size: 12.5px;
|
||||
color: var(--warn);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.exam-editor-alert {
|
||||
margin: 12px 24px 0;
|
||||
}
|
||||
|
||||
.exam-editor-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 300px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.exam-editor-body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exam-editor-sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 20px 16px;
|
||||
background: var(--paper);
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 57px - 57px);
|
||||
}
|
||||
|
||||
.exam-editor-main {
|
||||
padding: 32px 48px 120px;
|
||||
overflow-y: auto;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.exam-editor-q-remove {
|
||||
opacity: 0;
|
||||
font-size: 11px;
|
||||
color: var(--wrong);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover .exam-editor-q-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.exam-editor-q-remove:hover {
|
||||
background: var(--wrong-weak);
|
||||
}
|
||||
|
||||
.exam-editor-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.exam-editor-type-btn {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 100ms ease;
|
||||
}
|
||||
|
||||
.exam-editor-type-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Question Form Editor
|
||||
============================================================ */
|
||||
|
||||
.question-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.question-form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.question-form-section {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.question-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.question-form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.question-form-row-inline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.question-form-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.question-form-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.question-form-check input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.choice-editor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.choice-editor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.choice-editor-row .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.match-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.order-editor-controls {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Workspace / Editor Responsive
|
||||
============================================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workspace-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workspace-sidebar {
|
||||
position: relative;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 16px;
|
||||
}
|
||||
.workspace-profile {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.workspace-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.workspace-nav {
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
.workspace-nav-item {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.workspace-nav-label {
|
||||
display: none;
|
||||
}
|
||||
.workspace-main {
|
||||
padding: 24px 20px 80px;
|
||||
}
|
||||
.exam-editor-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.exam-editor-sidebar {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.exam-editor-main {
|
||||
padding: 24px 20px 80px;
|
||||
}
|
||||
.exam-editor-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.exam-editor-title-input {
|
||||
max-width: 100%;
|
||||
}
|
||||
.match-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.question-form-row-inline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workspace-setting-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.workspace-setting-input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export interface Question {
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
id?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
author?: string;
|
||||
|
||||
@@ -5,3 +5,18 @@ export function slugify(title: string): string {
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function getExamUrlId(exam: { id?: string; title: string }): string {
|
||||
const slug = slugify(exam.title)
|
||||
if (exam.id) return `${slug}-${exam.id}`
|
||||
return slug
|
||||
}
|
||||
|
||||
export function extractExamSlug(examId: string): string {
|
||||
const lastDash = examId.lastIndexOf('-')
|
||||
if (lastDash === -1) return examId
|
||||
// Check if the part after the last dash looks like a generated id (8+ chars of a-z0-9)
|
||||
const suffix = examId.slice(lastDash + 1)
|
||||
if (/^[a-z0-9]{8,}$/.test(suffix)) return examId.slice(0, lastDash)
|
||||
return examId
|
||||
}
|
||||
@@ -13,4 +13,14 @@ export const validateExam = (data: unknown): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
export const parseExam = (data: unknown): Exam => data as Exam
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
export const parseExam = (data: unknown): Exam => {
|
||||
const exam = data as Exam
|
||||
if (!exam.id) {
|
||||
exam.id = generateId()
|
||||
}
|
||||
return exam
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||
import babel from "@rolldown/plugin-babel";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
Reference in new issue
Block a user