Compare commits

..
Author SHA1 Message Date
kcar fdbc19cf0d feat(exam): version templates on dashboard 2026-06-07 00:33:06 +01:00
8 changed files with 360 additions and 577 deletions
-1
View File
@@ -35,7 +35,6 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div>
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> })); vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> })); vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> })); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> })); vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> })); vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> })); vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
-2
View File
@@ -16,7 +16,6 @@ import AdminDashboard from './pages/auth/adminPage';
import PlatformAdminPage from './pages/auth/PlatformAdminPage'; import PlatformAdminPage from './pages/auth/PlatformAdminPage';
import TLDrawDevPage from './pages/tldraw/devPlayerPage'; import TLDrawDevPage from './pages/tldraw/devPlayerPage';
import DevPage from './pages/tldraw/devPage'; import DevPage from './pages/tldraw/devPage';
import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage';
import TeacherPlanner from './pages/react-flow/teacherPlanner'; import TeacherPlanner from './pages/react-flow/teacherPlanner';
import MorphicPage from './pages/morphicPage'; import MorphicPage from './pages/morphicPage';
import NotFound from './pages/user/NotFound'; import NotFound from './pages/user/NotFound';
@@ -187,7 +186,6 @@ const AppRoutes: React.FC = () => {
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} /> <Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} /> <Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} /> <Route path="/tldraw-dev" element={<TLDrawDevPage />} />
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
<Route path="/dev" element={<DevPage />} /> <Route path="/dev" element={<DevPage />} />
<Route path="/dev/upload-test" element={<SimpleUploadTest />} /> <Route path="/dev/upload-test" element={<SimpleUploadTest />} />
<Route path="/single-player" element={<SinglePlayerPage />} /> <Route path="/single-player" element={<SinglePlayerPage />} />
+224 -96
View File
@@ -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 { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -22,6 +22,8 @@ import {
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive'; import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository'; import { examRepository } from '../../services/exam/examRepository';
@@ -34,6 +36,51 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
archived: 'default', 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 ExamDashboardPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { bootstrapData } = useAuth(); const { bootstrapData } = useAuth();
@@ -43,10 +90,10 @@ const ExamDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [dialog, setDialog] = useState<TemplateDialogState>(null);
const [title, setTitle] = useState(''); const [templateName, setTemplateName] = useState('');
const [version, setVersion] = useState('v1');
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [sourcePdf, setSourcePdf] = useState<File | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -67,47 +114,99 @@ const ExamDashboardPage: React.FC = () => {
void load(); void load();
}, [load, instituteId]); }, [load, instituteId]);
const handleOpenCreate = () => { const groupedTemplates = useMemo(() => {
setCreateOpen(true); const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
}; 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 handleCloseCreate = () => { const openCreate = () => {
if (saving) return; setTemplateName('New template');
setCreateOpen(false); setVersion('v1');
setTitle('');
setSubject(''); setSubject('');
setSourcePdf(null); setDialog({ mode: 'create' });
}; };
const handleCreate = async () => { const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
if (!title.trim()) return; 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); setSaving(true);
try { try {
const created = await examRepository.createTemplate({ if (dialog.mode === 'create') {
title: title.trim(), const created = await examRepository.createTemplate({
subject: subject.trim() || undefined, title,
institute_id: instituteId, subject: subject.trim() || undefined,
source_pdf: sourcePdf, 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); setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setTitle(''); setDialog(null);
setSubject('');
setSourcePdf(null);
navigate(`/exam-marker/${created.id}/setup`);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(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); setError(msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleArchive = async (id: string, ev: React.MouseEvent) => { const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation(); 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 { try {
await examRepository.archiveTemplate(id); await examRepository.archiveTemplate(template.id);
setTemplates((prev) => prev.filter((t) => t.id !== id)); setTemplates((prev) => prev.filter((t) => t.id !== template.id));
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg }); logger.error('cc-exam-marker', 'Archive failed', { message: msg });
@@ -115,6 +214,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 ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={4}> <Stack spacing={4}>
@@ -123,11 +228,11 @@ const ExamDashboardPage: React.FC = () => {
<Typography variant="h3" component="h1" gutterBottom> <Typography variant="h3" component="h1" gutterBottom>
Exam Marker Exam Marker
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}> <Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
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.
</Typography> </Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}> <Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template New template
</Button> </Button>
</Box> </Box>
@@ -147,95 +252,118 @@ const ExamDashboardPage: React.FC = () => {
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} /> <AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="h6" gutterBottom>No exam templates yet</Typography> <Typography variant="h6" gutterBottom>No exam templates yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create your first template to start mapping an exam paper. Create your first named template to start mapping an exam paper.
</Typography> </Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}> <Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New template New template
</Button> </Button>
</Paper> </Paper>
) : ( ) : (
<Grid container spacing={3}> <Stack spacing={3}>
{templates.map((t) => ( {groupedTemplates.map((group) => (
<Grid item xs={12} sm={6} md={4} key={t.id}> <Box key={group.key}>
<Paper <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
elevation={2} <Typography variant="h6">{group.label}</Typography>
sx={{ <Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
p: 3, </Stack>
height: '100%', <Grid container spacing={3}>
cursor: 'pointer', {group.templates.map((t) => {
display: 'flex', const parsed = splitTemplateTitle(t.title);
flexDirection: 'column', return (
gap: 1, <Grid item xs={12} sm={6} md={4} key={t.id}>
transition: 'box-shadow 120ms', <Paper
'&:hover': { boxShadow: 6 }, elevation={2}
}} sx={{
onClick={() => navigate(`/exam-marker/${t.id}/setup`)} p: 3,
> height: '100%',
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}> cursor: 'pointer',
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography> display: 'flex',
<Tooltip title="Archive"> flexDirection: 'column',
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template"> gap: 1,
<ArchiveIcon fontSize="small" /> transition: 'box-shadow 120ms',
</IconButton> '&:hover': { boxShadow: 6 },
</Tooltip> }}
</Box> onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
{t.subject && ( >
<Typography variant="body2" color="text.secondary">{t.subject}</Typography> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
)} <Box sx={{ minWidth: 0 }}>
{t.exam_code && ( <Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography> <Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
)} </Box>
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Stack direction="row" spacing={0.5}>
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" /> <Tooltip title="Rename / edit version">
<Typography variant="caption" color="text.secondary"> <IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
{new Date(t.updated_at).toLocaleDateString()} <EditIcon fontSize="small" />
</Typography> </IconButton>
</Box> </Tooltip>
</Paper> <Tooltip title="Duplicate as new version">
</Grid> <IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Archive">
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
<ArchiveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
{t.subject && (
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
)}
{t.exam_code && (
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
)}
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
<Typography variant="caption" color="text.secondary">
Updated {new Date(t.updated_at).toLocaleDateString()}
</Typography>
</Box>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
))} ))}
</Grid> </Stack>
)} )}
</Stack> </Stack>
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm"> <Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>New exam template</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
<TextField <TextField
label="Title" label="Template name"
value={title} value={templateName}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTemplateName(e.target.value)}
fullWidth fullWidth
autoFocus autoFocus
required required
helperText="User-facing name. Several templates can share the same paper."
/> />
<TextField <TextField
label="Subject" label="Version"
value={version}
onChange={(e) => setVersion(e.target.value)}
fullWidth
helperText="Stored in the template title until the API grows a dedicated version column."
/>
<TextField
label="Paper / subject label"
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
fullWidth fullWidth
disabled={dialog?.mode === 'duplicate'}
/> />
<Box>
<Button variant="outlined" component="label">
{sourcePdf ? 'Replace source PDF' : 'Attach source PDF'}
<input
hidden
type="file"
accept="application/pdf"
onChange={(e) => setSourcePdf(e.target.files?.[0] ?? null)}
/>
</Button>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'}
</Typography>
</Box>
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button> <Button onClick={closeDialog} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}> <Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
{saving ? 'Creating…' : 'Create'} {saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
-170
View File
@@ -1,170 +0,0 @@
import React, { useMemo, useRef, useState } from 'react'
import {
Tldraw,
createTLStore,
createTLSchemaFromUtils,
defaultBindingUtils,
defaultShapeUtils,
Editor,
TLAnyBindingUtilConstructor,
TLAnyShapeUtilConstructor,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
} from '@tldraw/tldraw'
import { allBindingUtils } from '../../utils/tldraw/bindings'
import { allShapeUtils } from '../../utils/tldraw/shapes'
import { customAssets } from '../../utils/tldraw/assets'
import { getUiComponents, getUiOverrides } from '../../utils/tldraw/ui-overrides'
import { HEADER_HEIGHT } from '../Layout'
import { devTools } from '../../utils/tldraw/tools'
import {
activateExamMarkerTool,
EXAM_MARKER_BOX_TYPE,
ExamMarkerBoxShapeUtil,
ExamMarkerBoxTool,
placeExamMarkerSpike,
} from '../../utils/tldraw/exam-marker-spike'
const spikeShapeUtils = [...allShapeUtils, ExamMarkerBoxShapeUtil] as TLAnyShapeUtilConstructor[]
const spikeBindingUtils = allBindingUtils as TLAnyBindingUtilConstructor[]
const spikeTools = [...devTools, ExamMarkerBoxTool]
const spikeSchema = createTLSchemaFromUtils({
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
bindingUtils: [...defaultBindingUtils, ...spikeBindingUtils],
})
export default function ExamMarkerSpikePage() {
const editorRef = useRef<Editor | null>(null)
const [status, setStatus] = useState('Ready to insert an image and a custom exam-marker box.')
const store = useMemo(() => {
const nextStore = createTLStore({
schema: spikeSchema,
shapeUtils: spikeShapeUtils,
bindingUtils: spikeBindingUtils,
})
;(nextStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
return nextStore
}, [])
const onMount = (editor: Editor) => {
editorRef.current = editor
}
const insertSpike = () => {
if (!editorRef.current) return
const { imageId, boxId } = placeExamMarkerSpike(editorRef.current)
const toolId = activateExamMarkerTool(editorRef.current)
setStatus(`Inserted image ${imageId} and custom box ${boxId}; active tool is ${toolId}.`)
}
const focusBox = () => {
if (!editorRef.current) return
const current = editorRef.current.getCurrentPageShapes().find((shape) => shape.type === EXAM_MARKER_BOX_TYPE)
if (!current) {
setStatus('No exam-marker box found yet.')
return
}
const bounds = editorRef.current.getShapePageBounds(current)
if (bounds) {
editorRef.current.zoomToBounds(bounds)
setStatus('Focused the camera on the exam-marker box using getShapePageBounds + zoomToBounds.')
}
}
const uiOverrides = getUiOverrides(false)
const uiComponents = getUiComponents(false)
return (
<div style={{ position: 'fixed', inset: 0, top: HEADER_HEIGHT, background: '#f8fafc' }}>
<div
style={{
position: 'absolute',
zIndex: 10,
top: 16,
left: 16,
display: 'flex',
gap: 12,
alignItems: 'center',
padding: '10px 12px',
borderRadius: 16,
background: 'rgba(15, 23, 42, 0.9)',
color: 'white',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.22)',
maxWidth: 880,
}}
>
<div style={{ display: 'grid', gap: 4 }}>
<strong style={{ fontSize: 14 }}>Exam-marker spike</strong>
<span style={{ fontSize: 12, opacity: 0.9 }}>{status}</span>
</div>
<button
onClick={insertSpike}
style={{
marginLeft: 'auto',
padding: '8px 12px',
borderRadius: 10,
border: 'none',
background: '#f59e0b',
color: '#111827',
fontWeight: 700,
cursor: 'pointer',
}}
>
Insert image + marker box
</button>
<button
onClick={() => {
if (!editorRef.current) return
const toolId = activateExamMarkerTool(editorRef.current)
setStatus(`Activated tool ${toolId}. Drag on the canvas to create another marker box.`)
}}
style={{
padding: '8px 12px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.2)',
background: 'transparent',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Activate box tool
</button>
<button
onClick={focusBox}
style={{
padding: '8px 12px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.2)',
background: 'transparent',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Focus marker
</button>
</div>
<Tldraw
store={store}
tools={spikeTools}
shapeUtils={spikeShapeUtils}
bindingUtils={spikeBindingUtils}
components={uiComponents}
overrides={uiOverrides}
assetUrls={customAssets}
autoFocus
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
onMount={onMount}
/>
</div>
)
}
+117 -18
View File
@@ -8,11 +8,16 @@
import axios from 'axios'; import axios from 'axios';
import { API_BASE } from '../../config/apiConfig'; import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import type { import type {
CreateTemplatePayload, CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate, ExamTemplate,
ExamTemplateDetail, ExamTemplateDetail,
UpdateTemplateMetaPayload,
} from '../../types/exam.types'; } from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`; const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -25,6 +30,86 @@ async function authHeaders(): Promise<Record<string, string>> {
return { Authorization: `Bearer ${session.access_token}` }; return { Authorization: `Bearer ${session.access_token}` };
} }
function newUuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
const v = c === 'x' ? r : (r % 4) + 8;
return v.toString(16);
});
}
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
return {
id: idMap?.get(q.id) ?? q.id,
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
label: q.label,
order: q.order,
max_marks: q.max_marks,
answer_type: q.answer_type,
mcq_options: q.mcq_options,
mark_scheme: q.mark_scheme ?? {},
is_container: q.is_container,
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
};
}
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : r.id,
question_id: idMap?.get(r.question_id) ?? r.question_id,
page: r.page,
bounds: r.bounds,
kind: r.kind,
response_form: r.response_form,
context_type: r.context_type ?? null,
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
};
}
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : b.id,
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
label: b.label,
page_index: b.page_index,
y: b.y,
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
};
}
async function replaceTemplate(
templateId: string,
detail: ExamTemplateDetail,
meta?: UpdateTemplateMetaPayload,
duplicateIds = false,
): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const idMap = new Map<string, string>();
if (duplicateIds) {
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
}
const res = await axios.put<ExamTemplateDetail>(
`${EXAM_BASE}/templates/${templateId}`,
{
meta,
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
},
{ headers },
);
return res.data;
}
export const examRepository = { export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> { async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders(); const headers = await authHeaders();
@@ -43,28 +128,42 @@ export const examRepository = {
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> { async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders(); const headers = await authHeaders();
if (payload.source_pdf) {
const form = new FormData();
form.append('title', payload.title);
if (payload.subject) form.append('subject', payload.subject);
if (payload.exam_id) form.append('exam_id', payload.exam_id);
if (payload.exam_code) form.append('exam_code', payload.exam_code);
if (payload.source_file_id) form.append('source_file_id', payload.source_file_id);
if (payload.page_count !== undefined) form.append('page_count', String(payload.page_count));
if (payload.institute_id) form.append('institute_id', payload.institute_id);
form.append('source_pdf', payload.source_pdf);
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, form, {
headers,
});
return res.data;
}
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers }); const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data; return res.data;
}, },
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
return res.data;
},
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
const detail = await this.getTemplate(templateId);
const created = await this.createTemplate({
title,
subject: detail.subject ?? undefined,
exam_id: detail.exam_id ?? undefined,
exam_code: detail.exam_code ?? undefined,
source_file_id: detail.source_file_id ?? undefined,
page_count: detail.page_count ?? undefined,
institute_id: detail.institute_id,
});
try {
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
} catch (error) {
try {
await this.archiveTemplate(created.id);
} catch (archiveError) {
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
templateId: created.id,
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
});
}
throw error;
}
},
async archiveTemplate(templateId: string): Promise<void> { async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders(); const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
+19 -2
View File
@@ -27,11 +27,17 @@ export interface CreateTemplatePayload {
exam_id?: string; exam_id?: string;
exam_code?: string; exam_code?: string;
source_file_id?: string; source_file_id?: string;
source_pdf?: File | null;
page_count?: number; page_count?: number;
institute_id?: string; institute_id?: string;
} }
export interface UpdateTemplateMetaPayload {
title?: string;
subject?: string | null;
page_count?: number | null;
status?: ExamTemplateStatus;
}
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */ /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export interface ExamQuestion { export interface ExamQuestion {
id: string; id: string;
@@ -45,16 +51,27 @@ export interface ExamQuestion {
mark_scheme: Record<string, unknown>; mark_scheme: Record<string, unknown>;
is_container: boolean; is_container: boolean;
spec_ref: string | null; spec_ref: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
} }
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
export interface ExamResponseArea { export interface ExamResponseArea {
id: string; id: string;
question_id: string; question_id: string;
template_id: string; template_id: string;
page: number; page: number;
bounds: Record<string, number>; bounds: Record<string, number>;
kind: 'response' | 'context'; kind: ExamResponseAreaKind;
response_form: string | null; response_form: string | null;
context_type?: string | null;
source: 'manual' | 'ai'; source: 'manual' | 'ai';
confirmed: boolean; confirmed: boolean;
confidence: number | null; confidence: number | null;
@@ -1,91 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
Editor,
createTLStore,
createTLSchemaFromUtils,
defaultBindingUtils,
defaultShapeUtils,
} from '@tldraw/tldraw'
import {
EXAM_MARKER_BOX_TYPE,
ExamMarkerBoxShapeUtil,
ExamMarkerBoxTool,
activateExamMarkerTool,
buildExamMarkerSvgDataUrl,
getExamMarkerHitTestPoints,
insertExamMarkerBox,
} from './exam-marker-spike'
const spikeShapeUtils = [ExamMarkerBoxShapeUtil]
const spikeSchema = createTLSchemaFromUtils({
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
bindingUtils: defaultBindingUtils,
})
if (typeof globalThis.matchMedia !== 'function') {
vi.stubGlobal('matchMedia', () => ({
matches: false,
media: '',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
} as unknown as MediaQueryList))
}
function makeEditor() {
const store = createTLStore({
schema: spikeSchema,
shapeUtils: spikeShapeUtils,
bindingUtils: defaultBindingUtils,
})
;(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
return new Editor({
shapeUtils: spikeShapeUtils,
bindingUtils: defaultBindingUtils,
tools: [ExamMarkerBoxTool],
store,
getContainer: () => document.body,
})
}
describe('exam-marker spike tldraw helpers', () => {
let editor: Editor
beforeEach(() => {
editor = makeEditor()
})
afterEach(() => {
editor.dispose()
})
it('creates a custom box shape and supports point hit-testing + page bounds', () => {
const boxId = insertExamMarkerBox(editor)
const box = editor.getShape(boxId)
expect(box?.type).toBe(EXAM_MARKER_BOX_TYPE)
expect(editor.getShapePageBounds(boxId)).toBeTruthy()
const boxPoint = getExamMarkerHitTestPoints(editor, boxId)
expect(boxPoint?.bounds).toBeTruthy()
expect(editor.getShapesAtPoint(boxPoint!.center).some((shape) => shape.id === boxId)).toBe(true)
const viewport = editor.getViewportPageBounds()
expect(viewport.w).toBeGreaterThan(0)
expect(viewport.h).toBeGreaterThan(0)
expect(() => editor.zoomToBounds(boxPoint!.bounds)).not.toThrow()
})
it('registers the custom BaseBoxShapeTool and emits a locked svg asset URL', () => {
expect(activateExamMarkerTool(editor)).toBe(EXAM_MARKER_BOX_TYPE)
expect(editor.getCurrentToolId()).toBe(EXAM_MARKER_BOX_TYPE)
const svgUrl = buildExamMarkerSvgDataUrl()
expect(svgUrl.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
expect(decodeURIComponent(svgUrl.split(',')[1])).toContain('Exam scan')
})
})
-197
View File
@@ -1,197 +0,0 @@
import React from 'react'
import {
BaseBoxShapeTool,
BaseBoxShapeUtil,
Editor,
HTMLContainer,
T,
TLBaseBoxShape,
TLShapeId,
createShapeId,
toDomPrecision,
} from '@tldraw/tldraw'
export const EXAM_MARKER_BOX_TYPE = 'exam-marker-box' as const
export type ExamMarkerBoxShape = TLBaseBoxShape & {
type: typeof EXAM_MARKER_BOX_TYPE
}
export class ExamMarkerBoxShapeUtil extends BaseBoxShapeUtil<ExamMarkerBoxShape> {
static override type = EXAM_MARKER_BOX_TYPE
static override props = {
w: T.number,
h: T.number,
}
override getDefaultProps(): ExamMarkerBoxShape['props'] {
return {
w: 320,
h: 180,
}
}
override component(shape: ExamMarkerBoxShape) {
return (
<HTMLContainer
id={shape.id}
style={{
width: toDomPrecision(shape.props.w),
height: toDomPrecision(shape.props.h),
border: '2px solid #b91c1c',
borderRadius: 14,
background: 'linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 242, 242, 0.96))',
boxShadow: '0 12px 24px rgba(185, 28, 28, 0.18)',
boxSizing: 'border-box',
overflow: 'hidden',
pointerEvents: 'all',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 12,
fontFamily: 'Inter, system-ui, sans-serif',
color: '#7f1d1d',
height: '100%',
}}
>
<div style={{ fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
Exam marker spike
</div>
<div style={{ fontSize: 14, lineHeight: 1.35 }}>
This custom box shape uses the app&apos;s tldraw schema + BaseBoxShapeTool path.
</div>
<div
style={{
marginTop: 'auto',
padding: '6px 8px',
borderRadius: 999,
alignSelf: 'flex-start',
background: 'rgba(185, 28, 28, 0.12)',
fontSize: 12,
fontWeight: 600,
}}
>
Drag to resize · use the spike tool to create more
</div>
</div>
</HTMLContainer>
)
}
}
export class ExamMarkerBoxTool extends BaseBoxShapeTool {
static override id = EXAM_MARKER_BOX_TYPE
static override initial = 'pointing'
shapeType = EXAM_MARKER_BOX_TYPE
}
export function buildExamMarkerSvgDataUrl(label = 'Exam scan') {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#f4f7fb"/>
</linearGradient>
</defs>
<rect width="960" height="720" rx="32" fill="url(#g)" stroke="#cbd5e1" stroke-width="6"/>
<rect x="72" y="56" width="816" height="88" rx="18" fill="#1f2937" opacity="0.92"/>
<text x="116" y="108" fill="#fff" font-size="42" font-family="Inter, Arial, sans-serif" font-weight="700">${label}</text>
<rect x="72" y="176" width="816" height="120" rx="16" fill="#eef2ff" stroke="#c7d2fe"/>
<text x="106" y="224" fill="#1e3a8a" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Question 1</text>
<text x="106" y="266" fill="#334155" font-size="24" font-family="Inter, Arial, sans-serif">Show your working and annotate the image.</text>
<rect x="72" y="332" width="816" height="250" rx="16" fill="#fff7ed" stroke="#fed7aa"/>
<text x="106" y="384" fill="#9a3412" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Answer space</text>
<rect x="106" y="420" width="700" height="18" rx="9" fill="#fdba74" opacity="0.75"/>
<rect x="106" y="470" width="640" height="18" rx="9" fill="#fdba74" opacity="0.55"/>
<rect x="106" y="520" width="570" height="18" rx="9" fill="#fdba74" opacity="0.4"/>
<text x="106" y="644" fill="#475569" font-size="20" font-family="Inter, Arial, sans-serif">Inserted as a built-in tldraw image shape.</text>
</svg>
`.trim()
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
function getViewportAnchor(editor: Editor) {
const bounds = editor.getViewportPageBounds()
return {
x: bounds.x + bounds.w / 2,
y: bounds.y + bounds.h / 2,
}
}
export function insertExamMarkerImage(editor: Editor) {
const anchor = getViewportAnchor(editor)
const imageId = createShapeId()
editor.createShape({
id: imageId,
type: 'image',
x: anchor.x - 320,
y: anchor.y - 240,
props: {
url: buildExamMarkerSvgDataUrl(),
w: 640,
h: 480,
name: 'exam-marker-sample.svg',
},
})
return imageId
}
export function insertExamMarkerBox(editor: Editor) {
const anchor = getViewportAnchor(editor)
const boxId = createShapeId()
editor.createShape<ExamMarkerBoxShape>({
id: boxId,
type: EXAM_MARKER_BOX_TYPE,
x: anchor.x + 180,
y: anchor.y - 120,
props: {
w: 320,
h: 180,
},
})
return boxId
}
export function placeExamMarkerSpike(editor: Editor) {
const imageId = insertExamMarkerImage(editor)
const boxId = insertExamMarkerBox(editor)
const box = editor.getShape(boxId)
if (box) {
const bounds = editor.getShapePageBounds(box)
if (bounds) editor.zoomToBounds(bounds)
}
return { imageId, boxId }
}
export function activateExamMarkerTool(editor: Editor) {
editor.setCurrentTool(EXAM_MARKER_BOX_TYPE)
return editor.getCurrentToolId()
}
export function getExamMarkerHitTestPoints(editor: Editor, shapeId: TLShapeId) {
const shape = editor.getShape(shapeId)
if (!shape) return null
const bounds = editor.getShapePageBounds(shape)
if (!bounds) return null
return {
center: {
x: bounds.x + bounds.w / 2,
y: bounds.y + bounds.h / 2,
},
bounds,
}
}