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]>
This commit is contained in:
CC Worker
2026-06-06 19:47:02 +00:00
co-authored by Claude Opus 4.8
parent 434643596e
commit 29554ebdbd
18 changed files with 363 additions and 1547 deletions
@@ -1,18 +1,15 @@
import React, { useState } from 'react';
import { BasePanel } from './shared/BasePanel';
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
import { BaseContext, ViewContext } from '../../../../types/navigation';
interface CCPanelProps {
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onPinnedChange?: (pinned: boolean) => void;
}
export const CCPanel: React.FC<CCPanelProps> = ({
examMarkerProps,
export const CCPanel: React.FC<CCPanelProps> = ({
isExpanded,
isPinned,
onExpandedChange,
@@ -31,8 +28,7 @@ export const CCPanel: React.FC<CCPanelProps> = ({
};
return (
<BasePanel
examMarkerProps={examMarkerProps}
<BasePanel
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={handleExpandedChange}
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { TldrawUiButton } from '@tldraw/tldraw';
import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel';
@@ -7,7 +6,6 @@ import { CCFilesPanel } from './CCFilesPanel';
import { CCCabinetsPanel } from './CCCabinetsPanel';
import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
import { CCSearchPanel } from './CCSearchPanel';
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
@@ -31,16 +29,12 @@ export const PANEL_TYPES = {
{ id: 'graph', label: 'Graph', order: 60 },
{ id: 'search', label: 'Search', order: 70 },
],
examMarker: [
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
],
} 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 {
initialPanelType?: PanelType;
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
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" />
</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:
return (
<svg {...common}>
@@ -152,7 +136,6 @@ function isDarkMode() {
export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'files',
examMarkerProps,
isExpanded: controlledIsExpanded,
isPinned: controlledIsPinned,
onExpandedChange,
@@ -160,7 +143,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
isMenuOpen = false,
onMenuOpenChange = () => {},
}) => {
const location = useLocation();
const { tldrawPreferences } = useTLDraw();
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
const [menuOpen, setMenuOpen] = useState(false);
@@ -177,12 +159,9 @@ export const BasePanel: React.FC<BasePanelProps> = ({
? 'dark'
: 'light';
const isExamMarkerRoute = location.pathname === '/exam-marker';
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const availablePanels = PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(
isExamMarkerRoute ? 'exam-marker' : initialPanelType
);
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
const [internalIsPinned, setInternalIsPinned] = useState(true);
@@ -223,10 +202,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}, [isExpanded, isPinned]);
const renderCurrentPanel = () => {
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
}
switch (currentPanelType) {
case 'cabinets':
return <CCCabinetsPanel />;
@@ -331,7 +306,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
{type.id === 'search' && 'Search through your content'}
{type.id === 'navigation' && 'Navigate through different contexts'}
{type.id === 'node-snapshot' && 'Manage node snapshots'}
{type.id === 'exam-marker' && 'Mark and grade exams'}
</span>
</span>
</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>
);
};