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. + + + + + + {error && ( + setError(null)}> + {error} + + )} + + {loading ? ( + + + + ) : templates.length === 0 ? ( + + + No exam templates yet + + Create your first template to start mapping an exam paper. + + + + ) : ( + + {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()} + + + + + ))} + + )} + + + (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> + New exam template + + + setTitle(e.target.value)} + fullWidth + autoFocus + required + /> + setSubject(e.target.value)} + fullWidth + /> + + + + + + + + + ); +}; + +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