Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results
# Conflicts: # src/AppRoutes.tsx # src/pages/exam/index.ts # src/services/exam/examRepository.ts # src/types/exam.types.ts
This commit is contained in:
@@ -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<string, 'default' | 'info' | 'success' | 'warning'> =
|
||||
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<string | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [dialog, setDialog] = useState<TemplateDialogState>(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<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 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 (
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Stack spacing={4}>
|
||||
@@ -108,11 +229,11 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
Exam Marker
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||
Build a template for an exam paper, then run marking batches against your classes.
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
||||
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -132,73 +253,128 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||
<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>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{templates.map((t) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||
<Tooltip title="Archive">
|
||||
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||
<ArchiveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</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">
|
||||
{new Date(t.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Stack spacing={3}>
|
||||
{groupedTemplates.map((group) => (
|
||||
<Box key={group.key}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="h6">{group.label}</Typography>
|
||||
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||
</Stack>
|
||||
<Grid container spacing={3}>
|
||||
{group.templates.map((t) => {
|
||||
const parsed = splitTemplateTitle(t.title);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
transition: 'box-shadow 120ms',
|
||||
'&:hover': { boxShadow: 6 },
|
||||
}}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="Rename / edit version">
|
||||
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate as new version">
|
||||
<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>
|
||||
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||
startIcon={<GradingIcon fontSize="small" />}
|
||||
>
|
||||
Edit marks
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||
<DialogTitle>New exam template</DialogTitle>
|
||||
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
label="Template name"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
required
|
||||
helperText="User-facing name. Several templates can share the same paper."
|
||||
/>
|
||||
<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}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
disabled={dialog?.mode === 'duplicate'}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Creating…' : 'Create'}
|
||||
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
|
||||
import { logger } from '../../debugConfig';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type {
|
||||
ExamQuestion,
|
||||
ExamTemplateDetail,
|
||||
MarkScheme,
|
||||
MarkSchemeType,
|
||||
SpecPoint,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
type Drafts = Record<string, PartDraft>;
|
||||
|
||||
interface PartDraft {
|
||||
max_marks: string;
|
||||
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
|
||||
spec_ref: string;
|
||||
schemeType: MarkSchemeType;
|
||||
body: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
|
||||
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
|
||||
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
|
||||
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
|
||||
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
|
||||
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
|
||||
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
|
||||
];
|
||||
|
||||
function deriveSpecCode(template: ExamTemplateDetail | null): string {
|
||||
const examCode = template?.exam_code ?? '';
|
||||
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
|
||||
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
|
||||
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
|
||||
|
||||
if (type === 'levels' && Array.isArray(scheme?.levels)) {
|
||||
return {
|
||||
schemeType: 'levels',
|
||||
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'parts' && Array.isArray(scheme?.parts)) {
|
||||
return {
|
||||
schemeType: 'parts',
|
||||
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
|
||||
return {
|
||||
schemeType: 'checklist',
|
||||
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (type === 'free') {
|
||||
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
|
||||
}
|
||||
if (Array.isArray(scheme?.points)) {
|
||||
return {
|
||||
schemeType: 'points',
|
||||
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
return { schemeType: type, body: '', notes };
|
||||
}
|
||||
|
||||
function parseNumber(value: string, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function splitLine(line: string): string[] {
|
||||
return line.split('|').map((part) => part.trim());
|
||||
}
|
||||
|
||||
function draftToScheme(draft: PartDraft): MarkScheme {
|
||||
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
|
||||
if (draft.schemeType === 'levels') {
|
||||
return {
|
||||
type: 'levels',
|
||||
levels: lines.map((line, idx) => {
|
||||
const [level, range, descriptor] = splitLine(line);
|
||||
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
|
||||
return {
|
||||
level: level || `L${idx + 1}`,
|
||||
min: parseNumber(minRaw, 0),
|
||||
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
|
||||
descriptor: descriptor || line,
|
||||
};
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
if (draft.schemeType === 'parts') {
|
||||
return {
|
||||
type: 'parts',
|
||||
parts: lines.map((line, idx) => {
|
||||
const [label, marks, guidance] = splitLine(line);
|
||||
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
if (draft.schemeType === 'checklist') {
|
||||
return {
|
||||
type: 'checklist',
|
||||
checklist: lines.map((line) => {
|
||||
const [marks, text] = splitLine(line);
|
||||
return { marks: parseNumber(marks, 1), text: text || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'points',
|
||||
points: lines.map((line) => {
|
||||
const [mark, text] = splitLine(line);
|
||||
return { mark: parseNumber(mark, 1), text: text || line };
|
||||
}),
|
||||
notes: draft.notes || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDraft(question: ExamQuestion): PartDraft {
|
||||
const scheme = schemeToDraft(question.mark_scheme);
|
||||
return {
|
||||
max_marks: String(question.max_marks ?? 0),
|
||||
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
|
||||
spec_ref: question.spec_ref ?? '',
|
||||
schemeType: scheme.schemeType,
|
||||
body: scheme.body,
|
||||
notes: scheme.notes,
|
||||
};
|
||||
}
|
||||
|
||||
const MarkSchemePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { templateId } = useParams<{ templateId: string }>();
|
||||
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Drafts>({});
|
||||
const [specCode, setSpecCode] = useState('');
|
||||
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!templateId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const detail = await examRepository.getTemplate(templateId);
|
||||
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||
setTemplate(detail);
|
||||
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
|
||||
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
|
||||
setSpecCode((prev) => prev || deriveSpecCode(detail));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const loadSpecPoints = useCallback(async () => {
|
||||
if (!specCode.trim()) return;
|
||||
setSpecEndpointMissing(false);
|
||||
try {
|
||||
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
|
||||
setSpecEndpointMissing(true);
|
||||
setSpecPoints([]);
|
||||
}
|
||||
}, [specCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSpecPoints();
|
||||
}, [loadSpecPoints]);
|
||||
|
||||
const parts = useMemo(
|
||||
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
|
||||
[template],
|
||||
);
|
||||
const selected = parts.find((p) => p.id === selectedId) ?? null;
|
||||
const draft = selected ? drafts[selected.id] : null;
|
||||
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
|
||||
|
||||
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
|
||||
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
|
||||
};
|
||||
|
||||
const savePart = async (question: ExamQuestion) => {
|
||||
const partDraft = drafts[question.id];
|
||||
if (!partDraft) return;
|
||||
setSavingId(question.id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await examRepository.patchQuestion(question.id, {
|
||||
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||
answer_type: partDraft.answer_type,
|
||||
mark_scheme: draftToScheme(partDraft),
|
||||
spec_ref: partDraft.spec_ref.trim() || null,
|
||||
});
|
||||
setTemplate((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
questions: prev.questions.map((q) => q.id === question.id ? {
|
||||
...q,
|
||||
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||
answer_type: partDraft.answer_type,
|
||||
mark_scheme: draftToScheme(partDraft),
|
||||
spec_ref: partDraft.spec_ref.trim() || null,
|
||||
} : q),
|
||||
};
|
||||
});
|
||||
setNotice(`Saved mark scheme for Part ${question.label}.`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const syncGraph = async () => {
|
||||
if (!templateId) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await examRepository.syncTemplateToGraph(templateId);
|
||||
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||
Back to templates
|
||||
</Button>
|
||||
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
|
||||
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
|
||||
{syncing ? 'Syncing…' : 'Sync to graph'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
|
||||
{specEndpointMissing && (
|
||||
<Alert severity="warning">
|
||||
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="Specification code"
|
||||
value={specCode}
|
||||
onChange={(e) => setSpecCode(e.target.value)}
|
||||
fullWidth
|
||||
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint).
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{parts.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
|
||||
</Alert>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Parts</Typography>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
value={selectedId ?? false}
|
||||
onChange={(_, value) => setSelectedId(value)}
|
||||
variant="scrollable"
|
||||
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
|
||||
>
|
||||
{parts.map((part) => (
|
||||
<Tab
|
||||
key={part.id}
|
||||
value={part.id}
|
||||
label={
|
||||
<Box sx={{ textAlign: 'left', width: '100%' }}>
|
||||
<Typography variant="body2">Part {part.label}</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
|
||||
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={9}>
|
||||
{selected && draft && (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={3}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="h5">Part {selected.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={savingId === selected.id}
|
||||
onClick={() => savePart(selected)}
|
||||
>
|
||||
{savingId === selected.id ? 'Saving…' : 'Save part'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Max marks"
|
||||
value={draft.max_marks}
|
||||
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
|
||||
type="number"
|
||||
inputProps={{ min: 0, step: 0.5 }}
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="answer-type-label">Answer type</InputLabel>
|
||||
<Select
|
||||
labelId="answer-type-label"
|
||||
label="Answer type"
|
||||
value={draft.answer_type}
|
||||
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
|
||||
>
|
||||
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
|
||||
<Select
|
||||
labelId="scheme-type-label"
|
||||
label="Scheme form"
|
||||
value={draft.schemeType}
|
||||
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
|
||||
>
|
||||
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Autocomplete
|
||||
options={specPoints}
|
||||
value={selectedSpecPoint}
|
||||
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
|
||||
getOptionLabel={(option) => `${option.ref} — ${option.description}`}
|
||||
isOptionEqualToValue={(option, value) => option.ref === value.ref}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="SpecPoint picker"
|
||||
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="spec_ref"
|
||||
value={draft.spec_ref}
|
||||
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
|
||||
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TextField
|
||||
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
|
||||
value={draft.body}
|
||||
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
|
||||
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
|
||||
multiline
|
||||
minRows={10}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Internal marking notes"
|
||||
value={draft.notes}
|
||||
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkSchemePage;
|
||||
@@ -1,4 +1,6 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
||||
export { default as ExamResultsPage } from './ExamResultsPage';
|
||||
export { default as ResultsWidget } from './ResultsWidget';
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
||||
import SaveIcon from '@mui/icons-material/Save'
|
||||
import MouseIcon from '@mui/icons-material/Mouse'
|
||||
import '@tldraw/tldraw/tldraw.css'
|
||||
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||
import axios from 'axios'
|
||||
|
||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||
import { logger } from '../../../debugConfig'
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||
|
||||
const TOOLS = [
|
||||
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const },
|
||||
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const },
|
||||
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const },
|
||||
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const },
|
||||
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const },
|
||||
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const },
|
||||
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
|
||||
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
|
||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
|
||||
]
|
||||
|
||||
const PAGE_START_X = 0
|
||||
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||
|
||||
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||
let y = 0
|
||||
return pages.map((page) => {
|
||||
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||
y += page.height
|
||||
return geometry
|
||||
})
|
||||
}
|
||||
|
||||
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
|
||||
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
|
||||
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
|
||||
editor.setCameraOptions({
|
||||
constraints: {
|
||||
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
|
||||
padding: { x: 64, y: 64 },
|
||||
origin: { x: 0.5, y: 0 },
|
||||
initialZoom: 'fit-x-100',
|
||||
baseZoom: 'default',
|
||||
behavior: 'contain',
|
||||
},
|
||||
isLocked: false,
|
||||
})
|
||||
}
|
||||
|
||||
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
|
||||
return { conflict: false, message: detail ?? err.message }
|
||||
}
|
||||
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
|
||||
function stripShapePrefix(id: string) {
|
||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||
}
|
||||
|
||||
function domainIdForShape(shape: ExamCanvasTLShape): string {
|
||||
const fromProps = shape.props.domainId
|
||||
if (isUuid(fromProps)) return fromProps
|
||||
const fromShapeId = stripShapePrefix(shape.id)
|
||||
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
|
||||
}
|
||||
|
||||
function ensureDomainIds(editor: Editor) {
|
||||
const updates = editor.getCurrentPageShapes()
|
||||
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
|
||||
.filter((shape) => !isUuid(shape.props.domainId))
|
||||
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
|
||||
if (updates.length) editor.updateShapes(updates)
|
||||
}
|
||||
|
||||
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||
const kind = shapeTypeToKind(shape.type)
|
||||
if (!kind) return null
|
||||
const s = shape as ExamCanvasTLShape
|
||||
return {
|
||||
id: domainIdForShape(s),
|
||||
kind,
|
||||
x: Number(s.x ?? 0),
|
||||
y: Number(s.y ?? 0),
|
||||
w: Number(s.props.w ?? 1),
|
||||
h: Number(s.props.h ?? 1),
|
||||
label: s.props.label,
|
||||
maxMarks: s.props.maxMarks,
|
||||
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||
contextType: s.props.contextType,
|
||||
questionId: s.props.questionId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function bringDomainShapesToFront(editor: Editor) {
|
||||
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
|
||||
}
|
||||
|
||||
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||
if (!models.length) return
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
editor.createShapes(models.map((m) => ({
|
||||
id: createShapeId(m.id),
|
||||
type: SHAPE_TYPES[m.kind],
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id },
|
||||
})))
|
||||
}
|
||||
|
||||
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
if (!pages.length) return
|
||||
const geometries = pageGeometryFromImages(pages)
|
||||
editor.createShapes(geometries.map((geometry) => {
|
||||
const page = pages[geometry.pageNumber - 1]
|
||||
return {
|
||||
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
|
||||
type: PDF_PAGE_SHAPE_TYPE,
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
isLocked: true,
|
||||
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||
} as any
|
||||
}))
|
||||
// z-order is enforced by the caller via bringDomainShapesToFront
|
||||
}
|
||||
|
||||
function seedGuide(editor: Editor) {
|
||||
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||
if (current.length) return
|
||||
editor.createShapes([
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
|
||||
])
|
||||
}
|
||||
|
||||
const ExamTemplateSetupInner: React.FC = () => {
|
||||
const { templateId } = useParams<{ templateId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [activeTool, setActiveTool] = useState('select')
|
||||
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!templateId) return
|
||||
setLoading(true); setError(null); setConflict(null)
|
||||
try {
|
||||
const detail = await examRepository.getTemplate(templateId)
|
||||
setTemplate(detail)
|
||||
let pages: PdfPageImage[] = []
|
||||
setPdfStatus('loading')
|
||||
setPdfError(null)
|
||||
try {
|
||||
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
|
||||
const newPage = partialPages[partialPages.length - 1]
|
||||
const allGeometries = pageGeometryFromImages(partialPages)
|
||||
pageGeometriesRef.current = allGeometries
|
||||
const ed = editorRef.current
|
||||
if (ed) {
|
||||
const geometry = allGeometries[partialPages.length - 1]
|
||||
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
||||
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
|
||||
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
||||
bringDomainShapesToFront(ed)
|
||||
}
|
||||
applyDocViewConstraints(ed, partialPages)
|
||||
}
|
||||
setPdfStatus('ready')
|
||||
})
|
||||
setPdfStatus(pages.length ? 'ready' : 'missing')
|
||||
} catch (pdfErr) {
|
||||
const pdfMsg = apiMessage(pdfErr).message
|
||||
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
|
||||
setPdfError(pdfMsg)
|
||||
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
|
||||
}
|
||||
const geometries = pageGeometryFromImages(pages)
|
||||
pageGeometriesRef.current = geometries
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
syncPdfPages(editor, pages)
|
||||
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||
bringDomainShapesToFront(editor)
|
||||
applyDocViewConstraints(editor, pages)
|
||||
editor.resetZoom()
|
||||
}
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e).message
|
||||
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [templateId])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !templateId || !template) return
|
||||
setSaving(true); setError(null); setConflict(null)
|
||||
try {
|
||||
ensureDomainIds(editor)
|
||||
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
|
||||
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||
setTemplate(saved)
|
||||
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e)
|
||||
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [template, templateId])
|
||||
|
||||
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||
<Button
|
||||
size="small"
|
||||
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||
color={tool.color}
|
||||
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
||||
setActiveTool(tool.id)
|
||||
}}
|
||||
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
||||
>
|
||||
{tool.label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)), [activeTool])
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Top bar — single compact line */}
|
||||
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
||||
<Tooltip title="Back to exam marker">
|
||||
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Body row */}
|
||||
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
|
||||
{/* Left tool sidebar */}
|
||||
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||
<Stack spacing={1}>{toolButtons}</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Canvas area */}
|
||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
||||
<Tldraw
|
||||
shapeUtils={examCanvasShapeUtils as any}
|
||||
tools={examCanvasTools as any}
|
||||
hideUi
|
||||
inferDarkMode={theme.palette.mode === 'dark'}
|
||||
autoFocus
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||
applyDocViewConstraints(editor, [])
|
||||
editor.resetZoom()
|
||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||
bringDomainShapesToFront(editor)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Guide toggle */}
|
||||
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Guide panel — collapsible */}
|
||||
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
||||
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
||||
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
||||
const p = canvasShapePalette[kind]
|
||||
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
||||
})}
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
||||
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
{/* Conflict alert */}
|
||||
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const ExamTemplateSetupPage: React.FC = () => (
|
||||
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
||||
<ExamTemplateSetupInner />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
export default ExamTemplateSetupPage
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
import React from 'react'
|
||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||
|
||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||
|
||||
export const SHAPE_TYPES = {
|
||||
boundary: 'exam-boundary',
|
||||
part: 'exam-part',
|
||||
response: 'exam-region-response',
|
||||
context: 'exam-region-context',
|
||||
question_number: 'exam-region-question-number',
|
||||
mark_area: 'exam-region-mark-area',
|
||||
reference: 'exam-region-reference',
|
||||
furniture: 'exam-region-furniture',
|
||||
} as const
|
||||
|
||||
export type ExamPdfPageTLShape = TLBaseBoxShape & {
|
||||
type: typeof PDF_PAGE_SHAPE_TYPE
|
||||
props: { w: number; h: number; src: string; pageNumber: number }
|
||||
}
|
||||
|
||||
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
||||
props: {
|
||||
w: number
|
||||
h: number
|
||||
label: string
|
||||
kind: ExamCanvasShapeKind
|
||||
maxMarks?: number
|
||||
responseForm?: string
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
domainId?: string
|
||||
}
|
||||
}
|
||||
|
||||
type CanvasPaletteEntry = {
|
||||
stroke: string
|
||||
fill: string
|
||||
darkStroke: string
|
||||
darkFill: string
|
||||
dash?: string
|
||||
label: string
|
||||
icon: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
|
||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
|
||||
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
|
||||
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
|
||||
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
|
||||
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
|
||||
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
|
||||
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
|
||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
|
||||
}
|
||||
|
||||
const shapeCss = `
|
||||
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
|
||||
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
|
||||
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
||||
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||
`
|
||||
|
||||
function renderShape(shape: ExamCanvasTLShape) {
|
||||
const kind = shape.props.kind
|
||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||
const isBoundary = kind === 'boundary'
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||
<style>{shapeCss}</style>
|
||||
<div
|
||||
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||
style={{
|
||||
'--exam-light-stroke': p.stroke,
|
||||
'--exam-light-fill': p.fill,
|
||||
'--exam-dark-stroke': p.darkStroke,
|
||||
'--exam-dark-fill': p.darkFill,
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||
background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||
} as React.CSSProperties}
|
||||
aria-label={`${p.label}: ${p.role}`}
|
||||
title={`${p.label}: ${p.role}`}
|
||||
>
|
||||
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<span aria-hidden="true">{p.icon}</span>
|
||||
{shape.props.label || p.label}
|
||||
</span>
|
||||
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||||
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||
const p = canvasShapePalette[kind]
|
||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||
}
|
||||
|
||||
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }
|
||||
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||
static override type = PDF_PAGE_SHAPE_TYPE
|
||||
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||
override canEdit() { return false }
|
||||
override component(shape: ExamPdfPageTLShape) {
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||
}
|
||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
||||
|
||||
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
||||
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
|
||||
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
|
||||
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
|
||||
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
|
||||
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
|
||||
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
|
||||
|
||||
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
|
||||
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||
|
||||
export function isPdfPageShape(type: string): boolean {
|
||||
return type === PDF_PAGE_SHAPE_TYPE
|
||||
}
|
||||
|
||||
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as pdfjsLib from "pdfjs-dist"
|
||||
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
|
||||
|
||||
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
|
||||
|
||||
export interface PdfPageImage {
|
||||
pageNumber: number
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export async function loadPdfPageImages(
|
||||
pdfBytes: ArrayBuffer,
|
||||
targetWidth = PAGE_WIDTH,
|
||||
onPageReady?: (pages: PdfPageImage[]) => void,
|
||||
): Promise<PdfPageImage[]> {
|
||||
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
|
||||
const pages: PdfPageImage[] = []
|
||||
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
|
||||
// for a typical 36-page exam paper.
|
||||
const canvas = document.createElement("canvas")
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
const page = await pdf.getPage(pageNumber)
|
||||
const baseViewport = page.getViewport({ scale: 1 })
|
||||
const scale = targetWidth / baseViewport.width
|
||||
const viewport = page.getViewport({ scale })
|
||||
canvas.width = Math.ceil(viewport.width)
|
||||
canvas.height = Math.ceil(viewport.height)
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("Unable to create PDF render canvas")
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
await page.render({ canvasContext: context, viewport }).promise
|
||||
pages.push({
|
||||
pageNumber,
|
||||
src: canvas.toDataURL("image/png"),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
})
|
||||
onPageReady?.([...pages])
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
@@ -198,7 +198,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||
setActionError(null);
|
||||
const res = await apiPost(`/database/timetable/enrollment-requests/${requestId}/respond`, { status: action === 'approve' ? 'approved' : 'rejected' });
|
||||
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||
if (res.status === 'ok') load();
|
||||
else setActionError(res.detail || 'Action failed');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user