Compare commits

..
Author SHA1 Message Date
kcar c6b459a6ca chore: preserve app workspace t_9be330a5 2026-08-09 20:24:44 +01:00
CC Worker adc7a2a05b merge: exam-marker app dashboard + /exam-marker route (S4-8)
app-ci-deploy / test-build-deploy (push) Has been cancelled
New ExamDashboardPage + examRepository seam; dashboard nav entry; removes the old
CCExamMarker viewer + dead CCExamMarkerPanel wiring (R1.1). Build green; route tests pass.
2026-06-06 19:50:23 +00:00
CC WorkerandClaude Opus 4.8 29554ebdbd feat(exam): Assessment dashboard + /exam-marker route; remove old CCExamMarker (S4-8)
- New examRepository (R2.1 seam): the only module talking to /api/exam (Supabase
  JWT → Bearer, axios to API_BASE). list/get/create/archive templates.
- ExamDashboardPage (/exam-marker): lists institute templates, create dialog,
  archive; cards link to setup (S4-9). Wrapped in ErrorBoundary (R6.4).
- exam.types.ts mirrors the API contract.
- Dashboard 'Exam Marker' quick action (top-level discovery, R1.3/R6.1).
  Note: the in-canvas TeacherNavigation is unsuitable for a nav section (it's
  the worker prev/next tab bar) — flagged; used the dashboard entry instead.
- R1.1 removal: deleted src/pages/tldraw/CCExamMarker/ (old 3-PDF viewer) and
  the now-dead CCExamMarkerPanel + its wiring in CCPanel/BasePanel (examMarkerProps
  was only ever passed by the deleted page).
- Fixed pre-existing broken AppRoutes test (missing TimetableListPage mock export).

Build green (vite); AppRoutes route tests pass; typecheck clean for new files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:47:02 +00:00
CC Worker 434643596e Merge fix/r6-myclasses: add selectedInstituteId dependency + silent error handling
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-02 23:28:37 +00:00
CC WorkerandClaude Sonnet 4.6 313ab724b8 fix(my-classes): add selectedInstituteId to fetch dependency, silence unhandled rejection
Re-fetch classes when NeoInstitute context changes. Catch fetchMyClasses rejection with
logger.warn so unhandled promise rejections don't break the component boundary silently.
Use CircularProgress (MUI) for loading skeleton.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 23:28:26 +00:00
CC WorkerandClaude Sonnet 4.6 ee7d7f7cac fix(timetable): fall through to empty state on fetch error — endpoint not yet in API
app-ci-deploy / test-build-deploy (push) Has been cancelled
GET /database/timetable/timetables returns 404 (endpoint not implemented). Treat any
fetch error as empty state so the page renders gracefully rather than showing error UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 22:33:41 +00:00
CC WorkerandClaude Sonnet 4.6 0dd18c4fd4 fix(timetable): use fetchTimetables instead of fetchMyTimetables — service stub unimplemented
app-ci-deploy / test-build-deploy (push) Has been cancelled
timetableOnlyService.getMyTimetables is referenced but never defined; calling it throws
'is not a function' at runtime. fetchTimetables calls the implemented listTimetables endpoint.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 22:15:58 +00:00
CC WorkerandClaude Sonnet 4.6 5869c741d7 fix(routing): add TimetableListPage for /timetable — detail view required :timetableId
app-ci-deploy / test-build-deploy (push) Has been cancelled
Bare /timetable routed to TimetablePage which called useParams<{timetableId}>() and
immediately hit !currentTimetable → "Error Loading Timetable". New TimetableListPage
uses fetchMyTimetables() from timetableStore. AppRoutes now: /timetable →
TimetableListPage, /timetable/:timetableId → TimetablePage.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 21:36:57 +00:00
CC Worker 5b6e461706 fix(canvas): separate store lifecycle from snapshot lifecycle — one store per user session prevents disposed-store crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
Root cause: disposing store while React's async state update (storeReady=false) hadn't
unmounted Tldraw yet caused tldraw's reactive signals to read currentPageId on a disposed store.

Fix: Effect 1 creates store once per user.id (never recreated on node navigation).
     Effect 2 loads/reloads snapshots when context.node changes, store stays alive.
     Store is only disposed when user changes or component unmounts — after setStoreReady(false)
     has had time to unmount Tldraw cleanly via React's synchronous cleanup.
2026-06-01 06:25:15 +00:00
CC Worker 53adc74a1c fix(canvas): defer store disposal until next store is ready — prevent tldraw reactive signals reading disposed store during async React unmount
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:18:24 +00:00
CC Worker 9a7cc86a75 fix(canvas): bypass localStoreService singleton — create store directly in effect to eliminate disposed-store reuse
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:16:07 +00:00
CC Worker 6cba932146 fix(canvas): move ensureStoreIsUsable() to AFTER snapshot load — loadSnapshot() overwrites TLINSTANCE before it could be pre-initialized
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:13:49 +00:00
CC Worker ba829be8e9 fix(canvas): call ensureStoreIsUsable() before tldraw mount — pre-initializes TLINSTANCE/page/camera records in empty store preventing currentPageId crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:10:11 +00:00
CC Worker 9f58917c39 fix(canvas): sanitize snapshot session.currentPageId before loadSnapshot — stale snapshots with missing pages caused currentPageId crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
- Pre-load: if session.currentPageId not in snapshot pages, reset to first available page
- Post-load: if store instance.currentPageId still invalid, clear store for fresh start
- On loadSnapshot error: clear store instead of silently failing with corrupt state
- Root cause: teacher1 had a saved snapshot with currentPageId pointing to a deleted page
2026-06-01 06:02:19 +00:00
CC Worker a375fbe37f fix(canvas): clear localStoreService singleton on unmount — disposed store was returned to next mount causing currentPageId crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 03:04:29 +00:00
CC Worker 38ca23dc3e fix(theme): add CssBaseline — MUI dark theme on Android was white text on white body background
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 02:52:41 +00:00
CC Worker 8fe8edb341 fix(auth): RequireAuth must use Outlet not children — React Router v6 requires Route guards to be Route elements
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 02:45:20 +00:00
29 changed files with 1313 additions and 1626 deletions
+3
View File
@@ -47,3 +47,6 @@ docker-compose.override.yml
# Local environment variants # Local environment variants
.env.dev .env.dev
.env.prod .env.prod
# Playwright test artifacts
test-results/
+2
View File
@@ -1,6 +1,7 @@
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { useMemo, useEffect } from 'react'; import { useMemo, useEffect } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles'; import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { useTLDraw } from './contexts/TLDrawContext'; import { useTLDraw } from './contexts/TLDrawContext';
import { theme } from './services/themeService'; import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
@@ -53,6 +54,7 @@ const App = React.memo(() => {
<TLDrawProvider> <TLDrawProvider>
<DarkModeSync /> <DarkModeSync />
<ThemeProvider theme={appTheme}> <ThemeProvider theme={appTheme}>
<CssBaseline />
<AppRoutes /> <AppRoutes />
</ThemeProvider> </ThemeProvider>
</TLDrawProvider> </TLDrawProvider>
+2 -1
View File
@@ -31,7 +31,7 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> })); vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> })); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> })); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => <div>Exam Marker</div> })); 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> }));
@@ -46,6 +46,7 @@ vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
})); }));
vi.mock('./pages/timetable', () => ({ vi.mock('./pages/timetable', () => ({
TimetablePage: () => <div>Timetable</div>, TimetablePage: () => <div>Timetable</div>,
TimetableListPage: () => <div>Timetable List</div>,
ClassesPage: () => <div>Classes</div>, ClassesPage: () => <div>Classes</div>,
LessonPage: () => <div>Lesson</div>, LessonPage: () => <div>Lesson</div>,
TaughtLessonsPage: () => <div>Taught Lessons</div>, TaughtLessonsPage: () => <div>Taught Lessons</div>,
+11 -7
View File
@@ -7,7 +7,8 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage'; import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser'; import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker'; import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage'; import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage'; import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas'; import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
@@ -30,6 +31,7 @@ import DashboardPage from './pages/user/dashboardPage';
// Timetable Module Pages // Timetable Module Pages
import { import {
TimetablePage, TimetablePage,
TimetableListPage,
ClassesPage, ClassesPage,
LessonPage, LessonPage,
TaughtLessonsPage, TaughtLessonsPage,
@@ -66,7 +68,7 @@ const FullContextRoutes: React.FC = () => {
return <Outlet />; return <Outlet />;
}; };
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => { const RequireAuth: React.FC = () => {
const { user, isAuthResolving } = useAuth(); const { user, isAuthResolving } = useAuth();
if (isAuthResolving) { if (isAuthResolving) {
@@ -88,7 +90,7 @@ const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
return <>{children}</>; return <Outlet />;
}; };
const AppRoutes: React.FC = () => { const AppRoutes: React.FC = () => {
@@ -159,10 +161,11 @@ const AppRoutes: React.FC = () => {
/> />
{/* Authentication only routes - only render if all contexts are initialized */} {/* Authentication only routes - only render if all contexts are initialized */}
<RequireAuth> <Route element={<RequireAuth />}>
<Route element={<FullContextRoutes />}> <Route element={<FullContextRoutes />}>
{/* Timetable Module Routes */} {/* Timetable Module Routes */}
<Route path="/timetable" element={<TimetablePage />} /> <Route path="/timetable" element={<TimetableListPage />} />
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
<Route path="/classes" element={<ClassesPage />} /> <Route path="/classes" element={<ClassesPage />} />
<Route path="/my-classes" element={<MyClassesPage />} /> <Route path="/my-classes" element={<MyClassesPage />} />
<Route path="/classes/:classId" element={<ClassDetailPage />} /> <Route path="/classes/:classId" element={<ClassDetailPage />} />
@@ -179,7 +182,8 @@ const AppRoutes: React.FC = () => {
{/* Existing Routes */} {/* Existing Routes */}
<Route path="/search" element={<SearxngPage />} /> <Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} /> <Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<CCExamMarker />} /> <Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
<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 />} />
@@ -190,7 +194,7 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} /> <Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
</RequireAuth> </Route>
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */} {/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} /> <Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
+209
View File
@@ -0,0 +1,209 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment';
import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository';
import type { ExamTemplate } from '../../types/exam.types';
import { logger } from '../../debugConfig';
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
draft: 'warning',
ready: 'success',
archived: 'default',
};
const ExamDashboardPage: React.FC = () => {
const navigate = useNavigate();
const { bootstrapData } = useAuth();
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [title, setTitle] = useState('');
const [subject, setSubject] = useState('');
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setTemplates(await examRepository.listTemplates());
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
setError(msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load, instituteId]);
const handleCreate = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const created = await examRepository.createTemplate({
title: title.trim(),
subject: subject.trim() || undefined,
institute_id: instituteId,
});
setCreateOpen(false);
setTitle('');
setSubject('');
navigate(`/exam-marker/${created.id}/setup`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
setError(msg);
} finally {
setSaving(false);
}
};
const handleArchive = async (id: string, ev: React.MouseEvent) => {
ev.stopPropagation();
try {
await examRepository.archiveTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
setError(msg);
}
};
return (
<Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={4}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<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>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New template
</Button>
</Box>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : templates.length === 0 ? (
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
<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.
</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
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>
))}
</Grid>
)}
</Stack>
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
<DialogTitle>New exam template</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
autoFocus
required
/>
<TextField
label="Subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
{saving ? 'Creating…' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default ExamDashboardPage;
+2
View File
@@ -0,0 +1,2 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
@@ -0,0 +1,263 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, defaultBindingUtils, 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 { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
const TOOLS = [
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
{ id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const },
{ id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const },
{ id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
{ id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const },
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const },
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const },
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
]
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 modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
const kind = shapeTypeToKind(shape.type)
if (!kind) return null
const s = shape as ExamCanvasTLShape
return {
id: s.props.domainId || stripShapePrefix(s.id),
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 loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!models.length) return
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, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
})))
}
function newDomainId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
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', domainId: newDomainId(), label: 'Q1 start' } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
])
}
const ExamTemplateSetupInner: React.FC = () => {
const { templateId } = useParams<{ templateId: string }>()
const navigate = useNavigate()
const theme = useTheme()
const editorRef = useRef<Editor | null>(null)
const pageLayoutsRef = useRef<PdfPageLayout[]>([])
const hydratingRef = useRef(false)
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true)
const [pdfLoading, setPdfLoading] = useState(false)
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
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 loadPdf = useCallback(async (id: string, editor: Editor) => {
setPdfLoading(true)
setPdfStatus('Loading PDF…')
try {
const result = await renderPdfBackdrop(id)
hydratingRef.current = true
replacePdfPageShapes(editor, result.pages)
pageLayoutsRef.current = result.pageLayouts
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
editor.zoomToFit()
} catch (e) {
pageLayoutsRef.current = []
const msg = apiMessage(e).message
setPdfStatus(`PDF unavailable: ${msg}`)
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
} finally {
hydratingRef.current = false
setPdfLoading(false)
setDirty(false)
}
}, [])
const load = useCallback(async () => {
if (!templateId) return
setLoading(true); setError(null); setConflict(null)
try {
const detail = await examRepository.getTemplate(templateId)
setTemplate(detail)
const editor = editorRef.current
if (editor) {
hydratingRef.current = true
loadShapes(editor, shapesFromTemplate(detail))
hydratingRef.current = false
await loadPdf(templateId, editor)
}
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)
}
}, [loadPdf, 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 {
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved)
hydratingRef.current = true
loadShapes(editor, shapesFromTemplate(saved))
hydratingRef.current = false
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" /> : undefined}
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' }}>
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
<Tldraw
shapeUtils={examCanvasShapeUtils as any}
bindingUtils={defaultBindingUtils}
tools={examCanvasTools as any}
hideUi
inferDarkMode={theme.palette.mode === 'dark'}
autoFocus
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
editor.store.listen((entry) => {
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
}, { scope: 'document' })
if (template) {
hydratingRef.current = true
loadShapes(editor, shapesFromTemplate(template))
hydratingRef.current = false
if (templateId) void loadPdf(templateId, editor)
} else seedGuide(editor)
}}
/>
</Box>
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
<Divider orientation="vertical" flexItem />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
<Typography variant="caption" color="text.secondary">Exam Marker Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
</Box>
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper>
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
<Stack spacing={1}>{toolButtons}</Stack>
</Paper>
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary">
1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
</Typography>
</Paper>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
<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
+123
View File
@@ -0,0 +1,123 @@
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 PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
w: number
h: number
src: string
pageNumber: number
}>
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
w: number
h: number
label: string
kind: ExamCanvasShapeKind
domainId: string
maxMarks?: number
responseForm?: string
contextType?: string
questionId?: string | null
}>
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
}
function makeDomainId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
)
}
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = palette[kind] ?? palette.response
const isBoundary = kind === 'boundary'
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<div style={{
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : p.fill, color: p.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 ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
}}>
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
{shape.props.label || p.label}
</span>
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
</div>
</HTMLContainer>
)
}
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = palette[kind]
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
}
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
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 canResize() { return false }
override hideResizeHandles() { return true }
override hideRotateHandle() { return true }
override component(shape: PdfPageTLShape) {
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img
alt={`PDF page ${shape.props.pageNumber}`}
src={shape.props.src}
draggable={false}
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
/>
</HTMLContainer>
)
}
}
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(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 shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
}
+75
View File
@@ -0,0 +1,75 @@
import type { Editor } from '@tldraw/tldraw'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
import { examRepository } from '../../../services/exam/examRepository'
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
export interface PdfBackdropPage {
page: number
src: string
layout: PdfPageLayout
}
export interface PdfBackdropResult {
pages: PdfBackdropPage[]
pageLayouts: PdfPageLayout[]
}
export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
const bytes = await examRepository.getTemplateSourcePdf(templateId)
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
const pdf = await loadingTask.promise
const pages: PdfBackdropPage[] = []
let y = 0
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber)
const nativeViewport = page.getViewport({ scale: 1 })
const layoutScale = PAGE_WIDTH / nativeViewport.width
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
const renderViewport = page.getViewport({ scale: renderScale })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
canvas.width = Math.ceil(renderViewport.width)
canvas.height = Math.ceil(renderViewport.height)
await page.render({ canvasContext: context, viewport: renderViewport }).promise
const layout: PdfPageLayout = {
page: pageNumber,
x: 0,
y,
w: PAGE_WIDTH,
h: nativeViewport.height * layoutScale,
}
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
y += layout.h + PAGE_GAP
}
await pdf.destroy()
return { pages, pageLayouts: pages.map((p) => p.layout) }
}
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
if (old.length) editor.deleteShapes(old)
if (!pages.length) return
editor.createShapes(pages.map((page) => ({
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
type: PDF_PAGE_SHAPE_TYPE,
x: page.layout.x,
y: page.layout.y,
isLocked: true,
props: {
w: page.layout.w,
h: page.layout.h,
src: page.src,
pageNumber: page.page,
},
})))
}
+20 -10
View File
@@ -3,9 +3,19 @@ import { Link } from 'react-router-dom';
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material'; import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore'; import useTimetableStore from '../../stores/timetableStore';
import { useUser } from '../../contexts/UserContext'; import { useUser } from '../../contexts/UserContext';
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
import { CircularProgress } from '@mui/material';
import { logger } from '../../debugConfig';
const MyClassesSkeleton = () => (
<div className="flex justify-center items-center h-64">
<CircularProgress />
</div>
);
const MyClassesPage: React.FC = () => { const MyClassesPage: React.FC = () => {
const { profile } = useUser(); const { profile } = useUser();
const { selectedInstituteId } = useNeoInstitute();
const { const {
myClasses, myClasses,
classesLoading: myClassesLoading, classesLoading: myClassesLoading,
@@ -14,8 +24,16 @@ const MyClassesPage: React.FC = () => {
} = useTimetableStore(); } = useTimetableStore();
useEffect(() => { useEffect(() => {
fetchMyClasses(); fetchMyClasses().catch((error) =>
}, [fetchMyClasses]); logger.warn('my-classes', 'Fetch my classes failed', {
message: error instanceof Error ? error.message : String(error),
})
);
}, [fetchMyClasses, selectedInstituteId]);
if (myClassesLoading) {
return <MyClassesSkeleton />;
}
const getRoleLabel = (role: string) => { const getRoleLabel = (role: string) => {
switch (role) { switch (role) {
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
} }
}; };
if (myClassesLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (myClassesError) { if (myClassesError) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
+76
View File
@@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
import { format, parseISO } from 'date-fns';
const TimetableListPage: React.FC = () => {
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
useEffect(() => {
fetchTimetables();
}, [fetchTimetables]);
if (timetablesLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
const showEmpty = timetablesError || timetables.length === 0;
return (
<div className="container mx-auto px-4 py-6 max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
</div>
</div>
{showEmpty ? (
<div className="text-center py-16">
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
<p className="mt-2 text-gray-500">
Timetables are created from a class. Go to{' '}
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
to get started.
</p>
</div>
) : (
<div className="space-y-3">
{timetables.map((timetable) => (
<Link
key={timetable.id}
to={`/timetable/${timetable.id}`}
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
>
<CalendarToday className="text-blue-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
<p className="text-sm text-gray-500">
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
{timetable.end_date
? ` ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
: ' (ongoing)'}
</p>
</div>
{timetable.recurrence_rule && (
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
Recurring
</span>
)}
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
</Link>
))}
</div>
)}
</div>
);
};
export default TimetableListPage;
+1
View File
@@ -3,6 +3,7 @@
*/ */
export { default as TimetablePage } from './TimetablePage'; export { default as TimetablePage } from './TimetablePage';
export { default as TimetableListPage } from './TimetableListPage';
export { default as ClassesPage } from './ClassesPage'; export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage'; export { default as LessonPage } from './LessonPage';
export { default as TaughtLessonsPage } from './TaughtLessonsPage'; export { default as TaughtLessonsPage } from './TaughtLessonsPage';
@@ -1,92 +0,0 @@
import { TLShapeId } from '@tldraw/tldraw';
export interface AnnotationData {
studentIndex?: number; // undefined for exam/markscheme annotations
pageIndex: number;
shapeId: TLShapeId;
bounds: {
x: number;
y: number;
width: number;
height: number;
};
}
export class AnnotationManager {
private examAnnotations: Set<TLShapeId> = new Set();
private markSchemeAnnotations: Set<TLShapeId> = new Set();
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
this.annotationData.set(shapeId, data);
if (data.studentIndex !== undefined) {
// Student response annotation
let studentSet = this.studentAnnotations.get(data.studentIndex);
if (!studentSet) {
studentSet = new Set();
this.studentAnnotations.set(data.studentIndex, studentSet);
}
studentSet.add(shapeId);
} else {
// Exam or mark scheme annotation
if (data.pageIndex < 0) {
this.examAnnotations.add(shapeId);
} else {
this.markSchemeAnnotations.add(shapeId);
}
}
}
removeAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (!data) return;
if (data.studentIndex !== undefined) {
const studentSet = this.studentAnnotations.get(data.studentIndex);
studentSet?.delete(shapeId);
} else {
if (data.pageIndex < 0) {
this.examAnnotations.delete(shapeId);
} else {
this.markSchemeAnnotations.delete(shapeId);
}
}
this.annotationData.delete(shapeId);
}
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
return Array.from(this.studentAnnotations.get(studentIndex) || []);
}
getAnnotationsForExam(): TLShapeId[] {
return Array.from(this.examAnnotations);
}
getAnnotationsForMarkScheme(): TLShapeId[] {
return Array.from(this.markSchemeAnnotations);
}
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
return this.annotationData.get(shapeId);
}
clear() {
this.examAnnotations.clear();
this.markSchemeAnnotations.clear();
this.studentAnnotations.clear();
this.annotationData.clear();
}
// Future transcription support
addTranscriptionToAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (data) {
this.annotationData.set(shapeId, {
...data
});
}
}
}
@@ -1,108 +0,0 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import 'tldraw/tldraw.css';
import { CCPdfEditor } from './CCPdfEditor';
import { CCPdfPicker } from './CCPdfPicker';
import { ExamPdfState } from './types';
import './cc-exam-marker.css';
import { HEADER_HEIGHT } from '../../Layout';
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
export const CCExamMarker = () => {
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
setView(newView);
};
const handleNextStudent = () => {
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
if (currentStudentIndex < totalStudents - 1) {
setCurrentStudentIndex(prev => prev + 1);
}
}
};
const handlePreviousStudent = () => {
if (currentStudentIndex > 0) {
setCurrentStudentIndex(prev => prev - 1);
}
};
return (
<Box sx={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
color: 'text.primary',
}}>
{state.phase === 'pick' ? (
<CCPdfPicker
onOpenPdfs={(pdfs) =>
setState({
phase: 'edit',
examPaper: pdfs.examPaper,
markScheme: pdfs.markScheme,
studentResponses: pdfs.studentResponses,
})
}
/>
) : (
<Box sx={{ flex: 1, position: 'relative' }}>
<Box sx={{
position: 'absolute',
inset: 0,
bgcolor: 'background.paper',
}}>
<CCPdfEditor
examPaper={state.examPaper}
markScheme={state.markScheme}
studentResponses={state.studentResponses}
currentView={view}
currentStudentIndex={currentStudentIndex}
onEditorMount={(editor) => {
if (!editor) return null;
const examMarkerProps = {
editor,
currentView: view,
onViewChange: handleViewChange,
currentStudentIndex,
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
onPreviousStudent: handlePreviousStudent,
onNextStudent: handleNextStudent,
getCurrentPdf: () => {
if (!editor) return null;
const currentPageId = editor.getCurrentPageId();
if (currentPageId.includes('exam-page')) {
return state.examPaper;
} else if (currentPageId.includes('mark-scheme-page')) {
return state.markScheme;
} else if (currentPageId.includes('student-response')) {
return state.studentResponses;
}
return null;
},
};
return <CCPanel
examMarkerProps={examMarkerProps}
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={setIsExpanded}
onPinnedChange={setIsPinned}
/>;
}}
/>
</Box>
</Box>
)}
</Box>
);
};
@@ -1,114 +0,0 @@
import { PDFDocument } from 'pdf-lib';
import { useState } from 'react';
import { Editor, exportToBlob } from '@tldraw/tldraw';
import { Button } from '@mui/material';
import { Pdf } from './types';
interface CCExportPdfButtonProps {
editor: Editor;
pdf: Pdf;
}
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void
) => {
const totalThings = pages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const pdf = await PDFDocument.load(source);
tickProgress();
const pdfPages = pdf.getPages();
if (pdfPages.length !== pages.length) {
throw new Error('PDF page count mismatch');
}
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
(id) => !pageShapeIds.has(id)
);
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const pdfPage = pdfPages[i];
const {bounds} = page;
const shapesInBounds = allIds.filter((id) => {
const shapePageBounds = editor.getShapePageBounds(id);
if (!shapePageBounds) return false;
return shapePageBounds.collides(bounds);
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
const exportedPng = await exportToBlob({
editor,
ids: allIds,
format: 'png',
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
});
tickProgress();
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
tickProgress();
}
const pdfBytes = await pdf.save();
const url = URL.createObjectURL(
new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
return (
<Button
className="CCExportPdfButton"
variant="contained"
color="primary"
onClick={async () => {
setExportProgress(0);
try {
await exportPdf(editor, pdf, setExportProgress);
} finally {
setExportProgress(null);
}
}}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 1000,
}}
>
{exportProgress
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export PDF'}
</Button>
);
}
@@ -1,385 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
import { Tldraw } from '@tldraw/tldraw';
import { useCallback, useEffect, useState, useRef } from 'react';
import { ExamPdfs } from './types';
import { AnnotationManager, AnnotationData } from './AnnotationManager';
import { logger } from '../../../debugConfig';
const PAGE_SPACING = 32; // Same spacing as the example
interface CCPdfEditorProps extends ExamPdfs {
currentView: 'exam-and-markscheme' | 'student-responses';
currentStudentIndex: number;
onEditorMount: (editor: Editor) => React.ReactNode;
}
export function CCPdfEditor({
examPaper,
markScheme,
studentResponses,
currentView,
currentStudentIndex,
onEditorMount,
}: CCPdfEditorProps) {
const [editor, setEditor] = useState<Editor | null>(null);
const [pagesInitialized, setPagesInitialized] = useState(false);
const annotationManager = useRef(new AnnotationManager());
const handleMount = useCallback((editor: Editor) => {
setEditor(editor);
onEditorMount(editor);
// Subscribe to shape changes
editor.on('change', () => {
const shapes = editor.getCurrentPageShapeIds();
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
totalShapes: shapes.size,
currentPage: editor.getCurrentPageId()
});
shapes.forEach(shapeId => {
const shape = editor.getShape(shapeId);
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
const bounds = editor.getShapePageBounds(shapeId);
if (bounds) {
const currentPageId = editor.getCurrentPageId();
let annotationData: AnnotationData;
if (currentPageId.includes('student-response')) {
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
// Find which page this annotation belongs to by checking collision with page bounds
const pageShapes = Array.from(shapes).filter(id => {
const s = editor.getShape(id);
return s?.isLocked; // Locked shapes are our PDF pages
});
let pageIndex = -1; // Default to -1 if no collision found
for (let i = 0; i < pageShapes.length; i++) {
const pageShape = editor.getShape(pageShapes[i]);
if (!pageShape) continue;
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
if (!pageBounds) continue;
// Check if the annotation's center point is within the page bounds
const annotationCenter = {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
};
if (annotationCenter.x >= pageBounds.x &&
annotationCenter.x <= pageBounds.x + pageBounds.width &&
annotationCenter.y >= pageBounds.y &&
annotationCenter.y <= pageBounds.y + pageBounds.height) {
pageIndex = i;
break;
}
}
logger.debug('cc-exam-marker', '📏 Calculated page index', {
shapeId,
shapeBounds: bounds,
pageIndex,
studentIndex
});
annotationData = {
studentIndex,
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
} else {
// For exam/mark scheme, use current page type as index
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
annotationData = {
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
}
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
shapeId,
annotationData,
currentPage: currentPageId
});
annotationManager.current.addAnnotation(shapeId, annotationData);
}
}
});
});
}, [onEditorMount]);
// Initial setup effect - runs only once when editor is mounted
useEffect(() => {
if (!editor || pagesInitialized) return;
const setupExamAndMarkScheme = async () => {
const examPageId = 'page:exam-page' as TLPageId;
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
// Calculate vertical layout for exam pages
let top = 0;
let widest = 0;
const examPages = examPaper.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
examPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create exam paper page
editor.createPage({
id: examPageId,
name: 'Exam Paper',
});
editor.setCurrentPage(examPageId);
// Create assets and shapes for exam pages
examPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Similar process for mark scheme pages
let markSchemeTop = 0;
const markSchemePages = markScheme.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = markSchemeTop;
markSchemeTop += height + PAGE_SPACING;
return {
...page,
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
};
});
// Create mark scheme page
editor.createPage({
id: markSchemePageId,
name: 'Mark Scheme',
});
editor.setCurrentPage(markSchemePageId);
// Create assets and shapes for mark scheme pages
markSchemePages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Go back to exam page
editor.setCurrentPage(examPageId);
};
const setupStudentResponses = async () => {
const pagesPerStudent = examPaper.pages.length;
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
// Calculate vertical layout
let top = 0;
let widest = 0;
const studentPages = studentResponses.pages
.slice(startPage, endPage)
.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
studentPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create page for this student
editor.createPage({
id: studentPageId,
name: `Student ${studentIndex + 1}`,
});
editor.setCurrentPage(studentPageId);
// Create assets and shapes
studentPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
}
};
// Initial setup of all pages
const setup = async () => {
await setupExamAndMarkScheme();
await setupStudentResponses();
setPagesInitialized(true);
};
setup();
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
// Effect to handle view changes and navigation
useEffect(() => {
if (!editor || !pagesInitialized) return;
// Switch to appropriate page based on current view
const targetPageId = currentView === 'exam-and-markscheme'
? ('page:exam-page' as TLPageId)
: (`page:student-response-${currentStudentIndex}` as TLPageId);
logger.debug('cc-exam-marker', '🔄 Switching view', {
currentView,
currentStudentIndex,
targetPageId
});
editor.setCurrentPage(targetPageId);
// Update camera constraints for current page
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
(acc: TLBox | null, shapeId) => {
const bounds = editor.getShapePageBounds(shapeId);
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
},
null as TLBox | null
);
if (currentPageBounds) {
const isMobile = editor.getViewportScreenBounds().width < 840;
editor.setCameraOptions({
constraints: {
bounds: currentPageBounds,
padding: { x: isMobile ? 16 : 164, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
});
editor.setCamera(editor.getCamera(), { reset: true });
}
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
// Expose annotationManager to parent through onEditorMount
useEffect(() => {
if (editor) {
onEditorMount(editor);
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
editor.annotationManager = annotationManager.current;
}
}, [editor, onEditorMount]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<Tldraw
onMount={handleMount}
components={{
InFrontOfTheCanvas: () => onEditorMount(editor!)
}}
/>
</Box>
);
}
@@ -1,210 +0,0 @@
import { useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
import { ExamPdfs, Pdf, PdfPage } from './types';
interface CCPdfPickerProps {
onOpenPdfs: (pdfs: ExamPdfs) => void;
}
const pageSpacing = 32;
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
const [isLoading, setIsLoading] = useState(false);
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
const PdfJS = await import('pdfjs-dist');
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const pdf = await PdfJS.getDocument(source.slice()).promise;
const pages: PdfPage[] = [];
const canvas = window.document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Failed to create canvas context');
const visualScale = 1.5;
const scale = window.devicePixelRatio;
let top = 0;
let widest = 0;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: scale * visualScale });
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderContext = {
canvasContext: context,
viewport,
};
await page.render(renderContext).promise;
const width = viewport.width / scale;
const height = viewport.height / scale;
pages.push({
src: canvas.toDataURL(),
bounds: new TLBox(0, top, width, height),
assetId: AssetRecordType.createId(),
shapeId: createShapeId(),
});
top += height + pageSpacing;
widest = Math.max(widest, width);
}
canvas.width = 0;
canvas.height = 0;
for (const page of pages) {
page.bounds.x = (widest - page.bounds.width) / 2;
}
return {
name,
pages,
source,
};
}
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
setIsLoading(true);
try {
const pdf = await loadPdf(file.name, await file.arrayBuffer());
// Validate student responses page count
if (type === 'studentResponses' && selectedPdfs.examPaper) {
const examPageCount = selectedPdfs.examPaper.pages.length;
if (pdf.pages.length % examPageCount !== 0) {
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
return;
}
}
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
} catch (error) {
console.error('Error loading PDF:', error);
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
} finally {
setIsLoading(false);
}
};
const createFileInput = (type: keyof ExamPdfs) => {
const input = window.document.createElement('input');
input.type = 'file';
input.accept = 'application/pdf';
input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files;
if (!fileList || fileList.length === 0) return;
await handleFileSelect(type, fileList[0]);
});
input.click();
};
const allPdfsSelected = () => {
return (
selectedPdfs.examPaper &&
selectedPdfs.markScheme &&
selectedPdfs.studentResponses
);
};
if (isLoading) {
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Typography>Loading...</Typography>
</Box>
);
}
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Stack
spacing={4}
alignItems="center"
sx={{
maxWidth: '800px',
width: '100%',
p: 3
}}
>
<Typography variant="h5">Select PDF Files</Typography>
<Stack
direction="row"
sx={{
width: '100%',
justifyContent: 'center',
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
}}
>
<Button
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
onClick={() => createFileInput('examPaper')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
</Button>
<Button
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
onClick={() => createFileInput('markScheme')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
</Button>
<Button
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
onClick={() => createFileInput('studentResponses')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
</Button>
</Stack>
{allPdfsSelected() && (
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
color="primary"
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
sx={{
minWidth: '180px',
height: '48px'
}}
>
Continue
</Button>
</Box>
)}
</Stack>
</Box>
);
}
@@ -1,61 +0,0 @@
.CCExamMarker {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.CCExamMarker .CCPdfPicker {
position: absolute;
inset: 1rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.CCExamMarker .CCPdfBgRenderer {
position: absolute;
pointer-events: none;
}
.CCExamMarker .CCPdfBgRenderer img {
position: absolute;
}
.CCExamMarker .PageOverlayScreen-screen {
pointer-events: none;
z-index: -1;
fill: var(--color-background);
fill-opacity: 0.8;
stroke: none;
}
.CCExamMarker .PageOverlayScreen-outline {
position: absolute;
pointer-events: none;
z-index: -1;
box-shadow: var(--shadow-2);
}
.CCExamMarker .CCExportPdfButton {
font: inherit;
background: var(--color-primary);
border: none;
color: var(--color-selected-contrast);
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 6px;
margin: 6px;
margin-bottom: 0;
pointer-events: all;
z-index: var(--layer-panels);
border: 2px solid var(--color-background);
cursor: pointer;
}
.CCExamMarker .CCExportPdfButton:hover {
filter: brightness(1.1);
}
-44
View File
@@ -1,44 +0,0 @@
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
export interface PdfPage {
src: string;
bounds: Box;
assetId: TLAssetId;
shapeId: TLShapeId;
}
export interface Pdf {
name: string;
pages: PdfPage[];
source: string | ArrayBuffer;
}
export interface ExamPdfs {
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
}
export type ExamPdfState =
| {
phase: 'pick';
}
| {
phase: 'edit';
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
};
export interface StudentResponse {
studentId: string;
pageStart: number;
pageEnd: number;
}
export interface ExamMetadata {
totalPages: number;
pagesPerStudent: number;
totalStudents: number;
studentResponses: StudentResponse[];
}
+58 -43
View File
@@ -6,7 +6,8 @@ import {
useTldrawUser, useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore TLStore,
createTLStore,
} from '@tldraw/tldraw'; } from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext'; import { useTLDraw } from '../../contexts/TLDrawContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@@ -80,52 +81,31 @@ export default function SinglePlayerPage() {
setUserPreferences: setTldrawPreferences setUserPreferences: setTldrawPreferences
}); });
// Orchestrated context.node lifecycle — replaces 3 separate effects
const [canvasPhase, setCanvasPhase] = useState< const [canvasPhase, setCanvasPhase] = useState<
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error' 'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
>('idle'); >('idle');
const phaseError = useRef<string>('');
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
// the store alive for the component lifetime avoids all disposal race conditions.
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
let cancelled = false;
const run = async () => {
try {
setCanvasPhase('store-init'); setCanvasPhase('store-init');
const newStore = localStoreService.getStore({ const store = createTLStore({
schema: customSchema, schema: customSchema,
shapeUtils: allShapeUtils, shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils bindingUtils: allBindingUtils,
}); });
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined); const snapSvc = new NavigationSnapshotService(store, undefined);
if (accessToken) snapSvc.setAccessToken(accessToken); if (accessToken) snapSvc.setAccessToken(accessToken);
snapshotServiceRef.current = snapSvc; snapshotServiceRef.current = snapSvc;
if (context.node) {
setCanvasPhase('snapshot-loading');
const path = context.node.node_storage_path;
if (path) {
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
path,
accessToken || '',
newStore,
setLoadingState,
undefined,
editorRef.current || undefined
);
snapSvc.setCurrentNodePath(path);
}
}
if (cancelled) {
newStore.dispose();
return;
}
let debounce: ReturnType<typeof setTimeout> | null = null; let debounce: ReturnType<typeof setTimeout> | null = null;
newStore.listen(() => { const unsubscribe = store.listen(() => {
if (!snapshotServiceRef.current?.getCurrentNodePath()) return; if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
if (debounce) clearTimeout(debounce); if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => { debounce = setTimeout(async () => {
@@ -133,27 +113,62 @@ export default function SinglePlayerPage() {
}, 2000); }, 2000);
}); });
storeRef.current = newStore; storeRef.current = store;
setStoreReady(true); setStoreReady(true);
setCanvasPhase('ready'); setCanvasPhase('ready');
} catch (e) {
phaseError.current = e instanceof Error ? e.message : 'Init failed';
setCanvasPhase('error');
}
};
run();
return () => { return () => {
cancelled = true; unsubscribe?.();
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
snapshotServiceRef.current?.clearCurrentNode(); snapshotServiceRef.current?.clearCurrentNode();
snapshotServiceRef.current = null; snapshotServiceRef.current = null;
storeRef.current?.dispose();
storeRef.current = null;
setStoreReady(false); setStoreReady(false);
setCanvasPhase('idle'); setCanvasPhase('idle');
store.dispose();
storeRef.current = null;
}; };
}, [user?.id, context.node?.id]); }, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
useEffect(() => {
if (!user || !storeRef.current) return;
const store = storeRef.current;
let cancelled = false;
const loadNodeSnapshot = async () => {
if (!context.node?.node_storage_path) {
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.clearCurrentNode();
setCanvasPhase('ready');
return;
}
setCanvasPhase('snapshot-loading');
try {
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.node_storage_path,
accessToken || '',
store,
setLoadingState,
undefined,
editorRef.current || undefined
);
if (!cancelled) {
// Repair store in case snapshot overwrote TLINSTANCE
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
setCanvasPhase('ready');
}
} catch (e) {
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
}
};
loadNodeSnapshot();
return () => { cancelled = true; };
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
// Handle navigation changes — save previous snapshot, load next one. // Handle navigation changes — save previous snapshot, load next one.
// No automatic node shape placement: the canvas shows only persisted state. // No automatic node shape placement: the canvas shows only persisted state.
+6
View File
@@ -85,6 +85,12 @@ const DashboardPage: React.FC = () => {
> >
Open workspace Open workspace
</Button> </Button>
<Button
variant="outlined"
onClick={() => navigate('/exam-marker')}
>
Exam Marker
</Button>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => navigate('/calendar')} onClick={() => navigate('/calendar')}
+69
View File
@@ -0,0 +1,69 @@
/**
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
*
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
*/
import axios from 'axios';
import { API_BASE } from '../../config/apiConfig';
import { supabase } from '../../supabaseClient';
import type {
CreateTemplatePayload,
ExamTemplate,
ExamTemplateDetail,
TemplateReplacePayload,
} from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`;
async function authHeaders(): Promise<Record<string, string>> {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No authentication token available');
}
return { Authorization: `Bearer ${session.access_token}` };
}
export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders();
const res = await axios.get<{ templates: ExamTemplate[] }>(
`${EXAM_BASE}/templates`,
{ headers, params: { include_archived: includeArchived } },
);
return res.data.templates ?? [];
},
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
return res.data;
},
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data;
},
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
return res.data;
},
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { headers, responseType: 'arraybuffer' });
return res.data;
},
async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
},
};
export default examRepository;
+25 -6
View File
@@ -1,5 +1,5 @@
// External imports // External imports
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw'; import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
import logger from '../../debugConfig'; import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService'; import { SharedStoreService } from './sharedStoreService';
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
return; return;
} }
// Sanitize session.currentPageId against pages actually in the document
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
const session = snap.session as { currentPageId?: string } | undefined;
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
currentPageId: session.currentPageId, availablePages: pageIds
});
session.currentPageId = pageIds[0];
}
const snapshotCopy = { const snapshotCopy = {
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion, schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snap.document, document: snap.document,
session: snap.session, session: snap.session,
}; };
const targetStore = editor ? editor.store : store;
try { try {
if (editor) { loadSnapshot(targetStore, snapshotCopy as any);
loadSnapshot(editor.store, snapshotCopy as any); // Post-load validation: ensure instance.currentPageId is still valid
} else { const instance = targetStore.get(TLINSTANCE_ID);
loadSnapshot(store, snapshotCopy as any); if (instance) {
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
const pageValid = pages.some((p) => p.id === instance.currentPageId);
if (!pageValid) {
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
targetStore.clear();
}
} }
logger.debug('snapshot-service', '✅ Snapshot loaded successfully'); logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} catch (err) { } catch (err) {
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
if (isSchemaMigration) { if (isSchemaMigration) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', { error: msg }); logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', { error: msg });
} else { } else {
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg }); logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
targetStore.clear();
} }
} }
+127
View File
@@ -0,0 +1,127 @@
/**
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
* the shared UUIDs (template/question/region ids, exam_code).
*/
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
export interface ExamTemplate {
id: string;
title: string;
subject: string | null;
exam_id: string | null;
exam_code: string | null;
source_file_id: string | null;
page_count: number | null;
institute_id: string;
teacher_id: string;
status: ExamTemplateStatus;
created_at: string;
updated_at: string;
}
export interface CreateTemplatePayload {
title: string;
subject?: string;
exam_id?: string;
exam_code?: string;
source_file_id?: string;
page_count?: number;
institute_id?: string;
}
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export interface ExamQuestion {
id: string;
template_id: string;
parent_id: string | null;
label: string;
order: number;
max_marks: number;
answer_type: string | null;
mcq_options: unknown | null;
mark_scheme: Record<string, unknown>;
is_container: boolean;
spec_ref: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
}
export interface ExamResponseArea {
id: string;
question_id: string;
template_id: string;
page: number;
bounds: Record<string, number>;
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
response_form: string | null;
context_type?: string | null;
source: 'manual' | 'ai';
confirmed: boolean;
confidence: number | null;
}
export interface ExamBoundary {
id: string;
template_id: string;
question_id: string | null;
label: string | null;
page_index: number;
y: number;
bounds: Record<string, number> | null;
source: 'manual' | 'ai';
confirmed: boolean;
}
export interface ExamTemplateDetail extends ExamTemplate {
questions: ExamQuestion[];
response_areas: ExamResponseArea[];
boundaries: ExamBoundary[];
}
export interface TemplateReplacePayload {
meta?: {
title?: string;
subject?: string;
page_count?: number;
status?: ExamTemplateStatus;
};
questions: Array<{
id?: string;
parent_id?: string | null;
label: string;
order?: number;
max_marks?: number;
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
mcq_options?: unknown | null;
mark_scheme?: Record<string, unknown>;
is_container?: boolean;
spec_ref?: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
}>;
response_areas: Array<{
id?: string;
question_id: string;
page: number;
bounds: Record<string, number>;
kind: ExamResponseArea['kind'];
response_form?: string | null;
context_type?: string | null;
source?: 'manual' | 'ai';
confirmed?: boolean;
confidence?: number | null;
}>;
boundaries: Array<{
id?: string;
question_id?: string | null;
label?: string | null;
page_index: number;
y: number;
bounds?: Record<string, number> | null;
source?: 'manual' | 'ai';
confirmed?: boolean;
}>;
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import type { ExamTemplateDetail } from '../../types/exam.types'
import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model'
const template: ExamTemplateDetail = {
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
}
const ids = {
top: '11111111-1111-4111-8111-111111111111',
bottom: '22222222-2222-4222-8222-222222222222',
main: '33333333-3333-4333-8333-333333333333',
part: '44444444-4444-4444-8444-444444444444',
response: '55555555-5555-4555-8555-555555555555',
}
describe('exam setup canvas serialization', () => {
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
const payload = serializeCanvasShapes(template, [
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
{ id: ids.part, kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
{ id: ids.response, kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
])
const main = payload.questions.find((q) => q.is_container)
const part = payload.questions.find((q) => q.id === ids.part)
expect(main?.label).toBe('Q1')
expect(part?.parent_id).toBe(main?.id)
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' })
expect(payload.boundaries).toHaveLength(2)
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
})
it('uses real PDF page layouts to save visual page numbers', () => {
const layouts: PdfPageLayout[] = [
{ page: 1, x: 0, y: 0, w: 780, h: 1000 },
{ page: 2, x: 0, y: 1040, w: 780, h: 980 },
]
const payload = serializeCanvasShapes(template, [
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' },
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' },
{ id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 },
{ id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' },
], layouts)
expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1])
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
expect(payload.response_areas[0].page).toBe(2)
expect(payload.meta?.page_count).toBe(2)
})
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
const shapes = shapesFromTemplate({
...template,
questions: [
{ id: ids.main, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
{ id: ids.part, template_id: 'tpl-1', parent_id: ids.main, label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
],
response_areas: [
{ id: ids.response, question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
{ id: '66666666-6666-4666-8666-666666666666', question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
],
boundaries: [{ id: ids.top, template_id: 'tpl-1', question_id: ids.main, label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
})
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 })
})
})
+149
View File
@@ -0,0 +1,149 @@
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
export const PAGE_WIDTH = 780
export const DEFAULT_PAGE_HEIGHT = 1100
export const PAGE_GAP = 40
// Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights.
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
export interface CanvasBounds { x: number; y: number; w: number; h: number }
export interface PdfPageLayout { page: number; x: number; y: number; w: number; h: number }
export interface ExamCanvasShapeModel {
id: string
kind: ExamCanvasShapeKind
x: number
y: number
w: number
h: number
label?: string
maxMarks?: number
answerType?: 'written' | 'mcq' | 'short' | 'diagram'
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
contextType?: string
questionId?: string | null
}
export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] {
return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({
page: i + 1,
x: 0,
y: i * PAGE_HEIGHT,
w: PAGE_WIDTH,
h: DEFAULT_PAGE_HEIGHT,
}))
}
export function pageForY(y: number, layouts?: PdfPageLayout[]): number {
if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h)
if (hit) return hit.page
const nearest = layouts.reduce((best, layout) => {
const dist = y < layout.y ? layout.y - y : y - (layout.y + layout.h)
return dist < best.dist ? { page: layout.page, dist } : best
}, { page: layouts[0].page, dist: Number.POSITIVE_INFINITY })
return nearest.page
}
export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number {
return pageForY(shape.y + shape.h / 2, layouts)
}
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
}
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
const ox2 = outer.x + outer.w
const oy2 = outer.y + outer.h
const ix2 = inner.x + inner.w
const iy2 = inner.y + inner.h
return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2
}
function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean {
const minY = Math.min(top.y, bottom.y)
const maxY = Math.max(top.y, bottom.y)
const cy = shape.y + shape.h / 2
return cy >= minY && cy <= maxY
}
function uuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
)
}
function stableUuid(id?: string | null): string {
return id && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ? id : uuid()
}
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pageLayouts?: PdfPageLayout[]): TemplateReplacePayload {
const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y))
const parts = shapes.filter((s) => s.kind === 'part')
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
const questions: TemplateReplacePayload['questions'] = []
const boundaries: TemplateReplacePayload['boundaries'] = []
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
for (let i = 0; i < orderedBoundaries.length; i += 2) {
const top = orderedBoundaries[i]
const bottom = orderedBoundaries[i + 1]
if (!top || !bottom) break
const qNum = bands.length + 1
const questionId = stableUuid(top.questionId || bottom.questionId)
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
bands.push({ questionId, top, bottom })
for (const b of [top, bottom]) {
boundaries.push({ id: stableUuid(b.id), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pageLayouts) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
}
}
const partQuestionIds = new Map<string, string>()
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = stableUuid(part.questionId || part.id)
partQuestionIds.set(part.id, qid)
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pageLayouts) })
})
const response_areas: TemplateReplacePayload['response_areas'] = []
for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const regionPage = pageForShape(region, pageLayouts)
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind
response_areas.push({ id: stableUuid(region.id), question_id: questionId, page: regionPage, bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
}
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
}
export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] {
const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) {
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
shapes.push({ id: stableUuid(b.id), kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
}
for (const q of detail.questions ?? []) {
if (q.is_container || !q.bounds) continue
shapes.push({ id: stableUuid(q.id), kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
}
for (const r of detail.response_areas ?? []) {
const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 }
const q = questions.get(r.question_id)
shapes.push({ id: stableUuid(r.id), kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
}
return shapes
}
@@ -1,10 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { BasePanel } from './shared/BasePanel'; import { BasePanel } from './shared/BasePanel';
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
import { BaseContext, ViewContext } from '../../../../types/navigation'; import { BaseContext, ViewContext } from '../../../../types/navigation';
interface CCPanelProps { interface CCPanelProps {
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean; isExpanded?: boolean;
isPinned?: boolean; isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void; onExpandedChange?: (expanded: boolean) => void;
@@ -12,7 +10,6 @@ interface CCPanelProps {
} }
export const CCPanel: React.FC<CCPanelProps> = ({ export const CCPanel: React.FC<CCPanelProps> = ({
examMarkerProps,
isExpanded, isExpanded,
isPinned, isPinned,
onExpandedChange, onExpandedChange,
@@ -32,7 +29,6 @@ export const CCPanel: React.FC<CCPanelProps> = ({
return ( return (
<BasePanel <BasePanel
examMarkerProps={examMarkerProps}
isExpanded={isExpanded} isExpanded={isExpanded}
isPinned={isPinned} isPinned={isPinned}
onExpandedChange={handleExpandedChange} onExpandedChange={handleExpandedChange}
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useMemo, useState } from 'react'; import React, { useEffect, useRef, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { TldrawUiButton } from '@tldraw/tldraw'; import { TldrawUiButton } from '@tldraw/tldraw';
import { CCShapesPanel } from './CCShapesPanel'; import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel'; import { CCSlidesPanel } from './CCSlidesPanel';
@@ -7,7 +6,6 @@ import { CCFilesPanel } from './CCFilesPanel';
import { CCCabinetsPanel } from './CCCabinetsPanel'; import { CCCabinetsPanel } from './CCCabinetsPanel';
import { CCYoutubePanel } from './CCYoutubePanel'; import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel'; import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
import { CCSearchPanel } from './CCSearchPanel'; import { CCSearchPanel } from './CCSearchPanel';
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'; import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
import { CCTranscriptionPanel } from './CCTranscriptionPanel'; import { CCTranscriptionPanel } from './CCTranscriptionPanel';
@@ -31,16 +29,12 @@ export const PANEL_TYPES = {
{ id: 'graph', label: 'Graph', order: 60 }, { id: 'graph', label: 'Graph', order: 60 },
{ id: 'search', label: 'Search', order: 70 }, { id: 'search', label: 'Search', order: 70 },
], ],
examMarker: [
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
],
} as const; } as const;
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id']; export type PanelType = typeof PANEL_TYPES.default[number]['id'];
interface BasePanelProps { interface BasePanelProps {
initialPanelType?: PanelType; initialPanelType?: PanelType;
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean; isExpanded?: boolean;
isPinned?: boolean; isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void; onExpandedChange?: (expanded: boolean) => void;
@@ -125,16 +119,6 @@ function getIconSvg(panelId: PanelType, size = '1.25rem') {
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg> </svg>
); );
case 'exam-marker':
return (
<svg {...common}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
default: default:
return ( return (
<svg {...common}> <svg {...common}>
@@ -152,7 +136,6 @@ function isDarkMode() {
export const BasePanel: React.FC<BasePanelProps> = ({ export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'files', initialPanelType = 'files',
examMarkerProps,
isExpanded: controlledIsExpanded, isExpanded: controlledIsExpanded,
isPinned: controlledIsPinned, isPinned: controlledIsPinned,
onExpandedChange, onExpandedChange,
@@ -160,7 +143,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
isMenuOpen = false, isMenuOpen = false,
onMenuOpenChange = () => {}, onMenuOpenChange = () => {},
}) => { }) => {
const location = useLocation();
const { tldrawPreferences } = useTLDraw(); const { tldrawPreferences } = useTLDraw();
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode()); const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -177,12 +159,9 @@ export const BasePanel: React.FC<BasePanelProps> = ({
? 'dark' ? 'dark'
: 'light'; : 'light';
const isExamMarkerRoute = location.pathname === '/exam-marker'; const availablePanels = PANEL_TYPES.default;
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = useState<PanelType>( const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
isExamMarkerRoute ? 'exam-marker' : initialPanelType
);
const [internalIsExpanded, setInternalIsExpanded] = useState(true); const [internalIsExpanded, setInternalIsExpanded] = useState(true);
const [internalIsPinned, setInternalIsPinned] = useState(true); const [internalIsPinned, setInternalIsPinned] = useState(true);
@@ -223,10 +202,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}, [isExpanded, isPinned]); }, [isExpanded, isPinned]);
const renderCurrentPanel = () => { const renderCurrentPanel = () => {
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
}
switch (currentPanelType) { switch (currentPanelType) {
case 'cabinets': case 'cabinets':
return <CCCabinetsPanel />; return <CCCabinetsPanel />;
@@ -331,7 +306,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
{type.id === 'search' && 'Search through your content'} {type.id === 'search' && 'Search through your content'}
{type.id === 'navigation' && 'Navigate through different contexts'} {type.id === 'navigation' && 'Navigate through different contexts'}
{type.id === 'node-snapshot' && 'Manage node snapshots'} {type.id === 'node-snapshot' && 'Manage node snapshots'}
{type.id === 'exam-marker' && 'Mark and grade exams'}
</span> </span>
</span> </span>
</button> </button>
@@ -1,495 +0,0 @@
import React, { useState } from 'react';
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
import { PDFDocument } from 'pdf-lib';
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
import { logger } from '../../../../../debugConfig';
interface CCExamMarkerPanelProps {
editor: Editor | null;
currentView: 'exam-and-markscheme' | 'student-responses';
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
currentStudentIndex: number;
totalStudents: number;
onPreviousStudent: () => void;
onNextStudent: () => void;
getCurrentPdf: () => Pdf | null;
}
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
editor,
currentView,
onViewChange,
currentStudentIndex,
totalStudents,
onPreviousStudent,
onNextStudent,
getCurrentPdf,
}) => {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void,
startPage?: number,
endPage?: number,
studentIndex?: number
) => {
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
name,
startPage,
endPage,
studentIndex,
currentView,
totalPages: pages.length
});
const pdfPages = pages.slice(startPage, endPage);
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
pdfPages: pdfPages.length,
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
});
const totalThings = pdfPages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const sourcePdf = await PDFDocument.load(source);
tickProgress();
// Create a new PDF document for the selected pages
const newPdf = await PDFDocument.create();
// Copy pages from source PDF
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
copiedPages.forEach(page => newPdf.addPage(page));
tickProgress();
// Store current page to restore later
const currentPageId = editor.getCurrentPageId();
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
// Switch to the correct page based on context
const targetPageId = (studentIndex !== undefined
? `page:student-response-${studentIndex}`
: currentView === 'exam-and-markscheme'
? 'page:exam-page'
: 'page:mark-scheme-page') as TLPageId;
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
editor.setCurrentPage(targetPageId);
// Get all shape IDs that are not page shapes (i.e., annotations)
const pageShapeIds = new Set(pages.map(page => page.shapeId));
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
totalShapes: editor.getCurrentPageShapeIds().size,
pageShapes: pageShapeIds.size,
annotationShapes: allShapeIds.length
});
// For each page, draw annotations on top
for (let i = 0; i < pdfPages.length; i++) {
const page = pdfPages[i];
const pdfPage = newPdf.getPages()[i];
const {bounds} = page;
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
bounds,
pageIndex: i,
globalPageIndex: (startPage || 0) + i
});
// Get shapes that intersect with this page using editor's bounds checking
const shapesInBounds = allShapeIds.filter((id) => {
const shape = editor.getShape(id);
if (!shape || shape.isLocked) return false;
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
const annotationManager = editor.annotationManager;
const annotationData = annotationManager.getAnnotationData(id);
if (!annotationData) return false;
// Filter by student index if provided
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
return false;
}
// For exam/markscheme view, only include those annotations
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
return false;
}
// For individual student exports, use the annotation's original page index
// For full exports, use the stored page index
const adjustedPageIndex = annotationData.pageIndex;
// Check if this shape belongs to this page index
if (adjustedPageIndex !== i) {
return false;
}
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
shapeId: id,
annotationData,
adjustedPageIndex,
currentPageIndex: i,
bounds: editor.getShapePageBounds(id)
});
return true;
});
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
shapesInBounds: shapesInBounds.length,
pageIndex: i,
globalPageIndex: (startPage || 0) + i
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
// Export the annotations as PNG
const exportedPng = await exportToBlob({
editor,
ids: shapesInBounds,
format: 'png',
opts: {
background: false,
// Create a new bounds that's relative to the current page
bounds: new TLBox(
bounds.x,
0, // Reset to 0 since we want annotations relative to current page
bounds.width,
bounds.height
),
padding: 0,
scale: 1
},
});
tickProgress();
// Draw the annotations on the PDF page
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
const pdfWidth = pdfPage.getWidth();
const pdfHeight = pdfPage.getHeight();
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfWidth,
height: pdfHeight,
});
tickProgress();
}
// Restore original page
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
editor.setCurrentPage(currentPageId);
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(
new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
};
const handleExportCurrentView = async () => {
if (!editor) return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
setExportProgress(0);
try {
if (currentView === 'student-responses') {
// For student responses, we need to handle each student's annotations separately
const pagesPerStudent = currentPdf.pages.length / totalStudents;
let currentProgress = 0;
// Create a new PDF with all pages
const sourcePdf = await PDFDocument.load(currentPdf.source);
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
copiedPages.forEach(page => newPdf.addPage(page));
// For each student, export their annotations onto their pages
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
// Switch to the student's page to get their annotations
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
editor.setCurrentPage(targetPageId);
// Get all annotations for this student
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
// Process each page for this student
for (let i = 0; i < pagesPerStudent; i++) {
const pageIndex = startPage + i;
const page = currentPdf.pages[pageIndex];
const pdfPage = newPdf.getPages()[pageIndex];
// Get shapes for this page
const shapesInBounds = allShapeIds.filter((id) => {
const shape = editor.getShape(id);
if (!shape || shape.isLocked) return false;
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
const annotationManager = editor.annotationManager;
const annotationData = annotationManager.getAnnotationData(id);
if (!annotationData) return false;
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
});
if (shapesInBounds.length > 0) {
// Export and draw annotations
const exportedPng = await exportToBlob({
editor,
ids: shapesInBounds,
format: 'png',
opts: {
background: false,
bounds: new TLBox(
page.bounds.x,
0,
page.bounds.width,
page.bounds.height
),
padding: 0,
scale: 1
},
});
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
}
currentProgress++;
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
}
}
// Save the combined PDF
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = currentPdf.name;
a.click();
URL.revokeObjectURL(url);
} else {
// For exam/mark scheme view, use the original export logic
await exportPdf(editor, currentPdf, setExportProgress);
}
} finally {
setExportProgress(null);
}
};
const handleExportCurrentStudent = async () => {
if (!editor || currentView !== 'student-responses') return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
const pagesPerStudent = currentPdf.pages.length / totalStudents;
const startPage = currentStudentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
setExportProgress(0);
try {
await exportPdf(
editor,
{
...currentPdf,
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
},
setExportProgress,
Math.floor(startPage),
Math.floor(endPage),
currentStudentIndex
);
} finally {
setExportProgress(null);
}
};
const handleBatchExport = async () => {
if (!editor || currentView !== 'student-responses') return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
setExportProgress(0);
try {
const pagesPerStudent = currentPdf.pages.length / totalStudents;
let currentProgress = 0;
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
await exportPdf(
editor,
{
...currentPdf,
name: `Student_${studentIndex + 1}_Response.pdf`,
},
setExportProgress,
Math.floor(startPage),
Math.floor(endPage),
studentIndex
);
currentProgress++;
setExportProgress(currentProgress / totalStudents);
}
} finally {
setExportProgress(null);
}
};
return (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Exam Marker
</Typography>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
View Mode
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
onClick={() => onViewChange('exam-and-markscheme')}
>
Exam & Mark Scheme
</Button>
<Button
fullWidth
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
onClick={() => onViewChange('student-responses')}
>
Student Responses
</Button>
</Box>
</Box>
{currentView === 'student-responses' && (
<>
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Student Navigation
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
variant="outlined"
onClick={onPreviousStudent}
disabled={currentStudentIndex === 0}
>
Previous Student
</Button>
<Typography variant="body2" align="center">
Student {currentStudentIndex + 1} of {totalStudents}
</Typography>
<Button
fullWidth
variant="outlined"
onClick={onNextStudent}
disabled={currentStudentIndex === totalStudents - 1}
>
Next Student
</Button>
</Box>
</Box>
</>
)}
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Actions
</Typography>
<Stack spacing={1}>
<Button
fullWidth
variant="contained"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleExportCurrentView}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
</Button>
{currentView === 'student-responses' && (
<>
<Button
fullWidth
variant="outlined"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleExportCurrentStudent}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export Current Student'}
</Button>
<Button
fullWidth
variant="outlined"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleBatchExport}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export All as Separate Files'}
</Button>
</>
)}
</Stack>
</Box>
<Box sx={{ mt: 'auto' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Statistics
</Typography>
<Typography variant="body2">
Total Pages: {getCurrentPdf()?.pages.length || 0}
</Typography>
</Box>
</Box>
);
};