diff --git a/.gitignore b/.gitignore
index ef87748..5360eef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,6 @@ docker-compose.override.yml
# Local environment variants
.env.dev
.env.prod
+
+# Playwright test artifacts
+test-results/
diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx
index 96572a0..a4346e5 100644
--- a/src/AppRoutes.admin.test.tsx
+++ b/src/AppRoutes.admin.test.tsx
@@ -31,7 +31,7 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
}));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
}));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
}));
-vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () =>
Exam Marker
}));
+vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
}));
vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
}));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
}));
@@ -46,6 +46,7 @@ vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
}));
vi.mock('./pages/timetable', () => ({
TimetablePage: () =>
Timetable
,
+ TimetableListPage: () =>
Timetable List
,
ClassesPage: () =>
Classes
,
LessonPage: () =>
Lesson
,
TaughtLessonsPage: () =>
Taught Lessons
,
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index 2b7263c..f091bfb 100644
--- a/src/AppRoutes.tsx
+++ b/src/AppRoutes.tsx
@@ -7,7 +7,8 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
-import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
+import { ExamDashboardPage } from './pages/exam';
+import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
@@ -181,7 +182,7 @@ const AppRoutes: React.FC = () => {
{/* Existing Routes */}
} />
} />
-
} />
+
} />
} />
} />
} />
diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx
new file mode 100644
index 0000000..500c45f
--- /dev/null
+++ b/src/pages/exam/ExamDashboardPage.tsx
@@ -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 = {
+ 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([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
+
+
+
+ Exam Marker
+
+
+ Build a template for an exam paper, then run marking batches against your classes.
+
+
+ } onClick={() => setCreateOpen(true)}>
+ New template
+
+
+
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : templates.length === 0 ? (
+
+
+ No exam templates yet
+
+ Create your first template to start mapping an exam paper.
+
+ } onClick={() => setCreateOpen(true)}>
+ New template
+
+
+ ) : (
+
+ {templates.map((t) => (
+
+ navigate(`/exam-marker/${t.id}/setup`)}
+ >
+
+ {t.title}
+
+ handleArchive(t.id, e)} aria-label="archive template">
+
+
+
+
+ {t.subject && (
+ {t.subject}
+ )}
+ {t.exam_code && (
+ {t.exam_code}
+ )}
+
+
+
+ {new Date(t.updated_at).toLocaleDateString()}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+};
+
+export default ExamDashboardPage;
diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts
new file mode 100644
index 0000000..d8b8c5c
--- /dev/null
+++ b/src/pages/exam/index.ts
@@ -0,0 +1 @@
+export { default as ExamDashboardPage } from './ExamDashboardPage';
diff --git a/src/pages/tldraw/CCExamMarker/AnnotationManager.ts b/src/pages/tldraw/CCExamMarker/AnnotationManager.ts
deleted file mode 100644
index cd3450a..0000000
--- a/src/pages/tldraw/CCExamMarker/AnnotationManager.ts
+++ /dev/null
@@ -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 = new Set();
- private markSchemeAnnotations: Set = new Set();
- private studentAnnotations: Map> = new Map();
- private annotationData: Map = 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
- });
- }
- }
-
-}
\ No newline at end of file
diff --git a/src/pages/tldraw/CCExamMarker/CCExamMarker.tsx b/src/pages/tldraw/CCExamMarker/CCExamMarker.tsx
deleted file mode 100644
index c965891..0000000
--- a/src/pages/tldraw/CCExamMarker/CCExamMarker.tsx
+++ /dev/null
@@ -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({ 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 (
-
- {state.phase === 'pick' ? (
-
- setState({
- phase: 'edit',
- examPaper: pdfs.examPaper,
- markScheme: pdfs.markScheme,
- studentResponses: pdfs.studentResponses,
- })
- }
- />
- ) : (
-
-
- {
- 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 ;
- }}
- />
-
-
- )}
-
- );
-};
diff --git a/src/pages/tldraw/CCExamMarker/CCExportPdfButton.tsx b/src/pages/tldraw/CCExamMarker/CCExportPdfButton.tsx
deleted file mode 100644
index 4f8c285..0000000
--- a/src/pages/tldraw/CCExamMarker/CCExportPdfButton.tsx
+++ /dev/null
@@ -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(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 (
-
- );
-}
\ No newline at end of file
diff --git a/src/pages/tldraw/CCExamMarker/CCPdfEditor.tsx b/src/pages/tldraw/CCExamMarker/CCPdfEditor.tsx
deleted file mode 100644
index 4e1b90c..0000000
--- a/src/pages/tldraw/CCExamMarker/CCPdfEditor.tsx
+++ /dev/null
@@ -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(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 (
-
- onEditorMount(editor!)
- }}
- />
-
- );
-}
\ No newline at end of file
diff --git a/src/pages/tldraw/CCExamMarker/CCPdfPicker.tsx b/src/pages/tldraw/CCExamMarker/CCPdfPicker.tsx
deleted file mode 100644
index f879c6d..0000000
--- a/src/pages/tldraw/CCExamMarker/CCPdfPicker.tsx
+++ /dev/null
@@ -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>({});
-
- async function loadPdf(name: string, source: ArrayBuffer): Promise {
- 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 (
-
- Loading...
-
- );
- }
-
- return (
-
-
- Select PDF Files
-
-
-
-
-
-
-
-
-
- {allPdfsSelected() && (
-
-
-
- )}
-
-
- );
-}
\ No newline at end of file
diff --git a/src/pages/tldraw/CCExamMarker/cc-exam-marker.css b/src/pages/tldraw/CCExamMarker/cc-exam-marker.css
deleted file mode 100644
index b72fda9..0000000
--- a/src/pages/tldraw/CCExamMarker/cc-exam-marker.css
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/src/pages/tldraw/CCExamMarker/types.ts b/src/pages/tldraw/CCExamMarker/types.ts
deleted file mode 100644
index c474c69..0000000
--- a/src/pages/tldraw/CCExamMarker/types.ts
+++ /dev/null
@@ -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[];
-}
\ No newline at end of file
diff --git a/src/pages/user/dashboardPage.tsx b/src/pages/user/dashboardPage.tsx
index 366a518..715cb3e 100644
--- a/src/pages/user/dashboardPage.tsx
+++ b/src/pages/user/dashboardPage.tsx
@@ -85,6 +85,12 @@ const DashboardPage: React.FC = () => {
>
Open workspace
+
diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCExamMarkerPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCExamMarkerPanel.tsx
deleted file mode 100644
index ae4aa51..0000000
--- a/src/utils/tldraw/ui-overrides/components/shared/CCExamMarkerPanel.tsx
+++ /dev/null
@@ -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 = ({
- editor,
- currentView,
- onViewChange,
- currentStudentIndex,
- totalStudents,
- onPreviousStudent,
- onNextStudent,
- getCurrentPdf,
-}) => {
- const [exportProgress, setExportProgress] = useState(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 (
-
-
- Exam Marker
-
-
-
-
- View Mode
-
-
-
-
-
-
-
- {currentView === 'student-responses' && (
- <>
-
-
-
- Student Navigation
-
-
-
-
- Student {currentStudentIndex + 1} of {totalStudents}
-
-
-
-
- >
- )}
-
-
-
-
-
- Actions
-
-
-
-
- {currentView === 'student-responses' && (
- <>
-
-
-
- >
- )}
-
-
-
-
-
- Statistics
-
-
- Total Pages: {getCurrentPdf()?.pages.length || 0}
-
-
-
- );
-};
\ No newline at end of file