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 501060e..59616e3 100644
--- a/src/AppRoutes.admin.test.tsx
+++ b/src/AppRoutes.admin.test.tsx
@@ -33,6 +33,7 @@ vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single P
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
}));
vi.mock('./pages/exam', () => ({
ExamDashboardPage: () =>
Exam Marker
,
+ ExamTemplateSetupPage: () =>
Exam Template Setup
,
MarkSchemePage: () =>
Mark Scheme editor
,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
}));
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index 4c3330c..681a452 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, MarkSchemePage } from './pages/exam';
+import { ExamDashboardPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
@@ -183,6 +183,7 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx
index 5080792..7e3a7c7 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,8 @@ 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';
@@ -35,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();
@@ -44,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);
@@ -67,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 });
@@ -101,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 (
@@ -109,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
@@ -133,83 +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()}
+
+
+
+
+
+
+ );
+ })}
+
+
))}
-
+
)}
-