diff --git a/Dockerfile b/Dockerfile
index 0a5c0ba..7f87db7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,6 +19,9 @@ FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
+# .mjs files (pdfjs worker) must be served as application/javascript for module workers
+RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
+
# Create a simple nginx configuration
RUN echo 'server { \
listen 3000; \
diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx
index a4346e5..12fc485 100644
--- a/src/AppRoutes.admin.test.tsx
+++ b/src/AppRoutes.admin.test.tsx
@@ -31,7 +31,14 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
}));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
}));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
}));
-vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
}));
+vi.mock('./pages/exam', () => ({
+ ExamDashboardPage: () =>
Exam Marker
,
+ ExamTemplateSetupPage: () =>
Exam Template Setup
,
+ MarkSchemePage: () =>
Mark Scheme editor
,
+ ExamMarkingPage: () =>
Exam Marking
,
+ ExamResultsPage: () =>
Exam Results
,
+ ResultsWidget: () =>
Results Widget
,
+}));
vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
}));
vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
}));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
}));
@@ -122,3 +129,22 @@ describe('/admin route authorization', () => {
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
});
});
+
+
+describe('exam-marker routes', () => {
+ beforeEach(() => {
+ mockUseAuth.mockReset();
+ });
+
+ it('renders the mark scheme editor route for authenticated users', () => {
+ mockUseAuth.mockReturnValue(authState({
+ user: { id: 'teacher-1', email: 'teacher@example.com', user_type: 'email_teacher' },
+ user_role: 'email_teacher',
+ accessToken: 'token',
+ }));
+
+ renderAt('/exam-marker/template-123/marks');
+
+ expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
+ });
+});
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index e9d103a..f9139a2 100644
--- a/src/AppRoutes.tsx
+++ b/src/AppRoutes.tsx
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
-import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage } from './pages/exam';
+import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
@@ -184,6 +184,8 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx
index 500c45f..3595c31 100644
--- a/src/pages/exam/ExamDashboardPage.tsx
+++ b/src/pages/exam/ExamDashboardPage.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
@@ -22,6 +22,9 @@ import {
import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import EditIcon from '@mui/icons-material/Edit';
+import GradingIcon from '@mui/icons-material/Grading';
import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository';
@@ -34,6 +37,51 @@ const STATUS_COLOR: Record =
archived: 'default',
};
+const VERSION_SEPARATOR = ' · ';
+const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
+
+type DialogMode = 'create' | 'edit' | 'duplicate';
+
+type TemplateDialogState = {
+ mode: DialogMode;
+ template?: ExamTemplate;
+} | null;
+
+function splitTemplateTitle(title: string): { name: string; version: string } {
+ const parts = title.split(VERSION_SEPARATOR);
+ const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
+ if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
+ return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
+ }
+ return { name: title, version: 'v1' };
+}
+
+function composeTemplateTitle(name: string, version: string): string {
+ const cleanName = name.trim();
+ const cleanVersion = version.trim();
+ return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
+}
+
+function nextVersionLabel(version: string): string {
+ const match = version.trim().match(VERSION_RE);
+ if (!match) return 'v2';
+ const segments = match[1].split('.');
+ const last = Number(segments[segments.length - 1]);
+ segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
+ return `v${segments.join('.')}`;
+}
+
+function paperKey(t: ExamTemplate): string {
+ return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
+}
+
+function paperLabel(t: ExamTemplate): string {
+ if (t.exam_code) return t.exam_code;
+ if (t.subject) return t.subject;
+ if (t.source_file_id) return 'Uploaded paper';
+ return 'Custom paper';
+}
+
const ExamDashboardPage: React.FC = () => {
const navigate = useNavigate();
const { bootstrapData } = useAuth();
@@ -43,8 +91,9 @@ const ExamDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [createOpen, setCreateOpen] = useState(false);
- const [title, setTitle] = useState('');
+ const [dialog, setDialog] = useState(null);
+ const [templateName, setTemplateName] = useState('');
+ const [version, setVersion] = useState('v1');
const [subject, setSubject] = useState('');
const [saving, setSaving] = useState(false);
@@ -66,33 +115,99 @@ const ExamDashboardPage: React.FC = () => {
void load();
}, [load, instituteId]);
- const handleCreate = async () => {
- if (!title.trim()) return;
+ const groupedTemplates = useMemo(() => {
+ const groups = new Map();
+ templates.forEach((template) => {
+ const key = paperKey(template);
+ const existing = groups.get(key);
+ if (existing) {
+ existing.templates.push(template);
+ } else {
+ groups.set(key, { label: paperLabel(template), templates: [template] });
+ }
+ });
+ return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
+ }, [templates]);
+
+ const openCreate = () => {
+ setTemplateName('New template');
+ setVersion('v1');
+ setSubject('');
+ setDialog({ mode: 'create' });
+ };
+
+ const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
+ ev.stopPropagation();
+ const parsed = splitTemplateTitle(template.title);
+ setTemplateName(parsed.name);
+ setVersion(parsed.version);
+ setSubject(template.subject ?? '');
+ setDialog({ mode: 'edit', template });
+ };
+
+ const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
+ ev.stopPropagation();
+ const parsed = splitTemplateTitle(template.title);
+ setTemplateName(parsed.name);
+ setVersion(nextVersionLabel(parsed.version));
+ setSubject(template.subject ?? '');
+ setDialog({ mode: 'duplicate', template });
+ };
+
+ const closeDialog = () => {
+ if (!saving) setDialog(null);
+ };
+
+ const handleSaveDialog = async () => {
+ if (!dialog || !templateName.trim()) return;
+ const title = composeTemplateTitle(templateName, version);
setSaving(true);
try {
- const created = await examRepository.createTemplate({
- title: title.trim(),
- subject: subject.trim() || undefined,
- institute_id: instituteId,
+ if (dialog.mode === 'create') {
+ const created = await examRepository.createTemplate({
+ title,
+ subject: subject.trim() || undefined,
+ institute_id: instituteId,
+ });
+ setDialog(null);
+ navigate(`/exam-marker/${created.id}/setup`);
+ return;
+ }
+
+ if (!dialog.template) return;
+
+ if (dialog.mode === 'duplicate') {
+ const created = await examRepository.duplicateTemplate(dialog.template.id, title);
+ setTemplates((prev) => [created, ...prev]);
+ setDialog(null);
+ navigate(`/exam-marker/${created.id}/setup`);
+ return;
+ }
+
+ const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
+ title,
+ subject: subject.trim() || null,
});
- setCreateOpen(false);
- setTitle('');
- setSubject('');
- navigate(`/exam-marker/${created.id}/setup`);
+ setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
+ setDialog(null);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- logger.error('cc-exam-marker', 'Create template failed', { message: msg });
+ logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
setError(msg);
} finally {
setSaving(false);
}
};
- const handleArchive = async (id: string, ev: React.MouseEvent) => {
+ const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
+ const parsed = splitTemplateTitle(template.title);
+ if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
+ return;
+ }
try {
- await examRepository.archiveTemplate(id);
- setTemplates((prev) => prev.filter((t) => t.id !== id));
+ await examRepository.archiveTemplate(template.id);
+ setTemplates((prev) => prev.filter((t) => t.id !== template.id));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
@@ -100,6 +215,12 @@ const ExamDashboardPage: React.FC = () => {
}
};
+ const dialogTitle = dialog?.mode === 'edit'
+ ? 'Rename template / edit version'
+ : dialog?.mode === 'duplicate'
+ ? 'Duplicate as new version'
+ : 'New exam template';
+
return (
@@ -108,11 +229,11 @@ const ExamDashboardPage: React.FC = () => {
Exam Marker
-
- Build a template for an exam paper, then run marking batches against your classes.
+
+ Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
- } onClick={() => setCreateOpen(true)}>
+ } onClick={openCreate}>
New template
@@ -132,73 +253,128 @@ const ExamDashboardPage: React.FC = () => {
No exam templates yet
- Create your first template to start mapping an exam paper.
+ Create your first named template to start mapping an exam paper.
- } onClick={() => setCreateOpen(true)}>
+ } onClick={openCreate}>
New template
) : (
-
- {templates.map((t) => (
-
- navigate(`/exam-marker/${t.id}/setup`)}
- >
-
- {t.title}
-
- handleArchive(t.id, e)} aria-label="archive template">
-
-
-
-
- {t.subject && (
- {t.subject}
- )}
- {t.exam_code && (
- {t.exam_code}
- )}
-
-
-
- {new Date(t.updated_at).toLocaleDateString()}
-
-
-
-
+
+ {groupedTemplates.map((group) => (
+
+
+ {group.label}
+
+
+
+ {group.templates.map((t) => {
+ const parsed = splitTemplateTitle(t.title);
+ return (
+
+ navigate(`/exam-marker/${t.id}/setup`)}
+ >
+
+
+ {parsed.name}
+
+
+
+
+ openEdit(t, e)} aria-label="rename template">
+
+
+
+
+ openDuplicate(t, e)} aria-label="duplicate template">
+
+
+
+
+ handleArchive(t, e)} aria-label="archive template">
+
+
+
+
+
+ {t.subject && (
+ {t.subject}
+ )}
+ {t.exam_code && (
+ {t.exam_code}
+ )}
+
+
+
+ Updated {new Date(t.updated_at).toLocaleDateString()}
+
+
+
+
+
+
+
+ );
+ })}
+
+
))}
-
+
)}
-