Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab8aab43c | ||
|
|
adc7a2a05b | ||
|
|
29554ebdbd | ||
|
|
434643596e | ||
|
|
313ab724b8 | ||
|
|
ee7d7f7cac | ||
|
|
0dd18c4fd4 | ||
|
|
5869c741d7 | ||
|
|
5b6e461706 | ||
|
|
53adc74a1c | ||
|
|
9a7cc86a75 | ||
|
|
6cba932146 | ||
|
|
ba829be8e9 | ||
|
|
9f58917c39 | ||
|
|
a375fbe37f | ||
|
|
38ca23dc3e | ||
|
|
8fe8edb341 |
@@ -47,3 +47,6 @@ docker-compose.override.yml
|
|||||||
# Local environment variants
|
# Local environment variants
|
||||||
.env.dev
|
.env.dev
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
|
# Playwright test artifacts
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { useMemo, useEffect } from 'react';
|
import { useMemo, useEffect } from 'react';
|
||||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import { useTLDraw } from './contexts/TLDrawContext';
|
import { useTLDraw } from './contexts/TLDrawContext';
|
||||||
import { theme } from './services/themeService';
|
import { theme } from './services/themeService';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
@@ -53,6 +54,7 @@ const App = React.memo(() => {
|
|||||||
<TLDrawProvider>
|
<TLDrawProvider>
|
||||||
<DarkModeSync />
|
<DarkModeSync />
|
||||||
<ThemeProvider theme={appTheme}>
|
<ThemeProvider theme={appTheme}>
|
||||||
|
<CssBaseline />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</TLDrawProvider>
|
</TLDrawProvider>
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
|
|||||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||||
vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => <div>Exam Marker</div> }));
|
vi.mock('./pages/exam', () => ({
|
||||||
|
ExamDashboardPage: () => <div>Exam Marker</div>,
|
||||||
|
MarkSchemePage: () => <div>Mark Scheme editor</div>,
|
||||||
|
}));
|
||||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||||
@@ -46,6 +49,7 @@ vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./pages/timetable', () => ({
|
vi.mock('./pages/timetable', () => ({
|
||||||
TimetablePage: () => <div>Timetable</div>,
|
TimetablePage: () => <div>Timetable</div>,
|
||||||
|
TimetableListPage: () => <div>Timetable List</div>,
|
||||||
ClassesPage: () => <div>Classes</div>,
|
ClassesPage: () => <div>Classes</div>,
|
||||||
LessonPage: () => <div>Lesson</div>,
|
LessonPage: () => <div>Lesson</div>,
|
||||||
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
||||||
@@ -121,3 +125,22 @@ describe('/admin route authorization', () => {
|
|||||||
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('exam-marker routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseAuth.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the mark scheme editor route for authenticated users', () => {
|
||||||
|
mockUseAuth.mockReturnValue(authState({
|
||||||
|
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||||
|
user_role: 'email_teacher',
|
||||||
|
accessToken: 'token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderAt('/exam-marker/template-123/marks');
|
||||||
|
|
||||||
|
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+11
-7
@@ -7,7 +7,8 @@ import LoginPage from './pages/auth/loginPage';
|
|||||||
import SignupPage from './pages/auth/signupPage';
|
import SignupPage from './pages/auth/signupPage';
|
||||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||||
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
|
import { ExamDashboardPage, MarkSchemePage } from './pages/exam';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import CalendarPage from './pages/user/calendarPage';
|
import CalendarPage from './pages/user/calendarPage';
|
||||||
import SettingsPage from './pages/user/settingsPage';
|
import SettingsPage from './pages/user/settingsPage';
|
||||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||||
@@ -30,6 +31,7 @@ import DashboardPage from './pages/user/dashboardPage';
|
|||||||
// Timetable Module Pages
|
// Timetable Module Pages
|
||||||
import {
|
import {
|
||||||
TimetablePage,
|
TimetablePage,
|
||||||
|
TimetableListPage,
|
||||||
ClassesPage,
|
ClassesPage,
|
||||||
LessonPage,
|
LessonPage,
|
||||||
TaughtLessonsPage,
|
TaughtLessonsPage,
|
||||||
@@ -66,7 +68,7 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const RequireAuth: React.FC = () => {
|
||||||
const { user, isAuthResolving } = useAuth();
|
const { user, isAuthResolving } = useAuth();
|
||||||
|
|
||||||
if (isAuthResolving) {
|
if (isAuthResolving) {
|
||||||
@@ -88,7 +90,7 @@ const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppRoutes: React.FC = () => {
|
||||||
@@ -159,10 +161,11 @@ const AppRoutes: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
<RequireAuth>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<FullContextRoutes />}>
|
<Route element={<FullContextRoutes />}>
|
||||||
{/* Timetable Module Routes */}
|
{/* Timetable Module Routes */}
|
||||||
<Route path="/timetable" element={<TimetablePage />} />
|
<Route path="/timetable" element={<TimetableListPage />} />
|
||||||
|
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
|
||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
@@ -179,7 +182,8 @@ const AppRoutes: React.FC = () => {
|
|||||||
{/* Existing Routes */}
|
{/* Existing Routes */}
|
||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||||
<Route path="/morphic" element={<MorphicPage />} />
|
<Route path="/morphic" element={<MorphicPage />} />
|
||||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||||
@@ -190,7 +194,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</RequireAuth>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||||
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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 GradingIcon from '@mui/icons-material/Grading';
|
||||||
|
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { ExamTemplate } from '../../types/exam.types';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
|
||||||
|
draft: 'warning',
|
||||||
|
ready: 'success',
|
||||||
|
archived: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExamDashboardPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { bootstrapData } = useAuth();
|
||||||
|
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setTemplates(await examRepository.listTemplates());
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load, instituteId]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await examRepository.createTemplate({
|
||||||
|
title: title.trim(),
|
||||||
|
subject: subject.trim() || undefined,
|
||||||
|
institute_id: instituteId,
|
||||||
|
});
|
||||||
|
setCreateOpen(false);
|
||||||
|
setTitle('');
|
||||||
|
setSubject('');
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
try {
|
||||||
|
await examRepository.archiveTemplate(id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" component="h1" gutterBottom>
|
||||||
|
Exam Marker
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||||
|
Build a template for an exam paper, then run marking batches against your classes.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
|
||||||
|
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Create your first template to start mapping an exam paper.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||||
|
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||||
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||||
|
<Tooltip title="Archive">
|
||||||
|
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||||
|
<ArchiveIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{t.subject && (
|
||||||
|
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||||
|
)}
|
||||||
|
{t.exam_code && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(t.updated_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ pt: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||||
|
startIcon={<GradingIcon fontSize="small" />}
|
||||||
|
>
|
||||||
|
Edit marks
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>New exam template</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||||
|
{saving ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamDashboardPage;
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type {
|
||||||
|
ExamQuestion,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
MarkScheme,
|
||||||
|
MarkSchemeType,
|
||||||
|
SpecPoint,
|
||||||
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
|
type Drafts = Record<string, PartDraft>;
|
||||||
|
|
||||||
|
interface PartDraft {
|
||||||
|
max_marks: string;
|
||||||
|
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
|
||||||
|
spec_ref: string;
|
||||||
|
schemeType: MarkSchemeType;
|
||||||
|
body: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
|
||||||
|
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
|
||||||
|
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
|
||||||
|
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
|
||||||
|
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
|
||||||
|
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
|
||||||
|
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function deriveSpecCode(template: ExamTemplateDetail | null): string {
|
||||||
|
const examCode = template?.exam_code ?? '';
|
||||||
|
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
|
||||||
|
return match?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
|
||||||
|
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
|
||||||
|
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
|
||||||
|
|
||||||
|
if (type === 'levels' && Array.isArray(scheme?.levels)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'levels',
|
||||||
|
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'parts' && Array.isArray(scheme?.parts)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'parts',
|
||||||
|
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'checklist',
|
||||||
|
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'free') {
|
||||||
|
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
|
||||||
|
}
|
||||||
|
if (Array.isArray(scheme?.points)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'points',
|
||||||
|
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { schemeType: type, body: '', notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, fallback = 0): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLine(line: string): string[] {
|
||||||
|
return line.split('|').map((part) => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftToScheme(draft: PartDraft): MarkScheme {
|
||||||
|
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
|
||||||
|
if (draft.schemeType === 'levels') {
|
||||||
|
return {
|
||||||
|
type: 'levels',
|
||||||
|
levels: lines.map((line, idx) => {
|
||||||
|
const [level, range, descriptor] = splitLine(line);
|
||||||
|
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
level: level || `L${idx + 1}`,
|
||||||
|
min: parseNumber(minRaw, 0),
|
||||||
|
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
|
||||||
|
descriptor: descriptor || line,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (draft.schemeType === 'parts') {
|
||||||
|
return {
|
||||||
|
type: 'parts',
|
||||||
|
parts: lines.map((line, idx) => {
|
||||||
|
const [label, marks, guidance] = splitLine(line);
|
||||||
|
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (draft.schemeType === 'checklist') {
|
||||||
|
return {
|
||||||
|
type: 'checklist',
|
||||||
|
checklist: lines.map((line) => {
|
||||||
|
const [marks, text] = splitLine(line);
|
||||||
|
return { marks: parseNumber(marks, 1), text: text || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'points',
|
||||||
|
points: lines.map((line) => {
|
||||||
|
const [mark, text] = splitLine(line);
|
||||||
|
return { mark: parseNumber(mark, 1), text: text || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDraft(question: ExamQuestion): PartDraft {
|
||||||
|
const scheme = schemeToDraft(question.mark_scheme);
|
||||||
|
return {
|
||||||
|
max_marks: String(question.max_marks ?? 0),
|
||||||
|
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
|
||||||
|
spec_ref: question.spec_ref ?? '',
|
||||||
|
schemeType: scheme.schemeType,
|
||||||
|
body: scheme.body,
|
||||||
|
notes: scheme.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkSchemePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { templateId } = useParams<{ templateId: string }>();
|
||||||
|
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Drafts>({});
|
||||||
|
const [specCode, setSpecCode] = useState('');
|
||||||
|
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId);
|
||||||
|
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||||
|
setTemplate(detail);
|
||||||
|
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
|
||||||
|
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
|
||||||
|
setSpecCode((prev) => prev || deriveSpecCode(detail));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const loadSpecPoints = useCallback(async () => {
|
||||||
|
if (!specCode.trim()) return;
|
||||||
|
setSpecEndpointMissing(false);
|
||||||
|
try {
|
||||||
|
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
|
||||||
|
setSpecEndpointMissing(true);
|
||||||
|
setSpecPoints([]);
|
||||||
|
}
|
||||||
|
}, [specCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSpecPoints();
|
||||||
|
}, [loadSpecPoints]);
|
||||||
|
|
||||||
|
const parts = useMemo(
|
||||||
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
const selected = parts.find((p) => p.id === selectedId) ?? null;
|
||||||
|
const draft = selected ? drafts[selected.id] : null;
|
||||||
|
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
|
||||||
|
|
||||||
|
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
|
||||||
|
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePart = async (question: ExamQuestion) => {
|
||||||
|
const partDraft = drafts[question.id];
|
||||||
|
if (!partDraft) return;
|
||||||
|
setSavingId(question.id);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await examRepository.patchQuestion(question.id, {
|
||||||
|
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||||
|
answer_type: partDraft.answer_type,
|
||||||
|
mark_scheme: draftToScheme(partDraft),
|
||||||
|
spec_ref: partDraft.spec_ref.trim() || null,
|
||||||
|
});
|
||||||
|
setTemplate((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
questions: prev.questions.map((q) => q.id === question.id ? {
|
||||||
|
...q,
|
||||||
|
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||||
|
answer_type: partDraft.answer_type,
|
||||||
|
mark_scheme: draftToScheme(partDraft),
|
||||||
|
spec_ref: partDraft.spec_ref.trim() || null,
|
||||||
|
} : q),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setNotice(`Saved mark scheme for Part ${question.label}.`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncGraph = async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
setSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const res = await examRepository.syncTemplateToGraph(templateId);
|
||||||
|
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Back to templates
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
|
||||||
|
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
|
||||||
|
{syncing ? 'Syncing…' : 'Sync to graph'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
|
||||||
|
{specEndpointMissing && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField
|
||||||
|
label="Specification code"
|
||||||
|
value={specCode}
|
||||||
|
onChange={(e) => setSpecCode(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={8}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint).
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{parts.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={3}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Parts</Typography>
|
||||||
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
value={selectedId ?? false}
|
||||||
|
onChange={(_, value) => setSelectedId(value)}
|
||||||
|
variant="scrollable"
|
||||||
|
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
|
||||||
|
>
|
||||||
|
{parts.map((part) => (
|
||||||
|
<Tab
|
||||||
|
key={part.id}
|
||||||
|
value={part.id}
|
||||||
|
label={
|
||||||
|
<Box sx={{ textAlign: 'left', width: '100%' }}>
|
||||||
|
<Typography variant="body2">Part {part.label}</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
|
||||||
|
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={9}>
|
||||||
|
{selected && draft && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5">Part {selected.label}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
disabled={savingId === selected.id}
|
||||||
|
onClick={() => savePart(selected)}
|
||||||
|
>
|
||||||
|
{savingId === selected.id ? 'Saving…' : 'Save part'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField
|
||||||
|
label="Max marks"
|
||||||
|
value={draft.max_marks}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 0, step: 0.5 }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="answer-type-label">Answer type</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="answer-type-label"
|
||||||
|
label="Answer type"
|
||||||
|
value={draft.answer_type}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
|
||||||
|
>
|
||||||
|
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="scheme-type-label"
|
||||||
|
label="Scheme form"
|
||||||
|
value={draft.schemeType}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
|
||||||
|
>
|
||||||
|
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
options={specPoints}
|
||||||
|
value={selectedSpecPoint}
|
||||||
|
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
|
||||||
|
getOptionLabel={(option) => `${option.ref} — ${option.description}`}
|
||||||
|
isOptionEqualToValue={(option, value) => option.ref === value.ref}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="SpecPoint picker"
|
||||||
|
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="spec_ref"
|
||||||
|
value={draft.spec_ref}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
|
||||||
|
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
|
||||||
|
value={draft.body}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
|
||||||
|
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
|
||||||
|
multiline
|
||||||
|
minRows={10}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Internal marking notes"
|
||||||
|
value={draft.notes}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkSchemePage;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||||
@@ -3,9 +3,19 @@ import { Link } from 'react-router-dom';
|
|||||||
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
||||||
import useTimetableStore from '../../stores/timetableStore';
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const MyClassesSkeleton = () => (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const MyClassesPage: React.FC = () => {
|
const MyClassesPage: React.FC = () => {
|
||||||
const { profile } = useUser();
|
const { profile } = useUser();
|
||||||
|
const { selectedInstituteId } = useNeoInstitute();
|
||||||
const {
|
const {
|
||||||
myClasses,
|
myClasses,
|
||||||
classesLoading: myClassesLoading,
|
classesLoading: myClassesLoading,
|
||||||
@@ -14,8 +24,16 @@ const MyClassesPage: React.FC = () => {
|
|||||||
} = useTimetableStore();
|
} = useTimetableStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMyClasses();
|
fetchMyClasses().catch((error) =>
|
||||||
}, [fetchMyClasses]);
|
logger.warn('my-classes', 'Fetch my classes failed', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [fetchMyClasses, selectedInstituteId]);
|
||||||
|
|
||||||
|
if (myClassesLoading) {
|
||||||
|
return <MyClassesSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
const getRoleLabel = (role: string) => {
|
const getRoleLabel = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (myClassesLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (myClassesError) {
|
if (myClassesError) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
const TimetableListPage: React.FC = () => {
|
||||||
|
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTimetables();
|
||||||
|
}, [fetchTimetables]);
|
||||||
|
|
||||||
|
if (timetablesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
|
||||||
|
const showEmpty = timetablesError || timetables.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
|
||||||
|
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
Timetables are created from a class. Go to{' '}
|
||||||
|
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
|
||||||
|
to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{timetables.map((timetable) => (
|
||||||
|
<Link
|
||||||
|
key={timetable.id}
|
||||||
|
to={`/timetable/${timetable.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<CalendarToday className="text-blue-500 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
|
||||||
|
{timetable.end_date
|
||||||
|
? ` – ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
|
||||||
|
: ' (ongoing)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{timetable.recurrence_rule && (
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimetableListPage;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as TimetablePage } from './TimetablePage';
|
export { default as TimetablePage } from './TimetablePage';
|
||||||
|
export { default as TimetableListPage } from './TimetableListPage';
|
||||||
export { default as ClassesPage } from './ClassesPage';
|
export { default as ClassesPage } from './ClassesPage';
|
||||||
export { default as LessonPage } from './LessonPage';
|
export { default as LessonPage } from './LessonPage';
|
||||||
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TLShapeId } from '@tldraw/tldraw';
|
|
||||||
|
|
||||||
export interface AnnotationData {
|
|
||||||
studentIndex?: number; // undefined for exam/markscheme annotations
|
|
||||||
pageIndex: number;
|
|
||||||
shapeId: TLShapeId;
|
|
||||||
bounds: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnnotationManager {
|
|
||||||
private examAnnotations: Set<TLShapeId> = new Set();
|
|
||||||
private markSchemeAnnotations: Set<TLShapeId> = new Set();
|
|
||||||
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
|
|
||||||
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
|
|
||||||
|
|
||||||
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
|
|
||||||
this.annotationData.set(shapeId, data);
|
|
||||||
|
|
||||||
if (data.studentIndex !== undefined) {
|
|
||||||
// Student response annotation
|
|
||||||
let studentSet = this.studentAnnotations.get(data.studentIndex);
|
|
||||||
if (!studentSet) {
|
|
||||||
studentSet = new Set();
|
|
||||||
this.studentAnnotations.set(data.studentIndex, studentSet);
|
|
||||||
}
|
|
||||||
studentSet.add(shapeId);
|
|
||||||
} else {
|
|
||||||
// Exam or mark scheme annotation
|
|
||||||
if (data.pageIndex < 0) {
|
|
||||||
this.examAnnotations.add(shapeId);
|
|
||||||
} else {
|
|
||||||
this.markSchemeAnnotations.add(shapeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAnnotation(shapeId: TLShapeId) {
|
|
||||||
const data = this.annotationData.get(shapeId);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
if (data.studentIndex !== undefined) {
|
|
||||||
const studentSet = this.studentAnnotations.get(data.studentIndex);
|
|
||||||
studentSet?.delete(shapeId);
|
|
||||||
} else {
|
|
||||||
if (data.pageIndex < 0) {
|
|
||||||
this.examAnnotations.delete(shapeId);
|
|
||||||
} else {
|
|
||||||
this.markSchemeAnnotations.delete(shapeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.annotationData.delete(shapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
|
|
||||||
return Array.from(this.studentAnnotations.get(studentIndex) || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForExam(): TLShapeId[] {
|
|
||||||
return Array.from(this.examAnnotations);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForMarkScheme(): TLShapeId[] {
|
|
||||||
return Array.from(this.markSchemeAnnotations);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
|
|
||||||
return this.annotationData.get(shapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.examAnnotations.clear();
|
|
||||||
this.markSchemeAnnotations.clear();
|
|
||||||
this.studentAnnotations.clear();
|
|
||||||
this.annotationData.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future transcription support
|
|
||||||
addTranscriptionToAnnotation(shapeId: TLShapeId) {
|
|
||||||
const data = this.annotationData.get(shapeId);
|
|
||||||
if (data) {
|
|
||||||
this.annotationData.set(shapeId, {
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import 'tldraw/tldraw.css';
|
|
||||||
import { CCPdfEditor } from './CCPdfEditor';
|
|
||||||
import { CCPdfPicker } from './CCPdfPicker';
|
|
||||||
import { ExamPdfState } from './types';
|
|
||||||
import './cc-exam-marker.css';
|
|
||||||
import { HEADER_HEIGHT } from '../../Layout';
|
|
||||||
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
|
|
||||||
|
|
||||||
export const CCExamMarker = () => {
|
|
||||||
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
|
|
||||||
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
|
|
||||||
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isPinned, setIsPinned] = useState(false);
|
|
||||||
|
|
||||||
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
|
|
||||||
setView(newView);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextStudent = () => {
|
|
||||||
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
|
|
||||||
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
|
|
||||||
if (currentStudentIndex < totalStudents - 1) {
|
|
||||||
setCurrentStudentIndex(prev => prev + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviousStudent = () => {
|
|
||||||
if (currentStudentIndex > 0) {
|
|
||||||
setCurrentStudentIndex(prev => prev - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
top: `${HEADER_HEIGHT}px`,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
bgcolor: 'background.default',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}>
|
|
||||||
{state.phase === 'pick' ? (
|
|
||||||
<CCPdfPicker
|
|
||||||
onOpenPdfs={(pdfs) =>
|
|
||||||
setState({
|
|
||||||
phase: 'edit',
|
|
||||||
examPaper: pdfs.examPaper,
|
|
||||||
markScheme: pdfs.markScheme,
|
|
||||||
studentResponses: pdfs.studentResponses,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}>
|
|
||||||
<CCPdfEditor
|
|
||||||
examPaper={state.examPaper}
|
|
||||||
markScheme={state.markScheme}
|
|
||||||
studentResponses={state.studentResponses}
|
|
||||||
currentView={view}
|
|
||||||
currentStudentIndex={currentStudentIndex}
|
|
||||||
onEditorMount={(editor) => {
|
|
||||||
if (!editor) return null;
|
|
||||||
const examMarkerProps = {
|
|
||||||
editor,
|
|
||||||
currentView: view,
|
|
||||||
onViewChange: handleViewChange,
|
|
||||||
currentStudentIndex,
|
|
||||||
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
|
|
||||||
onPreviousStudent: handlePreviousStudent,
|
|
||||||
onNextStudent: handleNextStudent,
|
|
||||||
getCurrentPdf: () => {
|
|
||||||
if (!editor) return null;
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
if (currentPageId.includes('exam-page')) {
|
|
||||||
return state.examPaper;
|
|
||||||
} else if (currentPageId.includes('mark-scheme-page')) {
|
|
||||||
return state.markScheme;
|
|
||||||
} else if (currentPageId.includes('student-response')) {
|
|
||||||
return state.studentResponses;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return <CCPanel
|
|
||||||
examMarkerProps={examMarkerProps}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isPinned={isPinned}
|
|
||||||
onExpandedChange={setIsExpanded}
|
|
||||||
onPinnedChange={setIsPinned}
|
|
||||||
/>;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Editor, exportToBlob } from '@tldraw/tldraw';
|
|
||||||
import { Button } from '@mui/material';
|
|
||||||
import { Pdf } from './types';
|
|
||||||
|
|
||||||
interface CCExportPdfButtonProps {
|
|
||||||
editor: Editor;
|
|
||||||
pdf: Pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
|
||||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const exportPdf = async (
|
|
||||||
editor: Editor,
|
|
||||||
{ name, source, pages }: Pdf,
|
|
||||||
onProgress: (progress: number) => void
|
|
||||||
) => {
|
|
||||||
const totalThings = pages.length * 2 + 2;
|
|
||||||
let progressCount = 0;
|
|
||||||
const tickProgress = () => {
|
|
||||||
progressCount++;
|
|
||||||
onProgress(progressCount / totalThings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pdf = await PDFDocument.load(source);
|
|
||||||
tickProgress();
|
|
||||||
const pdfPages = pdf.getPages();
|
|
||||||
|
|
||||||
if (pdfPages.length !== pages.length) {
|
|
||||||
throw new Error('PDF page count mismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
|
|
||||||
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
|
|
||||||
(id) => !pageShapeIds.has(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
|
||||||
const page = pages[i];
|
|
||||||
const pdfPage = pdfPages[i];
|
|
||||||
const {bounds} = page;
|
|
||||||
|
|
||||||
const shapesInBounds = allIds.filter((id) => {
|
|
||||||
const shapePageBounds = editor.getShapePageBounds(id);
|
|
||||||
if (!shapePageBounds) return false;
|
|
||||||
return shapePageBounds.collides(bounds);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length === 0) {
|
|
||||||
tickProgress();
|
|
||||||
tickProgress();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: allIds,
|
|
||||||
format: 'png',
|
|
||||||
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfPage.getWidth(),
|
|
||||||
height: pdfPage.getHeight(),
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfBytes = await pdf.save();
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })
|
|
||||||
);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="CCExportPdfButton"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
await exportPdf(editor, pdf, setExportProgress);
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 16,
|
|
||||||
right: 16,
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exportProgress
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export PDF'}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
|
|
||||||
import { Tldraw } from '@tldraw/tldraw';
|
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
||||||
import { ExamPdfs } from './types';
|
|
||||||
import { AnnotationManager, AnnotationData } from './AnnotationManager';
|
|
||||||
import { logger } from '../../../debugConfig';
|
|
||||||
|
|
||||||
const PAGE_SPACING = 32; // Same spacing as the example
|
|
||||||
|
|
||||||
interface CCPdfEditorProps extends ExamPdfs {
|
|
||||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
|
||||||
currentStudentIndex: number;
|
|
||||||
onEditorMount: (editor: Editor) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CCPdfEditor({
|
|
||||||
examPaper,
|
|
||||||
markScheme,
|
|
||||||
studentResponses,
|
|
||||||
currentView,
|
|
||||||
currentStudentIndex,
|
|
||||||
onEditorMount,
|
|
||||||
}: CCPdfEditorProps) {
|
|
||||||
const [editor, setEditor] = useState<Editor | null>(null);
|
|
||||||
const [pagesInitialized, setPagesInitialized] = useState(false);
|
|
||||||
const annotationManager = useRef(new AnnotationManager());
|
|
||||||
|
|
||||||
const handleMount = useCallback((editor: Editor) => {
|
|
||||||
setEditor(editor);
|
|
||||||
onEditorMount(editor);
|
|
||||||
|
|
||||||
// Subscribe to shape changes
|
|
||||||
editor.on('change', () => {
|
|
||||||
const shapes = editor.getCurrentPageShapeIds();
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
|
|
||||||
totalShapes: shapes.size,
|
|
||||||
currentPage: editor.getCurrentPageId()
|
|
||||||
});
|
|
||||||
|
|
||||||
shapes.forEach(shapeId => {
|
|
||||||
const shape = editor.getShape(shapeId);
|
|
||||||
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
if (bounds) {
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
let annotationData: AnnotationData;
|
|
||||||
|
|
||||||
if (currentPageId.includes('student-response')) {
|
|
||||||
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
|
|
||||||
|
|
||||||
// Find which page this annotation belongs to by checking collision with page bounds
|
|
||||||
const pageShapes = Array.from(shapes).filter(id => {
|
|
||||||
const s = editor.getShape(id);
|
|
||||||
return s?.isLocked; // Locked shapes are our PDF pages
|
|
||||||
});
|
|
||||||
|
|
||||||
let pageIndex = -1; // Default to -1 if no collision found
|
|
||||||
for (let i = 0; i < pageShapes.length; i++) {
|
|
||||||
const pageShape = editor.getShape(pageShapes[i]);
|
|
||||||
if (!pageShape) continue;
|
|
||||||
|
|
||||||
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
|
|
||||||
if (!pageBounds) continue;
|
|
||||||
|
|
||||||
// Check if the annotation's center point is within the page bounds
|
|
||||||
const annotationCenter = {
|
|
||||||
x: bounds.x + bounds.width / 2,
|
|
||||||
y: bounds.y + bounds.height / 2
|
|
||||||
};
|
|
||||||
|
|
||||||
if (annotationCenter.x >= pageBounds.x &&
|
|
||||||
annotationCenter.x <= pageBounds.x + pageBounds.width &&
|
|
||||||
annotationCenter.y >= pageBounds.y &&
|
|
||||||
annotationCenter.y <= pageBounds.y + pageBounds.height) {
|
|
||||||
pageIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📏 Calculated page index', {
|
|
||||||
shapeId,
|
|
||||||
shapeBounds: bounds,
|
|
||||||
pageIndex,
|
|
||||||
studentIndex
|
|
||||||
});
|
|
||||||
|
|
||||||
annotationData = {
|
|
||||||
studentIndex,
|
|
||||||
pageIndex,
|
|
||||||
shapeId,
|
|
||||||
bounds: {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// For exam/mark scheme, use current page type as index
|
|
||||||
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
|
|
||||||
annotationData = {
|
|
||||||
pageIndex,
|
|
||||||
shapeId,
|
|
||||||
bounds: {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
|
|
||||||
shapeId,
|
|
||||||
annotationData,
|
|
||||||
currentPage: currentPageId
|
|
||||||
});
|
|
||||||
|
|
||||||
annotationManager.current.addAnnotation(shapeId, annotationData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [onEditorMount]);
|
|
||||||
|
|
||||||
// Initial setup effect - runs only once when editor is mounted
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || pagesInitialized) return;
|
|
||||||
|
|
||||||
const setupExamAndMarkScheme = async () => {
|
|
||||||
const examPageId = 'page:exam-page' as TLPageId;
|
|
||||||
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
|
|
||||||
|
|
||||||
// Calculate vertical layout for exam pages
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
const examPages = examPaper.pages.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = top;
|
|
||||||
top += height + PAGE_SPACING;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
return { ...page, top: currentTop, width, height };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center pages horizontally
|
|
||||||
examPages.forEach(page => {
|
|
||||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create exam paper page
|
|
||||||
editor.createPage({
|
|
||||||
id: examPageId,
|
|
||||||
name: 'Exam Paper',
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(examPageId);
|
|
||||||
|
|
||||||
// Create assets and shapes for exam pages
|
|
||||||
examPages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Similar process for mark scheme pages
|
|
||||||
let markSchemeTop = 0;
|
|
||||||
const markSchemePages = markScheme.pages.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = markSchemeTop;
|
|
||||||
markSchemeTop += height + PAGE_SPACING;
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create mark scheme page
|
|
||||||
editor.createPage({
|
|
||||||
id: markSchemePageId,
|
|
||||||
name: 'Mark Scheme',
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(markSchemePageId);
|
|
||||||
|
|
||||||
// Create assets and shapes for mark scheme pages
|
|
||||||
markSchemePages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Go back to exam page
|
|
||||||
editor.setCurrentPage(examPageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupStudentResponses = async () => {
|
|
||||||
const pagesPerStudent = examPaper.pages.length;
|
|
||||||
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
|
|
||||||
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
|
|
||||||
|
|
||||||
// Calculate vertical layout
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
const studentPages = studentResponses.pages
|
|
||||||
.slice(startPage, endPage)
|
|
||||||
.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = top;
|
|
||||||
top += height + PAGE_SPACING;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
return { ...page, top: currentTop, width, height };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center pages horizontally
|
|
||||||
studentPages.forEach(page => {
|
|
||||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create page for this student
|
|
||||||
editor.createPage({
|
|
||||||
id: studentPageId,
|
|
||||||
name: `Student ${studentIndex + 1}`,
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(studentPageId);
|
|
||||||
|
|
||||||
// Create assets and shapes
|
|
||||||
studentPages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial setup of all pages
|
|
||||||
const setup = async () => {
|
|
||||||
await setupExamAndMarkScheme();
|
|
||||||
await setupStudentResponses();
|
|
||||||
setPagesInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
setup();
|
|
||||||
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
|
|
||||||
|
|
||||||
// Effect to handle view changes and navigation
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || !pagesInitialized) return;
|
|
||||||
|
|
||||||
// Switch to appropriate page based on current view
|
|
||||||
const targetPageId = currentView === 'exam-and-markscheme'
|
|
||||||
? ('page:exam-page' as TLPageId)
|
|
||||||
: (`page:student-response-${currentStudentIndex}` as TLPageId);
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Switching view', {
|
|
||||||
currentView,
|
|
||||||
currentStudentIndex,
|
|
||||||
targetPageId
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Update camera constraints for current page
|
|
||||||
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
|
|
||||||
(acc: TLBox | null, shapeId) => {
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
|
|
||||||
},
|
|
||||||
null as TLBox | null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentPageBounds) {
|
|
||||||
const isMobile = editor.getViewportScreenBounds().width < 840;
|
|
||||||
editor.setCameraOptions({
|
|
||||||
constraints: {
|
|
||||||
bounds: currentPageBounds,
|
|
||||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
|
||||||
origin: { x: 0.5, y: 0 },
|
|
||||||
initialZoom: 'fit-x-100',
|
|
||||||
baseZoom: 'default',
|
|
||||||
behavior: 'contain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.setCamera(editor.getCamera(), { reset: true });
|
|
||||||
}
|
|
||||||
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
|
|
||||||
|
|
||||||
// Expose annotationManager to parent through onEditorMount
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor) {
|
|
||||||
onEditorMount(editor);
|
|
||||||
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
|
|
||||||
editor.annotationManager = annotationManager.current;
|
|
||||||
}
|
|
||||||
}, [editor, onEditorMount]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
||||||
<Tldraw
|
|
||||||
onMount={handleMount}
|
|
||||||
components={{
|
|
||||||
InFrontOfTheCanvas: () => onEditorMount(editor!)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
|
||||||
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
|
|
||||||
import { ExamPdfs, Pdf, PdfPage } from './types';
|
|
||||||
|
|
||||||
interface CCPdfPickerProps {
|
|
||||||
onOpenPdfs: (pdfs: ExamPdfs) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageSpacing = 32;
|
|
||||||
|
|
||||||
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
|
|
||||||
|
|
||||||
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
|
|
||||||
const PdfJS = await import('pdfjs-dist');
|
|
||||||
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
|
|
||||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|
||||||
import.meta.url
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
const pdf = await PdfJS.getDocument(source.slice()).promise;
|
|
||||||
const pages: PdfPage[] = [];
|
|
||||||
const canvas = window.document.createElement('canvas');
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) throw new Error('Failed to create canvas context');
|
|
||||||
|
|
||||||
const visualScale = 1.5;
|
|
||||||
const scale = window.devicePixelRatio;
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
|
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const viewport = page.getViewport({ scale: scale * visualScale });
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const renderContext = {
|
|
||||||
canvasContext: context,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.render(renderContext).promise;
|
|
||||||
const width = viewport.width / scale;
|
|
||||||
const height = viewport.height / scale;
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
src: canvas.toDataURL(),
|
|
||||||
bounds: new TLBox(0, top, width, height),
|
|
||||||
assetId: AssetRecordType.createId(),
|
|
||||||
shapeId: createShapeId(),
|
|
||||||
});
|
|
||||||
|
|
||||||
top += height + pageSpacing;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = 0;
|
|
||||||
canvas.height = 0;
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
page.bounds.x = (widest - page.bounds.width) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
pages,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const pdf = await loadPdf(file.name, await file.arrayBuffer());
|
|
||||||
|
|
||||||
// Validate student responses page count
|
|
||||||
if (type === 'studentResponses' && selectedPdfs.examPaper) {
|
|
||||||
const examPageCount = selectedPdfs.examPaper.pages.length;
|
|
||||||
if (pdf.pages.length % examPageCount !== 0) {
|
|
||||||
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading PDF:', error);
|
|
||||||
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFileInput = (type: keyof ExamPdfs) => {
|
|
||||||
const input = window.document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = 'application/pdf';
|
|
||||||
input.addEventListener('change', async (e) => {
|
|
||||||
const fileList = (e.target as HTMLInputElement).files;
|
|
||||||
if (!fileList || fileList.length === 0) return;
|
|
||||||
await handleFileSelect(type, fileList[0]);
|
|
||||||
});
|
|
||||||
input.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const allPdfsSelected = () => {
|
|
||||||
return (
|
|
||||||
selectedPdfs.examPaper &&
|
|
||||||
selectedPdfs.markScheme &&
|
|
||||||
selectedPdfs.studentResponses
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Box className="CCPdfPicker" sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Typography>Loading...</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="CCPdfPicker" sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Stack
|
|
||||||
spacing={4}
|
|
||||||
alignItems="center"
|
|
||||||
sx={{
|
|
||||||
maxWidth: '800px',
|
|
||||||
width: '100%',
|
|
||||||
p: 3
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5">Select PDF Files</Typography>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('examPaper')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('markScheme')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('studentResponses')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{allPdfsSelected() && (
|
|
||||||
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
.CCExamMarker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfPicker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfBgRenderer {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfBgRenderer img {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .PageOverlayScreen-screen {
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
fill: var(--color-background);
|
|
||||||
fill-opacity: 0.8;
|
|
||||||
stroke: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .PageOverlayScreen-outline {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
box-shadow: var(--shadow-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCExportPdfButton {
|
|
||||||
font: inherit;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
color: var(--color-selected-contrast);
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 6px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
pointer-events: all;
|
|
||||||
z-index: var(--layer-panels);
|
|
||||||
border: 2px solid var(--color-background);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCExportPdfButton:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
useTldrawUser,
|
useTldrawUser,
|
||||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
TLStore
|
TLStore,
|
||||||
|
createTLStore,
|
||||||
} from '@tldraw/tldraw';
|
} from '@tldraw/tldraw';
|
||||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@@ -80,80 +81,94 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orchestrated context.node lifecycle — replaces 3 separate effects
|
|
||||||
const [canvasPhase, setCanvasPhase] = useState<
|
const [canvasPhase, setCanvasPhase] = useState<
|
||||||
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error'
|
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||||
>('idle');
|
>('idle');
|
||||||
const phaseError = useRef<string>('');
|
|
||||||
|
|
||||||
|
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
|
||||||
|
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
|
||||||
|
// the store alive for the component lifetime avoids all disposal race conditions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const run = async () => {
|
setCanvasPhase('store-init');
|
||||||
try {
|
const store = createTLStore({
|
||||||
setCanvasPhase('store-init');
|
schema: customSchema,
|
||||||
const newStore = localStoreService.getStore({
|
shapeUtils: allShapeUtils,
|
||||||
schema: customSchema,
|
bindingUtils: allBindingUtils,
|
||||||
shapeUtils: allShapeUtils,
|
});
|
||||||
bindingUtils: allBindingUtils
|
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||||
});
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
|
||||||
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||||
if (accessToken) snapSvc.setAccessToken(accessToken);
|
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||||
snapshotServiceRef.current = snapSvc;
|
snapshotServiceRef.current = snapSvc;
|
||||||
|
|
||||||
if (context.node) {
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
setCanvasPhase('snapshot-loading');
|
const unsubscribe = store.listen(() => {
|
||||||
const path = context.node.node_storage_path;
|
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||||
if (path) {
|
if (debounce) clearTimeout(debounce);
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
debounce = setTimeout(async () => {
|
||||||
path,
|
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
accessToken || '',
|
}, 2000);
|
||||||
newStore,
|
});
|
||||||
setLoadingState,
|
|
||||||
undefined,
|
|
||||||
editorRef.current || undefined
|
|
||||||
);
|
|
||||||
snapSvc.setCurrentNodePath(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelled) {
|
storeRef.current = store;
|
||||||
newStore.dispose();
|
setStoreReady(true);
|
||||||
return;
|
setCanvasPhase('ready');
|
||||||
}
|
|
||||||
|
|
||||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
newStore.listen(() => {
|
|
||||||
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
|
||||||
if (debounce) clearTimeout(debounce);
|
|
||||||
debounce = setTimeout(async () => {
|
|
||||||
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
storeRef.current = newStore;
|
|
||||||
setStoreReady(true);
|
|
||||||
setCanvasPhase('ready');
|
|
||||||
} catch (e) {
|
|
||||||
phaseError.current = e instanceof Error ? e.message : 'Init failed';
|
|
||||||
setCanvasPhase('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
unsubscribe?.();
|
||||||
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
snapshotServiceRef.current?.clearCurrentNode();
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
snapshotServiceRef.current = null;
|
snapshotServiceRef.current = null;
|
||||||
storeRef.current?.dispose();
|
|
||||||
storeRef.current = null;
|
|
||||||
setStoreReady(false);
|
setStoreReady(false);
|
||||||
setCanvasPhase('idle');
|
setCanvasPhase('idle');
|
||||||
|
store.dispose();
|
||||||
|
storeRef.current = null;
|
||||||
};
|
};
|
||||||
}, [user?.id, context.node?.id]);
|
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
|
||||||
|
|
||||||
|
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !storeRef.current) return;
|
||||||
|
|
||||||
|
const store = storeRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadNodeSnapshot = async () => {
|
||||||
|
if (!context.node?.node_storage_path) {
|
||||||
|
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
|
||||||
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
|
setCanvasPhase('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasPhase('snapshot-loading');
|
||||||
|
try {
|
||||||
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
|
context.node.node_storage_path,
|
||||||
|
accessToken || '',
|
||||||
|
store,
|
||||||
|
setLoadingState,
|
||||||
|
undefined,
|
||||||
|
editorRef.current || undefined
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
// Repair store in case snapshot overwrote TLINSTANCE
|
||||||
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
|
||||||
|
setCanvasPhase('ready');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadNodeSnapshot();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
|
||||||
|
|
||||||
// Handle navigation changes — save previous snapshot, load next one.
|
// Handle navigation changes — save previous snapshot, load next one.
|
||||||
// No automatic node shape placement: the canvas shows only persisted state.
|
// No automatic node shape placement: the canvas shows only persisted state.
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ const DashboardPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Open workspace
|
Open workspace
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate('/exam-marker')}
|
||||||
|
>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => navigate('/calendar')}
|
onClick={() => navigate('/calendar')}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
|
||||||
|
*
|
||||||
|
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
|
||||||
|
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
|
||||||
|
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
|
||||||
|
*/
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { API_BASE } from '../../config/apiConfig';
|
||||||
|
import { supabase } from '../../supabaseClient';
|
||||||
|
import type {
|
||||||
|
CreateTemplatePayload,
|
||||||
|
ExamTemplate,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
Neo4jSyncResult,
|
||||||
|
PatchQuestionPayload,
|
||||||
|
SpecPoint,
|
||||||
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
|
|
||||||
|
async function authHeaders(): Promise<Record<string, string>> {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
throw new Error('No authentication token available');
|
||||||
|
}
|
||||||
|
return { Authorization: `Bearer ${session.access_token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const examRepository = {
|
||||||
|
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ templates: ExamTemplate[] }>(
|
||||||
|
`${EXAM_BASE}/templates`,
|
||||||
|
{ headers, params: { include_archived: includeArchived } },
|
||||||
|
);
|
||||||
|
return res.data.templates ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async archiveTemplate(templateId: string): Promise<void> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
|
||||||
|
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
|
||||||
|
{ headers, params: search ? { q: search } : undefined },
|
||||||
|
);
|
||||||
|
if (Array.isArray(res.data)) return res.data;
|
||||||
|
return res.data.points ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default examRepository;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// External imports
|
// External imports
|
||||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
|
||||||
import logger from '../../debugConfig';
|
import logger from '../../debugConfig';
|
||||||
import { SharedStoreService } from './sharedStoreService';
|
import { SharedStoreService } from './sharedStoreService';
|
||||||
|
|
||||||
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize session.currentPageId against pages actually in the document
|
||||||
|
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
|
||||||
|
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
|
||||||
|
const session = snap.session as { currentPageId?: string } | undefined;
|
||||||
|
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
|
||||||
|
currentPageId: session.currentPageId, availablePages: pageIds
|
||||||
|
});
|
||||||
|
session.currentPageId = pageIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
const snapshotCopy = {
|
const snapshotCopy = {
|
||||||
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||||
document: snap.document,
|
document: snap.document,
|
||||||
session: snap.session,
|
session: snap.session,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const targetStore = editor ? editor.store : store;
|
||||||
try {
|
try {
|
||||||
if (editor) {
|
loadSnapshot(targetStore, snapshotCopy as any);
|
||||||
loadSnapshot(editor.store, snapshotCopy as any);
|
// Post-load validation: ensure instance.currentPageId is still valid
|
||||||
} else {
|
const instance = targetStore.get(TLINSTANCE_ID);
|
||||||
loadSnapshot(store, snapshotCopy as any);
|
if (instance) {
|
||||||
|
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
|
||||||
|
const pageValid = pages.some((p) => p.id === instance.currentPageId);
|
||||||
|
if (!pageValid) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
|
||||||
|
targetStore.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
|
|||||||
if (isSchemaMigration) {
|
if (isSchemaMigration) {
|
||||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||||
} else {
|
} else {
|
||||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
|
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
|
||||||
|
targetStore.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
|
||||||
|
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
|
||||||
|
* the shared UUIDs (template/question/region ids, exam_code).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
|
||||||
|
|
||||||
|
export interface ExamTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string | null;
|
||||||
|
exam_id: string | null;
|
||||||
|
exam_code: string | null;
|
||||||
|
source_file_id: string | null;
|
||||||
|
page_count: number | null;
|
||||||
|
institute_id: string;
|
||||||
|
teacher_id: string;
|
||||||
|
status: ExamTemplateStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplatePayload {
|
||||||
|
title: string;
|
||||||
|
subject?: string;
|
||||||
|
exam_id?: string;
|
||||||
|
exam_code?: string;
|
||||||
|
source_file_id?: string;
|
||||||
|
page_count?: number;
|
||||||
|
institute_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
|
||||||
|
|
||||||
|
export interface MarkSchemePoint {
|
||||||
|
mark: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemeLevel {
|
||||||
|
level: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
descriptor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemePart {
|
||||||
|
label: string;
|
||||||
|
marks: number;
|
||||||
|
guidance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemeChecklistItem {
|
||||||
|
text: string;
|
||||||
|
marks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkScheme {
|
||||||
|
type?: MarkSchemeType;
|
||||||
|
points?: MarkSchemePoint[];
|
||||||
|
levels?: MarkSchemeLevel[];
|
||||||
|
parts?: MarkSchemePart[];
|
||||||
|
checklist?: MarkSchemeChecklistItem[];
|
||||||
|
text?: string;
|
||||||
|
notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
|
export interface ExamQuestion {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
max_marks: number;
|
||||||
|
answer_type: string | null;
|
||||||
|
mcq_options: unknown | null;
|
||||||
|
mark_scheme: MarkScheme;
|
||||||
|
is_container: boolean;
|
||||||
|
spec_ref: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamResponseArea {
|
||||||
|
id: string;
|
||||||
|
question_id: string;
|
||||||
|
template_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||||
|
response_form: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source: 'manual' | 'ai';
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamBoundary {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
question_id: string | null;
|
||||||
|
label: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds: Record<string, number> | null;
|
||||||
|
source: 'manual' | 'ai';
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
|
questions: ExamQuestion[];
|
||||||
|
response_areas: ExamResponseArea[];
|
||||||
|
boundaries: ExamBoundary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchQuestionPayload {
|
||||||
|
label?: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown;
|
||||||
|
mark_scheme?: MarkScheme;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecPoint {
|
||||||
|
uid?: string;
|
||||||
|
uuid_string?: string;
|
||||||
|
ref: string;
|
||||||
|
description: string;
|
||||||
|
spec_code: string;
|
||||||
|
exam_board_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Neo4jSyncResult {
|
||||||
|
status: string;
|
||||||
|
projection?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BasePanel } from './shared/BasePanel';
|
import { BasePanel } from './shared/BasePanel';
|
||||||
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
|
|
||||||
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
||||||
|
|
||||||
interface CCPanelProps {
|
interface CCPanelProps {
|
||||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
onPinnedChange?: (pinned: boolean) => void;
|
onPinnedChange?: (pinned: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||||
examMarkerProps,
|
|
||||||
isExpanded,
|
isExpanded,
|
||||||
isPinned,
|
isPinned,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
@@ -31,8 +28,7 @@ export const CCPanel: React.FC<CCPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
examMarkerProps={examMarkerProps}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
onExpandedChange={handleExpandedChange}
|
onExpandedChange={handleExpandedChange}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { TldrawUiButton } from '@tldraw/tldraw';
|
import { TldrawUiButton } from '@tldraw/tldraw';
|
||||||
import { CCShapesPanel } from './CCShapesPanel';
|
import { CCShapesPanel } from './CCShapesPanel';
|
||||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||||
@@ -7,7 +6,6 @@ import { CCFilesPanel } from './CCFilesPanel';
|
|||||||
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
||||||
import { CCYoutubePanel } from './CCYoutubePanel';
|
import { CCYoutubePanel } from './CCYoutubePanel';
|
||||||
import { CCGraphPanel } from './CCGraphPanel';
|
import { CCGraphPanel } from './CCGraphPanel';
|
||||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
|
||||||
import { CCSearchPanel } from './CCSearchPanel';
|
import { CCSearchPanel } from './CCSearchPanel';
|
||||||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
||||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
||||||
@@ -31,16 +29,12 @@ export const PANEL_TYPES = {
|
|||||||
{ id: 'graph', label: 'Graph', order: 60 },
|
{ id: 'graph', label: 'Graph', order: 60 },
|
||||||
{ id: 'search', label: 'Search', order: 70 },
|
{ id: 'search', label: 'Search', order: 70 },
|
||||||
],
|
],
|
||||||
examMarker: [
|
|
||||||
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
|
|
||||||
],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
|
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
|
||||||
|
|
||||||
interface BasePanelProps {
|
interface BasePanelProps {
|
||||||
initialPanelType?: PanelType;
|
initialPanelType?: PanelType;
|
||||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
@@ -125,16 +119,6 @@ function getIconSvg(panelId: PanelType, size = '1.25rem') {
|
|||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case 'exam-marker':
|
|
||||||
return (
|
|
||||||
<svg {...common}>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
<polyline points="10 9 9 9 8 9" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
@@ -152,7 +136,6 @@ function isDarkMode() {
|
|||||||
|
|
||||||
export const BasePanel: React.FC<BasePanelProps> = ({
|
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||||
initialPanelType = 'files',
|
initialPanelType = 'files',
|
||||||
examMarkerProps,
|
|
||||||
isExpanded: controlledIsExpanded,
|
isExpanded: controlledIsExpanded,
|
||||||
isPinned: controlledIsPinned,
|
isPinned: controlledIsPinned,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
@@ -160,7 +143,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
isMenuOpen = false,
|
isMenuOpen = false,
|
||||||
onMenuOpenChange = () => {},
|
onMenuOpenChange = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
|
||||||
const { tldrawPreferences } = useTLDraw();
|
const { tldrawPreferences } = useTLDraw();
|
||||||
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -177,12 +159,9 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
? 'dark'
|
? 'dark'
|
||||||
: 'light';
|
: 'light';
|
||||||
|
|
||||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
const availablePanels = PANEL_TYPES.default;
|
||||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
|
||||||
|
|
||||||
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(
|
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
|
||||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
|
||||||
);
|
|
||||||
|
|
||||||
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
||||||
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
||||||
@@ -223,10 +202,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}, [isExpanded, isPinned]);
|
}, [isExpanded, isPinned]);
|
||||||
|
|
||||||
const renderCurrentPanel = () => {
|
const renderCurrentPanel = () => {
|
||||||
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
|
|
||||||
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (currentPanelType) {
|
switch (currentPanelType) {
|
||||||
case 'cabinets':
|
case 'cabinets':
|
||||||
return <CCCabinetsPanel />;
|
return <CCCabinetsPanel />;
|
||||||
@@ -331,7 +306,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
{type.id === 'search' && 'Search through your content'}
|
{type.id === 'search' && 'Search through your content'}
|
||||||
{type.id === 'navigation' && 'Navigate through different contexts'}
|
{type.id === 'navigation' && 'Navigate through different contexts'}
|
||||||
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
||||||
{type.id === 'exam-marker' && 'Mark and grade exams'}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,495 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
|
|
||||||
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
|
|
||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
|
|
||||||
import { logger } from '../../../../../debugConfig';
|
|
||||||
|
|
||||||
interface CCExamMarkerPanelProps {
|
|
||||||
editor: Editor | null;
|
|
||||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
|
||||||
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
|
|
||||||
currentStudentIndex: number;
|
|
||||||
totalStudents: number;
|
|
||||||
onPreviousStudent: () => void;
|
|
||||||
onNextStudent: () => void;
|
|
||||||
getCurrentPdf: () => Pdf | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
|
|
||||||
editor,
|
|
||||||
currentView,
|
|
||||||
onViewChange,
|
|
||||||
currentStudentIndex,
|
|
||||||
totalStudents,
|
|
||||||
onPreviousStudent,
|
|
||||||
onNextStudent,
|
|
||||||
getCurrentPdf,
|
|
||||||
}) => {
|
|
||||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const exportPdf = async (
|
|
||||||
editor: Editor,
|
|
||||||
{ name, source, pages }: Pdf,
|
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
startPage?: number,
|
|
||||||
endPage?: number,
|
|
||||||
studentIndex?: number
|
|
||||||
) => {
|
|
||||||
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
|
|
||||||
name,
|
|
||||||
startPage,
|
|
||||||
endPage,
|
|
||||||
studentIndex,
|
|
||||||
currentView,
|
|
||||||
totalPages: pages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfPages = pages.slice(startPage, endPage);
|
|
||||||
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
|
|
||||||
pdfPages: pdfPages.length,
|
|
||||||
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalThings = pdfPages.length * 2 + 2;
|
|
||||||
let progressCount = 0;
|
|
||||||
const tickProgress = () => {
|
|
||||||
progressCount++;
|
|
||||||
onProgress(progressCount / totalThings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sourcePdf = await PDFDocument.load(source);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
// Create a new PDF document for the selected pages
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
|
|
||||||
// Copy pages from source PDF
|
|
||||||
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
|
|
||||||
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
// Store current page to restore later
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
|
|
||||||
|
|
||||||
// Switch to the correct page based on context
|
|
||||||
const targetPageId = (studentIndex !== undefined
|
|
||||||
? `page:student-response-${studentIndex}`
|
|
||||||
: currentView === 'exam-and-markscheme'
|
|
||||||
? 'page:exam-page'
|
|
||||||
: 'page:mark-scheme-page') as TLPageId;
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Get all shape IDs that are not page shapes (i.e., annotations)
|
|
||||||
const pageShapeIds = new Set(pages.map(page => page.shapeId));
|
|
||||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
|
|
||||||
totalShapes: editor.getCurrentPageShapeIds().size,
|
|
||||||
pageShapes: pageShapeIds.size,
|
|
||||||
annotationShapes: allShapeIds.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// For each page, draw annotations on top
|
|
||||||
for (let i = 0; i < pdfPages.length; i++) {
|
|
||||||
const page = pdfPages[i];
|
|
||||||
const pdfPage = newPdf.getPages()[i];
|
|
||||||
const {bounds} = page;
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
|
|
||||||
bounds,
|
|
||||||
pageIndex: i,
|
|
||||||
globalPageIndex: (startPage || 0) + i
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get shapes that intersect with this page using editor's bounds checking
|
|
||||||
const shapesInBounds = allShapeIds.filter((id) => {
|
|
||||||
const shape = editor.getShape(id);
|
|
||||||
if (!shape || shape.isLocked) return false;
|
|
||||||
|
|
||||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
|
||||||
const annotationManager = editor.annotationManager;
|
|
||||||
const annotationData = annotationManager.getAnnotationData(id);
|
|
||||||
if (!annotationData) return false;
|
|
||||||
|
|
||||||
// Filter by student index if provided
|
|
||||||
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For exam/markscheme view, only include those annotations
|
|
||||||
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For individual student exports, use the annotation's original page index
|
|
||||||
// For full exports, use the stored page index
|
|
||||||
const adjustedPageIndex = annotationData.pageIndex;
|
|
||||||
|
|
||||||
// Check if this shape belongs to this page index
|
|
||||||
if (adjustedPageIndex !== i) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
|
|
||||||
shapeId: id,
|
|
||||||
annotationData,
|
|
||||||
adjustedPageIndex,
|
|
||||||
currentPageIndex: i,
|
|
||||||
bounds: editor.getShapePageBounds(id)
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
|
|
||||||
shapesInBounds: shapesInBounds.length,
|
|
||||||
pageIndex: i,
|
|
||||||
globalPageIndex: (startPage || 0) + i
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length === 0) {
|
|
||||||
tickProgress();
|
|
||||||
tickProgress();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the annotations as PNG
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: shapesInBounds,
|
|
||||||
format: 'png',
|
|
||||||
opts: {
|
|
||||||
background: false,
|
|
||||||
// Create a new bounds that's relative to the current page
|
|
||||||
bounds: new TLBox(
|
|
||||||
bounds.x,
|
|
||||||
0, // Reset to 0 since we want annotations relative to current page
|
|
||||||
bounds.width,
|
|
||||||
bounds.height
|
|
||||||
),
|
|
||||||
padding: 0,
|
|
||||||
scale: 1
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
// Draw the annotations on the PDF page
|
|
||||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
|
||||||
const pdfWidth = pdfPage.getWidth();
|
|
||||||
const pdfHeight = pdfPage.getHeight();
|
|
||||||
|
|
||||||
pdfPage.drawImage(pngImage, {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfWidth,
|
|
||||||
height: pdfHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore original page
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
|
|
||||||
editor.setCurrentPage(currentPageId);
|
|
||||||
|
|
||||||
const pdfBytes = await newPdf.save();
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' })
|
|
||||||
);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCurrentView = async () => {
|
|
||||||
if (!editor) return;
|
|
||||||
const currentPdf = getCurrentPdf();
|
|
||||||
if (!currentPdf) return;
|
|
||||||
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
if (currentView === 'student-responses') {
|
|
||||||
// For student responses, we need to handle each student's annotations separately
|
|
||||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
|
||||||
let currentProgress = 0;
|
|
||||||
|
|
||||||
// Create a new PDF with all pages
|
|
||||||
const sourcePdf = await PDFDocument.load(currentPdf.source);
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
|
|
||||||
// For each student, export their annotations onto their pages
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
|
|
||||||
// Switch to the student's page to get their annotations
|
|
||||||
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Get all annotations for this student
|
|
||||||
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
|
|
||||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
|
||||||
|
|
||||||
// Process each page for this student
|
|
||||||
for (let i = 0; i < pagesPerStudent; i++) {
|
|
||||||
const pageIndex = startPage + i;
|
|
||||||
const page = currentPdf.pages[pageIndex];
|
|
||||||
const pdfPage = newPdf.getPages()[pageIndex];
|
|
||||||
|
|
||||||
// Get shapes for this page
|
|
||||||
const shapesInBounds = allShapeIds.filter((id) => {
|
|
||||||
const shape = editor.getShape(id);
|
|
||||||
if (!shape || shape.isLocked) return false;
|
|
||||||
|
|
||||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
|
||||||
const annotationManager = editor.annotationManager;
|
|
||||||
const annotationData = annotationManager.getAnnotationData(id);
|
|
||||||
if (!annotationData) return false;
|
|
||||||
|
|
||||||
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length > 0) {
|
|
||||||
// Export and draw annotations
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: shapesInBounds,
|
|
||||||
format: 'png',
|
|
||||||
opts: {
|
|
||||||
background: false,
|
|
||||||
bounds: new TLBox(
|
|
||||||
page.bounds.x,
|
|
||||||
0,
|
|
||||||
page.bounds.width,
|
|
||||||
page.bounds.height
|
|
||||||
),
|
|
||||||
padding: 0,
|
|
||||||
scale: 1
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
|
||||||
pdfPage.drawImage(pngImage, {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfPage.getWidth(),
|
|
||||||
height: pdfPage.getHeight(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currentProgress++;
|
|
||||||
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the combined PDF
|
|
||||||
const pdfBytes = await newPdf.save();
|
|
||||||
const url = URL.createObjectURL(new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' }));
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = currentPdf.name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} else {
|
|
||||||
// For exam/mark scheme view, use the original export logic
|
|
||||||
await exportPdf(editor, currentPdf, setExportProgress);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCurrentStudent = async () => {
|
|
||||||
if (!editor || currentView !== 'student-responses') return;
|
|
||||||
const currentPdf = getCurrentPdf();
|
|
||||||
if (!currentPdf) return;
|
|
||||||
|
|
||||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
|
||||||
const startPage = currentStudentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
await exportPdf(
|
|
||||||
editor,
|
|
||||||
{
|
|
||||||
...currentPdf,
|
|
||||||
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
|
|
||||||
},
|
|
||||||
setExportProgress,
|
|
||||||
Math.floor(startPage),
|
|
||||||
Math.floor(endPage),
|
|
||||||
currentStudentIndex
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatchExport = async () => {
|
|
||||||
if (!editor || currentView !== 'student-responses') return;
|
|
||||||
const currentPdf = getCurrentPdf();
|
|
||||||
if (!currentPdf) return;
|
|
||||||
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
|
||||||
let currentProgress = 0;
|
|
||||||
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
|
|
||||||
await exportPdf(
|
|
||||||
editor,
|
|
||||||
{
|
|
||||||
...currentPdf,
|
|
||||||
name: `Student_${studentIndex + 1}_Response.pdf`,
|
|
||||||
},
|
|
||||||
setExportProgress,
|
|
||||||
Math.floor(startPage),
|
|
||||||
Math.floor(endPage),
|
|
||||||
studentIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
currentProgress++;
|
|
||||||
setExportProgress(currentProgress / totalStudents);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
|
||||||
Exam Marker
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
View Mode
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => onViewChange('exam-and-markscheme')}
|
|
||||||
>
|
|
||||||
Exam & Mark Scheme
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => onViewChange('student-responses')}
|
|
||||||
>
|
|
||||||
Student Responses
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{currentView === 'student-responses' && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
Student Navigation
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onPreviousStudent}
|
|
||||||
disabled={currentStudentIndex === 0}
|
|
||||||
>
|
|
||||||
Previous Student
|
|
||||||
</Button>
|
|
||||||
<Typography variant="body2" align="center">
|
|
||||||
Student {currentStudentIndex + 1} of {totalStudents}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onNextStudent}
|
|
||||||
disabled={currentStudentIndex === totalStudents - 1}
|
|
||||||
>
|
|
||||||
Next Student
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
Actions
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
|
||||||
onClick={handleExportCurrentView}
|
|
||||||
>
|
|
||||||
{exportProgress !== null
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{currentView === 'student-responses' && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
|
||||||
onClick={handleExportCurrentStudent}
|
|
||||||
>
|
|
||||||
{exportProgress !== null
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export Current Student'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
|
||||||
onClick={handleBatchExport}
|
|
||||||
>
|
|
||||||
{exportProgress !== null
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export All as Separate Files'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 'auto' }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
Statistics
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Total Pages: {getCurrentPdf()?.pages.length || 0}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user