Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1dd4dccc | ||
|
|
7bd66fbaf0 | ||
|
|
9c3a5f97cc | ||
|
|
2ccfb9ccd6 | ||
|
|
ef13a124dd | ||
|
|
ffa0ad85ac | ||
|
|
92f9dfef82 | ||
|
|
824031f2c0 | ||
|
|
7db852aaff | ||
|
|
afc0371dd9 | ||
|
|
7326b9f3be | ||
|
|
cd8ac38d39 | ||
|
|
469bcc0517 | ||
|
|
e899af303d | ||
|
|
7a01b3e8f6 | ||
|
|
bff91a4b17 | ||
|
|
66f35b8ae4 | ||
|
|
fe5dbe7fa8 | ||
|
|
15a519748d | ||
|
|
3389fdcb5b | ||
|
|
ab35193be1 | ||
|
|
b3f71c5749 | ||
|
|
b396af96b9 | ||
|
|
3eac792ced | ||
|
|
8e8a345e61 | ||
|
|
2de3e29179 | ||
|
|
29390d30ca | ||
|
|
aa2f35e467 | ||
|
|
61a189a7a2 | ||
|
|
ab6f0b09d7 | ||
|
|
16ae3aa089 | ||
|
|
496ec2cbf9 | ||
|
|
dea3275f23 | ||
|
|
f067db3eb8 | ||
|
|
1ab8aab43c | ||
|
|
fdbc19cf0d | ||
|
|
adc7a2a05b | ||
|
|
29554ebdbd | ||
|
|
434643596e | ||
|
|
313ab724b8 | ||
|
|
ee7d7f7cac | ||
|
|
0dd18c4fd4 | ||
|
|
5869c741d7 | ||
|
|
5b6e461706 | ||
|
|
53adc74a1c | ||
|
|
9a7cc86a75 | ||
|
|
6cba932146 | ||
|
|
ba829be8e9 | ||
|
|
9f58917c39 | ||
|
|
a375fbe37f | ||
|
|
38ca23dc3e | ||
|
|
8fe8edb341 | ||
|
|
143fff6414 | ||
|
|
c216df8021 | ||
|
|
f7bb84f9b8 | ||
|
|
cbff4deef2 | ||
|
|
85ecae7147 | ||
|
|
8a79675497 | ||
|
|
6951bc1700 | ||
|
|
e6f6bd5c3a | ||
|
|
b3ec09fbef | ||
|
|
765573163d | ||
|
|
1505adb154 | ||
|
|
ade0be97f6 | ||
|
|
96db086ecb | ||
|
|
6610abbd72 | ||
|
|
489c2208c5 | ||
|
|
3db843a6f9 | ||
|
|
79e7d4df9c | ||
|
|
2e3cb134d1 | ||
|
|
aca4d045b2 | ||
|
|
a0afbc90f5 | ||
|
|
57429f3a75 | ||
|
|
01a8a59349 | ||
|
|
0150ca3c32 | ||
|
|
33b9278085 | ||
|
|
9196af12f8 | ||
|
|
f6e2ab63dd | ||
|
|
f1c1a72f44 | ||
|
|
1e7b387b43 | ||
|
|
135ba4d26d | ||
|
|
8c1623256b | ||
|
|
b246106876 | ||
|
|
d5a7464e15 | ||
|
|
061cd22748 | ||
|
|
55da04a3f7 | ||
|
|
2a6359e247 | ||
|
|
3afba919c4 | ||
|
|
07ceef1294 | ||
|
|
50c7bb68bb | ||
|
|
3ffbb11552 | ||
|
|
669a6616d8 | ||
|
|
65bce2c52d | ||
|
|
cd37771c52 | ||
|
|
df59207add | ||
|
|
2d15b7cc03 | ||
|
|
d3bd25d544 |
@@ -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/
|
||||||
|
|||||||
+61
-7
@@ -7,18 +7,45 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
||||||
# during image build, not only at container runtime.
|
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
||||||
ARG ENV_FILE=.env
|
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
||||||
COPY ${ENV_FILE} .env
|
ARG VITE_API_BASE
|
||||||
|
ARG VITE_API_URL
|
||||||
# Run build with production mode
|
ARG VITE_APP_NAME
|
||||||
RUN npm run build -- --mode production
|
ARG VITE_APP_HMR_URL
|
||||||
|
ARG VITE_DEV
|
||||||
|
ARG VITE_FRONTEND_SITE_URL
|
||||||
|
ARG VITE_SEARCH_URL
|
||||||
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
|
ARG VITE_SUPABASE_URL
|
||||||
|
ARG VITE_SUPER_ADMIN_EMAIL
|
||||||
|
ARG VITE_TLSYNC_URL
|
||||||
|
ARG VITE_WHISPERLIVE_URL
|
||||||
|
# Run build with production mode. Keep these as build-step environment values
|
||||||
|
# rather than final-image ENV entries; Vite still embeds the public client config
|
||||||
|
# into the static bundle, but nginx image metadata does not need them.
|
||||||
|
RUN VITE_API_BASE="${VITE_API_BASE}" \
|
||||||
|
VITE_API_URL="${VITE_API_URL}" \
|
||||||
|
VITE_APP_NAME="${VITE_APP_NAME}" \
|
||||||
|
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
|
||||||
|
VITE_DEV="${VITE_DEV}" \
|
||||||
|
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
|
||||||
|
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
|
||||||
|
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
|
||||||
|
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
|
||||||
|
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
|
||||||
|
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
|
||||||
|
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
|
||||||
|
npm run build -- --mode production
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
|
||||||
|
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
|
||||||
|
|
||||||
# Create a simple nginx configuration
|
# Create a simple nginx configuration
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 3000; \
|
listen 3000; \
|
||||||
@@ -28,6 +55,33 @@ RUN echo 'server { \
|
|||||||
expires -1; \
|
expires -1; \
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
||||||
} \
|
} \
|
||||||
|
location = /health { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/health; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
|
location /__ccapi/ { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
|
location /__supabase/ { \
|
||||||
|
proxy_pass http://192.168.0.94:8000/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
proxy_set_header Upgrade $http_upgrade; \
|
||||||
|
proxy_set_header Connection "upgrade"; \
|
||||||
|
proxy_http_version 1.1; \
|
||||||
|
} \
|
||||||
|
location /api/ { \
|
||||||
|
proxy_pass http://192.168.0.64:18000/api/; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
proxy_set_header X-Real-IP $remote_addr; \
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||||
|
} \
|
||||||
location / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
|
|||||||
+22
-1
@@ -16,7 +16,28 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
ENV_FILE: .env.dev
|
# app-dev is served by nginx on the app host; browser API calls must stay
|
||||||
|
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
|
||||||
|
# strips that prefix before forwarding, preserving mixed backend routes
|
||||||
|
# such as /api/exam, /me/bootstrap, and /database/timetable.
|
||||||
|
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
|
||||||
|
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
|
||||||
|
# leading-slash URL is resolved against window.location.origin in
|
||||||
|
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
|
||||||
|
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
|
||||||
|
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
|
||||||
|
VITE_API_BASE: /__ccapi
|
||||||
|
VITE_API_URL: /__ccapi
|
||||||
|
VITE_SUPABASE_URL: /__supabase
|
||||||
|
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
|
||||||
|
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
|
||||||
|
VITE_DEV: ${VITE_DEV:-false}
|
||||||
|
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
|
||||||
|
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
|
||||||
|
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
|
||||||
|
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
|
||||||
|
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
|
||||||
|
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -84,7 +84,6 @@
|
|||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react-refresh": "^0.14.2",
|
"react-refresh": "^0.14.2",
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './src/__tests__/e2e',
|
||||||
|
testMatch: '**/*.spec.js',
|
||||||
|
timeout: 30000,
|
||||||
|
expect: { timeout: 5000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 1,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
webServer: undefined,
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
'postcss-import': {},
|
'postcss-import': {},
|
||||||
},
|
},
|
||||||
|
|||||||
+46
-5
@@ -1,5 +1,8 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { useMemo, useEffect } from 'react';
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
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';
|
||||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||||
@@ -8,21 +11,59 @@ import AppRoutes from './AppRoutes';
|
|||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const App = React.memo(() => (
|
/**
|
||||||
|
* Syncs tldraw's color scheme to data-color-mode on <html>.
|
||||||
|
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
|
||||||
|
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
|
||||||
|
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
|
||||||
|
* to the document root so --cc-* token overrides apply globally.
|
||||||
|
*/
|
||||||
|
const DarkModeSync: React.FC = () => {
|
||||||
|
const { tldrawPreferences } = useTLDraw();
|
||||||
|
const prefersDarkMode =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scheme = tldrawPreferences?.colorScheme ?? 'system';
|
||||||
|
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
|
||||||
|
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
|
||||||
|
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = React.memo(() => {
|
||||||
|
const prefersDarkMode =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
const appTheme = useMemo(() => {
|
||||||
|
// App is outside TLDrawProvider so tldrawPreferences is always null here.
|
||||||
|
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
|
||||||
|
// updating data-color-mode once tldraw preferences are loaded.
|
||||||
|
const mode = prefersDarkMode ? 'dark' : 'light';
|
||||||
|
return createTheme({ palette: { mode } });
|
||||||
|
}, [prefersDarkMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<TLDrawProvider>
|
<TLDrawProvider>
|
||||||
|
<DarkModeSync />
|
||||||
|
<ThemeProvider theme={appTheme}>
|
||||||
|
<CssBaseline />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</ThemeProvider>
|
||||||
</TLDrawProvider>
|
</TLDrawProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
|
|
||||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ 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>,
|
||||||
|
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
||||||
|
MarkSchemePage: () => <div>Mark Scheme editor</div>,
|
||||||
|
ExamMarkingPage: () => <div>Exam Marking</div>,
|
||||||
|
ExamResultsPage: () => <div>Exam Results</div>,
|
||||||
|
ResultsWidget: () => <div>Results Widget</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 +53,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 +129,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+38
-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, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, 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,
|
||||||
@@ -45,8 +47,6 @@ import {
|
|||||||
} from './pages/timetable';
|
} from './pages/timetable';
|
||||||
|
|
||||||
const FullContextRoutes: React.FC = () => {
|
const FullContextRoutes: React.FC = () => {
|
||||||
// Only block on Supabase profile being ready — Neo4j contexts initialize in the background.
|
|
||||||
// Individual pages handle their own Neo4j loading states.
|
|
||||||
const { isInitialized: isUserInitialized } = useUser();
|
const { isInitialized: isUserInitialized } = useUser();
|
||||||
|
|
||||||
if (!isUserInitialized) {
|
if (!isUserInitialized) {
|
||||||
@@ -68,6 +68,31 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RequireAuth: React.FC = () => {
|
||||||
|
const { user, isAuthResolving } = useAuth();
|
||||||
|
|
||||||
|
if (isAuthResolving) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppRoutes: React.FC = () => {
|
||||||
const { user, user_role, loading: isAuthLoading } = useAuth();
|
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -136,13 +161,15 @@ const AppRoutes: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
{user && (
|
<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 />} />
|
||||||
|
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
|
||||||
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
<Route path="/student-lessons" element={<StudentLessonsPage />} />
|
||||||
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
<Route path="/lesson-plans" element={<LessonPlansPage />} />
|
||||||
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
|
||||||
@@ -156,7 +183,11 @@ const AppRoutes: React.FC = () => {
|
|||||||
{/* Existing Routes */}
|
{/* Existing Routes */}
|
||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></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 />} />
|
||||||
@@ -167,7 +198,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>
|
||||||
)}
|
</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,47 @@
|
|||||||
|
import { test, expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
|
||||||
|
await ctx.dispose();
|
||||||
|
return Boolean(res);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.getByLabel('Email').fill(TEST_EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(TEST_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfUnconfigured() {
|
||||||
|
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Canvas mount', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
skipIfUnconfigured();
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login and show lesson canvas within 5s', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const canvasVisible = await page
|
||||||
|
.locator('[data-testid="tldraw-canvas"], .tl-container')
|
||||||
|
.waitFor({ timeout: 10000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(canvasVisible).toBe(true);
|
||||||
|
await expect(page.locator('[class*="error-state"], [data-testid="error"]').first()).toBeHidden({ timeout: 2000 }).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/// <reference types="@playwright/test" />
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
|
||||||
|
test.describe('Canvas mount', () => {
|
||||||
|
test('login and show lesson canvas', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
|
||||||
|
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
||||||
|
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
async function pingOk() {
|
||||||
|
try {
|
||||||
|
const r = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
|
||||||
|
() => null as any
|
||||||
|
);
|
||||||
|
return Boolean(r && (r as any).ok());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await pingOk())) test.skip(true, 'Dev server not reachable at ' + BASE);
|
||||||
|
|
||||||
|
const canvasVisible = page
|
||||||
|
.locator('[data-testid="tldraw-canvas"], .tl-container')
|
||||||
|
.waitFor({ timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
expect((await canvasVisible)).toBe(true);
|
||||||
|
await expect(page.getByText(/error|failed to load/i).first()).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
|
||||||
|
await ctx.dispose();
|
||||||
|
return Boolean(res);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.getByLabel('Email').fill(TEST_EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(TEST_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfUnconfigured() {
|
||||||
|
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Node navigation', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
skipIfUnconfigured();
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canvas remounts cleanly on node navigation', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', err => errors.push(err.message));
|
||||||
|
await login(page);
|
||||||
|
await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 });
|
||||||
|
const navLinks = page.locator('nav a, [data-testid="nav-node"]');
|
||||||
|
const count = await navLinks.count();
|
||||||
|
if (count > 1) {
|
||||||
|
await navLinks.nth(1).click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const hasCanvas = await page.locator('[data-testid="tldraw-canvas"], .tl-container')
|
||||||
|
.waitFor({ timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
expect(hasCanvas).toBe(true);
|
||||||
|
}
|
||||||
|
const criticalErrors = errors.filter(e => /uncaught|undefined is not/i.test(e));
|
||||||
|
expect(criticalErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/// <reference types="@playwright/test" />
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
|
||||||
|
test.describe('Node navigation', () => {
|
||||||
|
test('canvas remounts on node change', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
|
||||||
|
() => null as any
|
||||||
|
);
|
||||||
|
return Boolean(res && (res as any).ok());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE);
|
||||||
|
|
||||||
|
const consoleMessages: string[] = [];
|
||||||
|
page.on('console', (msg: any) => consoleMessages.push((msg.text() as string) || ''));
|
||||||
|
page.on('pageerror', (err: any) => consoleMessages.push('pageerror: ' + ((err.message as string) || 'unknown')));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
|
||||||
|
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
||||||
|
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
const canvas = page.locator('[data-testid="tldraw-canvas"], .tl-container');
|
||||||
|
if ((await canvas.count()) === 0) await canvas.first().waitFor({ timeout: 8000 });
|
||||||
|
await canvas.first().waitFor({ state: 'visible', timeout: 8000 });
|
||||||
|
|
||||||
|
const nodes = page.locator('a[href*="/node/"]').first();
|
||||||
|
if ((await nodes.count()) === 0) test.skip(true, 'No node navigation links found');
|
||||||
|
await nodes.click();
|
||||||
|
await page.waitForURL((url: URL) => /\/node\/\d+/.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
await expect(canvas.first()).toBeVisible();
|
||||||
|
const errorTexts = consoleMessages.some((m) => /error|failed/i.test(m));
|
||||||
|
expect(errorTexts).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect, request } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
|
||||||
|
await ctx.dispose();
|
||||||
|
return Boolean(res);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.getByLabel('Email').fill(TEST_EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(TEST_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipIfUnconfigured() {
|
||||||
|
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Transcription toggle', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
skipIfUnconfigured();
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('record button toggles isRecording state', async ({ page, context }) => {
|
||||||
|
await context.grantPermissions(['microphone']);
|
||||||
|
await login(page);
|
||||||
|
await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 });
|
||||||
|
const recordBtn = page.locator('[data-testid="record-button"], button[aria-label*="ecord" i]').first();
|
||||||
|
const btnVisible = await recordBtn.waitFor({ timeout: 5000 }).then(() => true).catch(() => false);
|
||||||
|
if (btnVisible) {
|
||||||
|
const labelBefore = await recordBtn.getAttribute('aria-label').catch(() => '');
|
||||||
|
await recordBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const labelAfter = await recordBtn.getAttribute('aria-label').catch(() => '');
|
||||||
|
expect(labelBefore).not.toEqual(labelAfter);
|
||||||
|
await recordBtn.click();
|
||||||
|
} else {
|
||||||
|
test.skip(true, 'Record button not visible on current node type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/// <reference types="@playwright/test" />
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
|
||||||
|
test.describe('Transcription toggle', () => {
|
||||||
|
test('record and stop preserve segments', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
async function serverReachable() {
|
||||||
|
try {
|
||||||
|
const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
|
||||||
|
() => null as any
|
||||||
|
);
|
||||||
|
return Boolean(res && (res as any).ok());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE);
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
|
||||||
|
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
||||||
|
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
|
||||||
|
|
||||||
|
const recordButton = page.locator('[data-testid="record-button"], button[aria-label*="record" i], button[aria-label*="Record" i]').first();
|
||||||
|
if ((await recordButton.count()) === 0) test.skip(true, 'No transcription record control found');
|
||||||
|
const beforeCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count();
|
||||||
|
|
||||||
|
await recordButton.click();
|
||||||
|
await expect(recordButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await recordButton.click();
|
||||||
|
|
||||||
|
const afterCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count();
|
||||||
|
expect(afterCount).toBeGreaterThanOrEqual(beforeCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ export interface AuthContextType {
|
|||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
bootstrapData: BootstrapResponse | null;
|
bootstrapData: BootstrapResponse | null;
|
||||||
|
isAuthResolving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
@@ -29,7 +30,8 @@ export const AuthContext = createContext<AuthContextType>({
|
|||||||
signIn: async () => {},
|
signIn: async () => {},
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
clearError: () => {},
|
clearError: () => {},
|
||||||
bootstrapData: null
|
bootstrapData: null,
|
||||||
|
isAuthResolving: false
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||||
|
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const persistSession = useCallback((session: Session | null) => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
@@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN') {
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
|
setIsAuthResolving(!!session?.user);
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
@@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
|
setIsAuthResolving(!!session?.user);
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
@@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsAuthResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'SIGNED_OUT') {
|
if (event === 'SIGNED_OUT') {
|
||||||
persistSession(null);
|
persistSession(null);
|
||||||
|
setIsAuthResolving(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
@@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
clearError,
|
clearError,
|
||||||
bootstrapData
|
bootstrapData,
|
||||||
|
isAuthResolving
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type LogCategory =
|
|||||||
| 'neo-institute-context'
|
| 'neo-institute-context'
|
||||||
| 'auth-service'
|
| 'auth-service'
|
||||||
| 'graph-service'
|
| 'graph-service'
|
||||||
|
| 'graph-sidebar'
|
||||||
| 'registration-service'
|
| 'registration-service'
|
||||||
| 'provisioning-service'
|
| 'provisioning-service'
|
||||||
| 'snapshot-service'
|
| 'snapshot-service'
|
||||||
@@ -111,6 +112,7 @@ export type LogCategory =
|
|||||||
| 'user-context'
|
| 'user-context'
|
||||||
| 'neo-user-context'
|
| 'neo-user-context'
|
||||||
| 'neo-institute-context'
|
| 'neo-institute-context'
|
||||||
|
| 'cc-live-transcription'
|
||||||
| 'search-service';
|
| 'search-service';
|
||||||
|
|
||||||
interface LogConfig {
|
interface LogConfig {
|
||||||
|
|||||||
+4
-5
@@ -1,7 +1,6 @@
|
|||||||
/* src/index.css */
|
/* src/index.css */
|
||||||
@tailwind base;
|
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
|
||||||
@tailwind components;
|
@import './tailwind-compat.css';
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
html,
|
html,
|
||||||
@@ -152,10 +151,10 @@ body {
|
|||||||
|
|
||||||
.login-role-header {
|
.login-role-header {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
color: #1976d2 !important;
|
color: var(--cc-action-primary) !important;
|
||||||
margin-bottom: 24px !important;
|
margin-bottom: 24px !important;
|
||||||
padding-bottom: 8px !important;
|
padding-bottom: 8px !important;
|
||||||
border-bottom: 2px solid #1976d2 !important;
|
border-bottom: 2px solid var(--cc-action-primary) !important;
|
||||||
width: fit-content !important;
|
width: fit-content !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
|
||||||
|
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
|
||||||
|
layouts, questions, boundaries, response areas, marking batches, student submissions, and
|
||||||
|
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
|
||||||
|
schools, users, or timetable data.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const getLoginErrorMessage = (error: unknown): string => {
|
||||||
|
const status = (error as { status?: number } | null)?.status;
|
||||||
|
const code = (error as { code?: string } | null)?.code;
|
||||||
|
|
||||||
|
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
|
||||||
|
return 'Please confirm your email before logging in';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 401 || code === 'invalid_grant') {
|
||||||
|
return 'Invalid email or password';
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error ? error.message : 'Login failed';
|
||||||
|
};
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, signIn } = useAuth();
|
const { user, signIn, isAuthResolving } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
logger.debug('login-page', '🔍 Login page loaded', {
|
logger.debug('login-page', '🔍 Login page loaded', {
|
||||||
hasUser: !!user
|
hasUser: !!user,
|
||||||
|
isAuthResolving
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (!isAuthResolving && user) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, isAuthResolving, navigate]);
|
||||||
|
|
||||||
const handleLogin = async (credentials: EmailCredentials) => {
|
const handleLogin = async (credentials: EmailCredentials) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signIn(credentials.email, credentials.password);
|
await signIn(credentials.email, credentials.password);
|
||||||
navigate('/dashboard');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('login-page', '❌ Login failed', error);
|
logger.error('login-page', '❌ Login failed', error);
|
||||||
setError(error instanceof Error ? error.message : 'Login failed');
|
const message = getLoginErrorMessage(error);
|
||||||
|
setError(message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user && !isAuthResolving) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { RegistrationService } from '../../services/auth/registrationService';
|
import { RegistrationService } from '../../services/auth/registrationService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import MicrosoftIcon from '@mui/icons-material/Microsoft';
|
import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
|
||||||
|
|
||||||
const SignupPage: React.FC = () => {
|
const SignupPage: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
CloudUpload as UploadIcon,
|
CloudUpload as UploadIcon,
|
||||||
|
Upload,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
FolderOpen as FolderOpenIcon,
|
FolderOpen as FolderOpenIcon,
|
||||||
Description as FileIcon,
|
Description as FileIcon,
|
||||||
@@ -39,8 +40,12 @@ import {
|
|||||||
PlayArrow as ProcessIcon,
|
PlayArrow as ProcessIcon,
|
||||||
CheckCircle as SuccessIcon,
|
CheckCircle as SuccessIcon,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
Info as InfoIcon
|
Info as InfoIcon,
|
||||||
|
InsertDriveFile,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
const FolderOpen = FolderOpenIcon;
|
||||||
|
const Refresh = RefreshIcon;
|
||||||
|
const Info = InfoIcon;
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import {
|
import {
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, 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 ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
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 VERSION_SEPARATOR = ' · ';
|
||||||
|
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
|
||||||
|
|
||||||
|
type DialogMode = 'create' | 'edit' | 'duplicate';
|
||||||
|
|
||||||
|
type TemplateDialogState = {
|
||||||
|
mode: DialogMode;
|
||||||
|
template?: ExamTemplate;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
function splitTemplateTitle(title: string): { name: string; version: string } {
|
||||||
|
const parts = title.split(VERSION_SEPARATOR);
|
||||||
|
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
|
||||||
|
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
|
||||||
|
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
|
||||||
|
}
|
||||||
|
return { name: title, version: 'v1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeTemplateTitle(name: string, version: string): string {
|
||||||
|
const cleanName = name.trim();
|
||||||
|
const cleanVersion = version.trim();
|
||||||
|
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVersionLabel(version: string): string {
|
||||||
|
const match = version.trim().match(VERSION_RE);
|
||||||
|
if (!match) return 'v2';
|
||||||
|
const segments = match[1].split('.');
|
||||||
|
const last = Number(segments[segments.length - 1]);
|
||||||
|
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
|
||||||
|
return `v${segments.join('.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paperKey(t: ExamTemplate): string {
|
||||||
|
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
|
||||||
|
}
|
||||||
|
|
||||||
|
function paperLabel(t: ExamTemplate): string {
|
||||||
|
if (t.exam_code) return t.exam_code;
|
||||||
|
if (t.subject) return t.subject;
|
||||||
|
if (t.source_file_id) return 'Uploaded paper';
|
||||||
|
return 'Custom paper';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [dialog, setDialog] = useState<TemplateDialogState>(null);
|
||||||
|
const [templateName, setTemplateName] = useState('');
|
||||||
|
const [version, setVersion] = useState('v1');
|
||||||
|
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 groupedTemplates = useMemo(() => {
|
||||||
|
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
||||||
|
templates.forEach((template) => {
|
||||||
|
const key = paperKey(template);
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.templates.push(template);
|
||||||
|
} else {
|
||||||
|
groups.set(key, { label: paperLabel(template), templates: [template] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setTemplateName('New template');
|
||||||
|
setVersion('v1');
|
||||||
|
setSubject('');
|
||||||
|
setDialog({ mode: 'create' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
setTemplateName(parsed.name);
|
||||||
|
setVersion(parsed.version);
|
||||||
|
setSubject(template.subject ?? '');
|
||||||
|
setDialog({ mode: 'edit', template });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
setTemplateName(parsed.name);
|
||||||
|
setVersion(nextVersionLabel(parsed.version));
|
||||||
|
setSubject(template.subject ?? '');
|
||||||
|
setDialog({ mode: 'duplicate', template });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
if (!saving) setDialog(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDialog = async () => {
|
||||||
|
if (!dialog || !templateName.trim()) return;
|
||||||
|
const title = composeTemplateTitle(templateName, version);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (dialog.mode === 'create') {
|
||||||
|
const created = await examRepository.createTemplate({
|
||||||
|
title,
|
||||||
|
subject: subject.trim() || undefined,
|
||||||
|
institute_id: instituteId,
|
||||||
|
});
|
||||||
|
setDialog(null);
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dialog.template) return;
|
||||||
|
|
||||||
|
if (dialog.mode === 'duplicate') {
|
||||||
|
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
|
||||||
|
setTemplates((prev) => [created, ...prev]);
|
||||||
|
setDialog(null);
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
|
||||||
|
title,
|
||||||
|
subject: subject.trim() || null,
|
||||||
|
});
|
||||||
|
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
|
setDialog(null);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const parsed = splitTemplateTitle(template.title);
|
||||||
|
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await examRepository.archiveTemplate(template.id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTitle = dialog?.mode === 'edit'
|
||||||
|
? 'Rename template / edit version'
|
||||||
|
: dialog?.mode === 'duplicate'
|
||||||
|
? 'Duplicate as new version'
|
||||||
|
: 'New exam template';
|
||||||
|
|
||||||
|
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: 620 }}>
|
||||||
|
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
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 named template to start mapping an exam paper.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{groupedTemplates.map((group) => (
|
||||||
|
<Box key={group.key}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="h6">{group.label}</Typography>
|
||||||
|
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{group.templates.map((t) => {
|
||||||
|
const parsed = splitTemplateTitle(t.title);
|
||||||
|
return (
|
||||||
|
<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', gap: 1 }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||||
|
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<Tooltip title="Rename / edit version">
|
||||||
|
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Duplicate as new version">
|
||||||
|
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Archive">
|
||||||
|
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
||||||
|
<ArchiveIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</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">
|
||||||
|
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Template name"
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
helperText="User-facing name. Several templates can share the same paper."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Version"
|
||||||
|
value={version}
|
||||||
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
helperText="Stored in the template title until the API grows a dedicated version column."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Paper / subject label"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
disabled={dialog?.mode === 'duplicate'}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||||
|
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamDashboardPage;
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { v5 as uuidv5 } from 'uuid';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
|
||||||
|
|
||||||
|
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
|
||||||
|
|
||||||
|
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
|
||||||
|
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamMarkingPage: React.FC = () => {
|
||||||
|
const { batchId } = useParams<{ batchId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [marks, setMarks] = useState<Record<string, string>>({});
|
||||||
|
const [comments, setComments] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const nextQueue = await examRepository.getBatchQueue(batchId);
|
||||||
|
setQueue(nextQueue);
|
||||||
|
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
|
||||||
|
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [batchId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const markableQuestions = useMemo(
|
||||||
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
const onScanFiles = useCallback(async (files: FileList | null) => {
|
||||||
|
if (!batchId || !files || files.length === 0) return;
|
||||||
|
setUploading(true); setError(null); setMessage(null);
|
||||||
|
let ok = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
|
||||||
|
// become an unmatched submission the teacher can reconcile.
|
||||||
|
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
|
||||||
|
ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
await load();
|
||||||
|
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
|
||||||
|
if (failures.length) setError(failures.join(' · '));
|
||||||
|
}, [batchId, load]);
|
||||||
|
|
||||||
|
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const saveSelected = async () => {
|
||||||
|
if (!batchId || !selected) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const writes = markableQuestions
|
||||||
|
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
|
||||||
|
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
|
||||||
|
for (const { q, raw, comment } of writes) {
|
||||||
|
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
|
||||||
|
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new Error(`Invalid mark for ${q.label}`);
|
||||||
|
}
|
||||||
|
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
|
||||||
|
submission_id: selected.id,
|
||||||
|
question_id: q.id,
|
||||||
|
awarded_marks: awarded,
|
||||||
|
comment: comment?.trim() || undefined,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
|
||||||
|
setMarks({});
|
||||||
|
setComments({});
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseStudent = (submission: StudentSubmission) => {
|
||||||
|
setSelectedId(submission.id);
|
||||||
|
setMarks({});
|
||||||
|
setComments({});
|
||||||
|
setMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !queue) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||||
|
Back to Exam Marker
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
|
||||||
|
Results
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
|
||||||
|
{markableQuestions.length === 0 && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
This template has no markable parts yet. Add parts in template setup before entering marks.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
|
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
|
||||||
|
<Typography variant="h6">Marking queue</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<UploadFileIcon fontSize="small" />}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload scans'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => { void onScanFiles(e.target.files); }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||||
|
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
|
||||||
|
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
|
||||||
|
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{(queue?.submissions ?? []).map((submission) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={submission.id}
|
||||||
|
selected={submission.id === selectedId}
|
||||||
|
onClick={() => chooseStudent(submission)}
|
||||||
|
sx={{ borderRadius: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={submission.student_name || submission.student_id || 'Unknown student'}
|
||||||
|
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
|
||||||
|
/>
|
||||||
|
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ flex: 1 }}>
|
||||||
|
<CardContent>
|
||||||
|
{selected ? (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
{markableQuestions.map((q: ExamQuestion) => (
|
||||||
|
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||||
|
<Box sx={{ minWidth: 150 }}>
|
||||||
|
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Mark"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
|
||||||
|
value={marks[q.id] ?? ''}
|
||||||
|
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||||
|
sx={{ width: { xs: '100%', sm: 120 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Comment"
|
||||||
|
size="small"
|
||||||
|
value={comments[q.id] ?? ''}
|
||||||
|
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
|
||||||
|
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
|
||||||
|
{saving ? 'Saving…' : 'Save marks'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamMarkingPage;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchResultsResponse } from '../../types/exam.types';
|
||||||
|
|
||||||
|
function formatMark(value: number | null | undefined) {
|
||||||
|
return value === null || value === undefined ? '' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamResultsPage: React.FC = () => {
|
||||||
|
const { batchId } = useParams<{ batchId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<BatchResultsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setData(await examRepository.getBatchResults(batchId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [batchId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const rows = data?.results ?? [];
|
||||||
|
const presentTotals = rows
|
||||||
|
.map((r) => r.total)
|
||||||
|
.filter((v): v is number => typeof v === 'number');
|
||||||
|
const average = presentTotals.length
|
||||||
|
? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
|
||||||
|
marked: rows.filter((r) => r.total !== null).length,
|
||||||
|
average,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const downloadCsv = async () => {
|
||||||
|
if (!batchId) return;
|
||||||
|
setDownloading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const csv = await examRepository.getBatchCsv(batchId);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `batch-${batchId}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">{error || 'Results not found'}</Alert>
|
||||||
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
||||||
|
Back to Exam Marker
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" component="h1" fontWeight={700}>
|
||||||
|
{data.batch.title || 'Exam results'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
|
||||||
|
Mark
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
|
||||||
|
{downloading ? 'Preparing…' : 'Download CSV'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip label={`${summary.total} students`} />
|
||||||
|
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
|
||||||
|
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
|
||||||
|
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Student</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
{data.questions.map((q) => (
|
||||||
|
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">Total</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.results.map((row) => (
|
||||||
|
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
|
||||||
|
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
||||||
|
</TableCell>
|
||||||
|
{data.questions.map((q) => (
|
||||||
|
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamResultsPage;
|
||||||
@@ -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,169 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
MenuItem,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||||
|
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types';
|
||||||
|
|
||||||
|
interface ResultsWidgetProps {
|
||||||
|
classId: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageFromResults(results: BatchResultsResponse | null) {
|
||||||
|
const totals = (results?.results ?? [])
|
||||||
|
.map((row) => row.total)
|
||||||
|
.filter((value): value is number => typeof value === 'number');
|
||||||
|
if (!totals.length) return null;
|
||||||
|
return totals.reduce((sum, value) => sum + value, 0) / totals.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResultsWidget: React.FC<ResultsWidgetProps> = ({ classId, className }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||||
|
const [batches, setBatches] = useState<MarkingBatch[]>([]);
|
||||||
|
const [latestResults, setLatestResults] = useState<BatchResultsResponse | null>(null);
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [nextTemplates, nextBatches] = await Promise.all([
|
||||||
|
examRepository.listTemplates(),
|
||||||
|
examRepository.listBatches(),
|
||||||
|
]);
|
||||||
|
const classBatches = nextBatches
|
||||||
|
.filter((batch) => batch.class_id === classId)
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
setTemplates(nextTemplates);
|
||||||
|
setSelectedTemplateId((current) => current || nextTemplates[0]?.id || '');
|
||||||
|
setBatches(classBatches);
|
||||||
|
setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [classId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const latestBatch = batches[0] ?? null;
|
||||||
|
const average = useMemo(() => averageFromResults(latestResults), [latestResults]);
|
||||||
|
const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0;
|
||||||
|
|
||||||
|
const createBatch = async () => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const template = templates.find((item) => item.id === selectedTemplateId);
|
||||||
|
const created = await examRepository.createBatch({
|
||||||
|
template_id: selectedTemplateId,
|
||||||
|
class_id: classId,
|
||||||
|
title: `${className || 'Class'} · ${template?.title || 'Exam'}`,
|
||||||
|
});
|
||||||
|
navigate(`/exam-marker/${created.id}/mark`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<AssessmentIcon color="primary" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">Assessment results</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Last exam summary for this class</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{loading && <CircularProgress size={22} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="warning" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{latestBatch ? (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" fontWeight={700}>{latestBatch.title || 'Exam batch'}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
<Chip size="small" color="primary" label={`Average ${average === null ? '—' : average.toFixed(1)}`} />
|
||||||
|
<Chip size="small" variant="outlined" label={`${latestResults?.results.length ?? 0} students`} />
|
||||||
|
<Chip size="small" color="warning" variant="outlined" label={`${absent} absent`} />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" variant="contained" startIcon={<TableChartIcon />} onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
|
||||||
|
View results
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigate(`/exam-marker/${latestBatch.id}/mark`)}>
|
||||||
|
Continue marking
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : !loading ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No exam batches have been created for this class yet.
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label="Template"
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||||
|
disabled={!templates.length || creating}
|
||||||
|
sx={{ minWidth: 260 }}
|
||||||
|
>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<MenuItem key={template.id} value={template.id}>{template.title}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
onClick={createBatch}
|
||||||
|
disabled={!selectedTemplateId || creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating…' : 'Create marking batch'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResultsWidget;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||||
|
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||||
|
export { default as ExamMarkingPage } from './ExamMarkingPage';
|
||||||
|
export { default as ExamResultsPage } from './ExamResultsPage';
|
||||||
|
export { default as ResultsWidget } from './ResultsWidget';
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
|
||||||
|
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
|
||||||
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||||
|
import { logger } from '../../../debugConfig'
|
||||||
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
|
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
|
||||||
|
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
|
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||||
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const },
|
||||||
|
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const },
|
||||||
|
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const },
|
||||||
|
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const },
|
||||||
|
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const },
|
||||||
|
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
|
||||||
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PAGE_START_X = 0
|
||||||
|
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||||
|
|
||||||
|
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||||
|
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
|
||||||
|
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
|
||||||
|
let y = 0
|
||||||
|
return pages.map((page) => {
|
||||||
|
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||||
|
y += page.height
|
||||||
|
return geometry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
|
||||||
|
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
|
||||||
|
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
|
||||||
|
editor.setCameraOptions({
|
||||||
|
constraints: {
|
||||||
|
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
|
||||||
|
padding: { x: 64, y: 64 },
|
||||||
|
origin: { x: 0.5, y: 0 },
|
||||||
|
initialZoom: 'fit-x-100',
|
||||||
|
baseZoom: 'default',
|
||||||
|
behavior: 'contain',
|
||||||
|
},
|
||||||
|
isLocked: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||||
|
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
|
||||||
|
return { conflict: false, message: detail ?? err.message }
|
||||||
|
}
|
||||||
|
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function reviewSummary(template: ExamTemplateDetail | null) {
|
||||||
|
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
|
||||||
|
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
|
||||||
|
return rows.reduce((acc, row) => {
|
||||||
|
if (row.source === 'ai') acc.ai += 1
|
||||||
|
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
|
||||||
|
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
|
||||||
|
return acc
|
||||||
|
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripShapePrefix(id: string) {
|
||||||
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainIdForShape(shape: ExamCanvasTLShape): string {
|
||||||
|
const fromProps = shape.props.domainId
|
||||||
|
if (isUuid(fromProps)) return fromProps
|
||||||
|
const fromShapeId = stripShapePrefix(shape.id)
|
||||||
|
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDomainIds(editor: Editor) {
|
||||||
|
const updates = editor.getCurrentPageShapes()
|
||||||
|
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
|
||||||
|
.filter((shape) => !isUuid(shape.props.domainId))
|
||||||
|
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
|
||||||
|
if (updates.length) editor.updateShapes(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||||
|
const kind = shapeTypeToKind(shape.type)
|
||||||
|
if (!kind) return null
|
||||||
|
const s = shape as ExamCanvasTLShape
|
||||||
|
return {
|
||||||
|
id: domainIdForShape(s),
|
||||||
|
kind,
|
||||||
|
x: Number(s.x ?? 0),
|
||||||
|
y: Number(s.y ?? 0),
|
||||||
|
w: Number(s.props.w ?? 1),
|
||||||
|
h: Number(s.props.h ?? 1),
|
||||||
|
label: s.props.label,
|
||||||
|
maxMarks: s.props.maxMarks,
|
||||||
|
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||||
|
contextType: s.props.contextType,
|
||||||
|
questionId: s.props.questionId ?? null,
|
||||||
|
source: s.props.source ?? 'manual',
|
||||||
|
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
|
||||||
|
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
|
||||||
|
derivation: s.props.derivation ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bringDomainShapesToFront(editor: Editor) {
|
||||||
|
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
|
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!models.length) return
|
||||||
|
editor.createShapes(models.map((m) => ({
|
||||||
|
id: createShapeId(m.id),
|
||||||
|
type: SHAPE_TYPES[m.kind],
|
||||||
|
x: m.x,
|
||||||
|
y: m.y,
|
||||||
|
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!pages.length) return
|
||||||
|
const geometries = pageGeometryFromImages(pages)
|
||||||
|
editor.createShapes(geometries.map((geometry) => {
|
||||||
|
const page = pages[geometry.pageNumber - 1]
|
||||||
|
return {
|
||||||
|
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
|
||||||
|
type: PDF_PAGE_SHAPE_TYPE,
|
||||||
|
x: geometry.x,
|
||||||
|
y: geometry.y,
|
||||||
|
isLocked: true,
|
||||||
|
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||||
|
} as any
|
||||||
|
}))
|
||||||
|
// z-order is enforced by the caller via bringDomainShapesToFront
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedGuide(editor: Editor) {
|
||||||
|
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||||
|
if (current.length) return
|
||||||
|
editor.createShapes([
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
|
||||||
|
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
|
||||||
|
if (!status) return 'Auto-map running'
|
||||||
|
if (status.status === 'queued') return 'Auto-map queued'
|
||||||
|
if (status.status === 'running') return 'Auto-map running'
|
||||||
|
if (status.status === 'completed') return 'Auto-map complete'
|
||||||
|
if (status.status === 'failed') return 'Auto-map failed'
|
||||||
|
return `Auto-map ${status.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupInner: React.FC = () => {
|
||||||
|
const { templateId } = useParams<{ templateId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const theme = useTheme()
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [activeTool, setActiveTool] = useState('select')
|
||||||
|
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
||||||
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
||||||
|
const autoMapPollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
|
||||||
|
setTemplate(detail)
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
|
||||||
|
loadShapes(editor, shapes)
|
||||||
|
if (!shapes.length) seedGuide(editor)
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const review = useMemo(() => reviewSummary(template), [template])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return
|
||||||
|
setLoading(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId)
|
||||||
|
setTemplate(detail)
|
||||||
|
let pages: PdfPageImage[] = []
|
||||||
|
setPdfStatus('loading')
|
||||||
|
setPdfError(null)
|
||||||
|
try {
|
||||||
|
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||||
|
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
|
||||||
|
const newPage = partialPages[partialPages.length - 1]
|
||||||
|
const allGeometries = pageGeometryFromImages(partialPages)
|
||||||
|
pageGeometriesRef.current = allGeometries
|
||||||
|
const ed = editorRef.current
|
||||||
|
if (ed) {
|
||||||
|
const geometry = allGeometries[partialPages.length - 1]
|
||||||
|
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
||||||
|
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
|
||||||
|
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
||||||
|
bringDomainShapesToFront(ed)
|
||||||
|
}
|
||||||
|
applyDocViewConstraints(ed, partialPages)
|
||||||
|
}
|
||||||
|
setPdfStatus('ready')
|
||||||
|
})
|
||||||
|
setPdfStatus(pages.length ? 'ready' : 'missing')
|
||||||
|
} catch (pdfErr) {
|
||||||
|
const pdfMsg = apiMessage(pdfErr).message
|
||||||
|
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
|
||||||
|
setPdfError(pdfMsg)
|
||||||
|
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
|
||||||
|
}
|
||||||
|
const geometries = pageGeometryFromImages(pages)
|
||||||
|
pageGeometriesRef.current = geometries
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
syncPdfPages(editor, pages)
|
||||||
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
applyDocViewConstraints(editor, pages)
|
||||||
|
editor.resetZoom()
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e).message
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [templateId])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pollAutoMapStatus = useCallback(async (jobId: string) => {
|
||||||
|
if (!templateId) return
|
||||||
|
try {
|
||||||
|
const status = await examRepository.getAutoMapStatus(templateId, jobId)
|
||||||
|
setAutoMapStatus(status)
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
||||||
|
autoMapPollRef.current = null
|
||||||
|
setAutoMapBusy(false)
|
||||||
|
const detail = status.template ?? await examRepository.getTemplate(templateId)
|
||||||
|
applyTemplateToCanvas(detail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status.status === 'failed') {
|
||||||
|
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
|
||||||
|
autoMapPollRef.current = null
|
||||||
|
setAutoMapBusy(false)
|
||||||
|
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e).message
|
||||||
|
setAutoMapBusy(false)
|
||||||
|
setError(msg)
|
||||||
|
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
|
||||||
|
}
|
||||||
|
}, [applyTemplateToCanvas, templateId])
|
||||||
|
|
||||||
|
const autoMapFromPdf = useCallback(async () => {
|
||||||
|
if (!templateId || autoMapBusy) return
|
||||||
|
let queued = false
|
||||||
|
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const result = await examRepository.autoMapTemplate(templateId)
|
||||||
|
if (isAutoMapAccepted(result)) {
|
||||||
|
queued = true
|
||||||
|
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
|
||||||
|
await pollAutoMapStatus(result.job_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAutoMapStatus(null)
|
||||||
|
applyTemplateToCanvas(result)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e)
|
||||||
|
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||||
|
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
|
||||||
|
} finally {
|
||||||
|
if (!queued) setAutoMapBusy(false)
|
||||||
|
}
|
||||||
|
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor || !templateId || !template) return
|
||||||
|
setSaving(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
ensureDomainIds(editor)
|
||||||
|
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||||
|
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
|
||||||
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
|
setTemplate(saved)
|
||||||
|
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||||
|
setDirty(false)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e)
|
||||||
|
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [template, templateId])
|
||||||
|
|
||||||
|
const layoutSummary = useMemo(() => {
|
||||||
|
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
|
||||||
|
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
|
||||||
|
}, [template?.layout])
|
||||||
|
|
||||||
|
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||||
|
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||||
|
color={tool.color}
|
||||||
|
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
||||||
|
onClick={() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
||||||
|
setActiveTool(tool.id)
|
||||||
|
}}
|
||||||
|
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
||||||
|
>
|
||||||
|
{tool.label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)), [activeTool])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
|
{/* Top bar — single compact line */}
|
||||||
|
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
||||||
|
<Tooltip title="Back to exam marker">
|
||||||
|
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
||||||
|
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
|
||||||
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
|
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
||||||
|
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
|
||||||
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Body row */}
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Left tool sidebar */}
|
||||||
|
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||||
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
|
tools={examCanvasTools as any}
|
||||||
|
hideUi
|
||||||
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
|
autoFocus
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
|
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||||
|
applyDocViewConstraints(editor, [])
|
||||||
|
editor.resetZoom()
|
||||||
|
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
|
||||||
|
// (Previously `else seedGuide` fired on mount while `template` was still null during
|
||||||
|
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
|
||||||
|
if (template) {
|
||||||
|
const s = shapesFromTemplate(template, pageGeometriesRef.current)
|
||||||
|
loadShapes(editor, s)
|
||||||
|
if (!s.length) seedGuide(editor)
|
||||||
|
}
|
||||||
|
bringDomainShapesToFront(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Guide toggle */}
|
||||||
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
||||||
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
||||||
|
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Guide panel — collapsible */}
|
||||||
|
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
||||||
|
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
|
||||||
|
</Typography>
|
||||||
|
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
|
||||||
|
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
|
||||||
|
</Alert>
|
||||||
|
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
||||||
|
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
||||||
|
const p = canvasShapePalette[kind]
|
||||||
|
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
||||||
|
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||||
|
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||||
|
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{/* Conflict alert */}
|
||||||
|
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
||||||
|
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupPage: React.FC = () => (
|
||||||
|
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
||||||
|
<ExamTemplateSetupInner />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default ExamTemplateSetupPage
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
|
||||||
|
import type { TLHandle } from '@tldraw/tldraw'
|
||||||
|
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
|
||||||
|
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
import type { ExamTemplateSource } from '../../../types/exam.types'
|
||||||
|
|
||||||
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
|
|
||||||
|
export const SHAPE_TYPES = {
|
||||||
|
boundary: 'exam-boundary',
|
||||||
|
part: 'exam-part',
|
||||||
|
response: 'exam-region-response',
|
||||||
|
context: 'exam-region-context',
|
||||||
|
question_number: 'exam-region-question-number',
|
||||||
|
mark_area: 'exam-region-mark-area',
|
||||||
|
reference: 'exam-region-reference',
|
||||||
|
furniture: 'exam-region-furniture',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ExamPdfPageTLShape = TLBaseBoxShape & {
|
||||||
|
type: typeof PDF_PAGE_SHAPE_TYPE
|
||||||
|
props: { w: number; h: number; src: string; pageNumber: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||||
|
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
||||||
|
props: {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
label: string
|
||||||
|
kind: ExamCanvasShapeKind
|
||||||
|
maxMarks?: number
|
||||||
|
responseForm?: string
|
||||||
|
contextType?: string
|
||||||
|
questionId?: string | null
|
||||||
|
domainId?: string
|
||||||
|
source?: 'manual' | 'ai'
|
||||||
|
confirmed?: boolean
|
||||||
|
confidence?: number | null
|
||||||
|
derivation?: string | null
|
||||||
|
reviewFlags?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CanvasPaletteEntry = {
|
||||||
|
stroke: string
|
||||||
|
fill: string
|
||||||
|
darkStroke: string
|
||||||
|
darkFill: string
|
||||||
|
dash?: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
|
||||||
|
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
|
||||||
|
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
|
||||||
|
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
|
||||||
|
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
|
||||||
|
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
|
||||||
|
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
|
||||||
|
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
|
||||||
|
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCss = `
|
||||||
|
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
|
||||||
|
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
||||||
|
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
|
||||||
|
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
|
||||||
|
`
|
||||||
|
|
||||||
|
|
||||||
|
function confidenceLabel(confidence: number | null | undefined) {
|
||||||
|
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewFlags(shape: ExamCanvasTLShape): string[] {
|
||||||
|
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
|
||||||
|
const bits = [base]
|
||||||
|
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
|
||||||
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
|
if (confidence) bits.push(`confidence ${confidence}`)
|
||||||
|
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
|
||||||
|
const flags = reviewFlags(shape)
|
||||||
|
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
|
||||||
|
return bits.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
||||||
|
const p = canvasShapePalette.boundary
|
||||||
|
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
||||||
|
const isAi = shape.props.source === 'ai'
|
||||||
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
|
const flags = reviewFlags(shape)
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||||||
|
<style>{shapeCss}</style>
|
||||||
|
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
|
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||||||
|
</svg>
|
||||||
|
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||||||
|
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
|
const kind = shape.props.kind
|
||||||
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
|
const isBoundary = kind === 'boundary'
|
||||||
|
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
|
||||||
|
if (isBoundary) return renderBoundaryLine(shape)
|
||||||
|
const isAi = shape.props.source === 'ai'
|
||||||
|
const confidence = confidenceLabel(shape.props.confidence)
|
||||||
|
const flags = reviewFlags(shape)
|
||||||
|
const title = provenanceTitle(shape, `${p.label}: ${p.role}`)
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
|
<style>{shapeCss}</style>
|
||||||
|
<div
|
||||||
|
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||||
|
style={{
|
||||||
|
'--exam-light-stroke': p.stroke,
|
||||||
|
'--exam-light-fill': p.fill,
|
||||||
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
|
'--exam-dark-fill': p.darkFill,
|
||||||
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
|
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||||
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
aria-label={title}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||||||
|
{!confidence && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||||||
|
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
|
||||||
|
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||||
|
</div>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
|
const p = canvasShapePalette[kind]
|
||||||
|
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) }
|
||||||
|
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||||
|
|
||||||
|
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||||
|
static override type = PDF_PAGE_SHAPE_TYPE
|
||||||
|
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||||
|
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override component(shape: ExamPdfPageTLShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||||
|
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
|
}
|
||||||
|
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
||||||
|
static override type = SHAPE_TYPES.boundary
|
||||||
|
static override props = sharedProps
|
||||||
|
|
||||||
|
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override canResize() { return false }
|
||||||
|
override canBind() { return false }
|
||||||
|
override hideResizeHandles() { return true }
|
||||||
|
override hideRotateHandle() { return true }
|
||||||
|
override hideSelectionBoundsBg() { return true }
|
||||||
|
|
||||||
|
private pageSpanForY(y: number) {
|
||||||
|
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
|
||||||
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
|
||||||
|
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
|
||||||
|
if (!best) return page
|
||||||
|
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
|
||||||
|
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
|
||||||
|
return pageDy < bestDy ? page : best
|
||||||
|
}, null)
|
||||||
|
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
|
||||||
|
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
|
||||||
|
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override getGeometry(shape: ExamCanvasTLShape) {
|
||||||
|
const y = shape.props.h / 2
|
||||||
|
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
|
||||||
|
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
|
||||||
|
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
|
||||||
|
return this.normalize({ ...current, x: initial.x })
|
||||||
|
}
|
||||||
|
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
|
||||||
|
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
|
||||||
|
}
|
||||||
|
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
|
||||||
|
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
||||||
|
}
|
||||||
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
||||||
|
|
||||||
|
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
||||||
|
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||||
|
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
|
||||||
|
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
|
||||||
|
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
|
||||||
|
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
|
||||||
|
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
|
||||||
|
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
|
||||||
|
|
||||||
|
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
|
||||||
|
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||||
|
|
||||||
|
export function isPdfPageShape(type: string): boolean {
|
||||||
|
return type === PDF_PAGE_SHAPE_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||||
|
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||||
|
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as pdfjsLib from "pdfjs-dist"
|
||||||
|
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
|
||||||
|
|
||||||
|
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
|
||||||
|
|
||||||
|
export interface PdfPageImage {
|
||||||
|
pageNumber: number
|
||||||
|
src: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPdfPageImages(
|
||||||
|
pdfBytes: ArrayBuffer,
|
||||||
|
targetWidth = PAGE_WIDTH,
|
||||||
|
onPageReady?: (pages: PdfPageImage[]) => void,
|
||||||
|
): Promise<PdfPageImage[]> {
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
|
||||||
|
const pages: PdfPageImage[] = []
|
||||||
|
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
|
||||||
|
// for a typical 36-page exam paper.
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdf.getPage(pageNumber)
|
||||||
|
const baseViewport = page.getViewport({ scale: 1 })
|
||||||
|
const scale = targetWidth / baseViewport.width
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
canvas.width = Math.ceil(viewport.width)
|
||||||
|
canvas.height = Math.ceil(viewport.height)
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("Unable to create PDF render canvas")
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise
|
||||||
|
pages.push({
|
||||||
|
pageNumber,
|
||||||
|
src: canvas.toDataURL("image/png"),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
})
|
||||||
|
onPageReady?.([...pages])
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
|
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { ResultsWidget } from '../exam';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
@@ -131,10 +132,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
if (clsRes.id) setCls(clsRes);
|
if (clsRes.id) {
|
||||||
|
setCls({
|
||||||
|
...clsRes,
|
||||||
|
class_code: clsRes.class_code || clsRes.code,
|
||||||
|
year_group: clsRes.year_group || clsRes.school_year,
|
||||||
|
teachers: clsRes.teachers || [],
|
||||||
|
students: clsRes.students || [],
|
||||||
|
enrollment_requests: clsRes.enrollment_requests || [],
|
||||||
|
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
else setError(clsRes.detail || 'Class not found');
|
else setError(clsRes.detail || 'Class not found');
|
||||||
const role = bootstrapData?.active_institute?.membership_role || '';
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||||
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||||
@@ -174,20 +185,20 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleAddStudent = async (studentId: string) => {
|
const handleAddStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
|
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Failed to add student');
|
else setActionError(res.detail || 'Failed to add student');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveStudent = async (studentId: string) => {
|
const handleRemoveStudent = async (studentId: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
await apiDelete(`/classes/${classId}/students/${studentId}`);
|
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
|
||||||
if (res.status === 'ok') load();
|
if (res.status === 'ok') load();
|
||||||
else setActionError(res.detail || 'Action failed');
|
else setActionError(res.detail || 'Action failed');
|
||||||
};
|
};
|
||||||
@@ -254,6 +265,8 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ResultsWidget classId={cls.id} className={cls.name} />
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
<Tab label={`Students (${cls.student_count})`} />
|
<Tab label={`Students (${cls.student_count})`} />
|
||||||
|
|||||||
@@ -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,113 +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 url = URL.createObjectURL(
|
|
||||||
new Blob([await pdf.save()], { 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[];
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
TLStore,
|
TLStore,
|
||||||
TLStoreWithStatus
|
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';
|
||||||
@@ -15,7 +15,6 @@ import { useUser } from '../../contexts/UserContext';
|
|||||||
// Tldraw services
|
// Tldraw services
|
||||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
|
||||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||||
// Tldraw utils
|
// Tldraw utils
|
||||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||||
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
|
|||||||
// App debug
|
// App debug
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
||||||
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
|
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||||
import { NodeData } from '../../types/graph-shape';
|
|
||||||
import { NavigationNode } from '../../types/navigation';
|
|
||||||
|
|
||||||
interface LoadingState {
|
interface LoadingState {
|
||||||
status: 'ready' | 'loading' | 'error';
|
status: 'ready' | 'loading' | 'error';
|
||||||
@@ -67,9 +64,8 @@ export default function SinglePlayerPage() {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: ''
|
error: ''
|
||||||
});
|
});
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const storeRef = useRef<TLStore | null>(null);
|
||||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
const [storeReady, setStoreReady] = useState(false);
|
||||||
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
|
||||||
|
|
||||||
// TLDraw user preferences
|
// TLDraw user preferences
|
||||||
const tldrawUser = useTldrawUser({
|
const tldrawUser = useTldrawUser({
|
||||||
@@ -85,248 +81,97 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize store — runs as soon as user is ready, no editor needed for store creation
|
const [canvasPhase, setCanvasPhase] = useState<
|
||||||
|
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||||
|
>('idle');
|
||||||
|
|
||||||
|
// 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) {
|
if (!user) return;
|
||||||
logger.debug('single-player-page', '⏳ Waiting for user data');
|
|
||||||
|
setCanvasPhase('store-init');
|
||||||
|
const store = createTLStore({
|
||||||
|
schema: customSchema,
|
||||||
|
shapeUtils: allShapeUtils,
|
||||||
|
bindingUtils: allBindingUtils,
|
||||||
|
});
|
||||||
|
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||||
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
|
||||||
|
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||||
|
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||||
|
snapshotServiceRef.current = snapSvc;
|
||||||
|
|
||||||
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const unsubscribe = store.listen(() => {
|
||||||
|
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||||
|
if (debounce) clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(async () => {
|
||||||
|
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
storeRef.current = store;
|
||||||
|
setStoreReady(true);
|
||||||
|
setCanvasPhase('ready');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
|
snapshotServiceRef.current = null;
|
||||||
|
setStoreReady(false);
|
||||||
|
setCanvasPhase('idle');
|
||||||
|
store.dispose();
|
||||||
|
storeRef.current = null;
|
||||||
|
};
|
||||||
|
}, [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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('single-player-page', '🔄 Starting store initialization', {
|
setCanvasPhase('snapshot-loading');
|
||||||
isEditorReady,
|
|
||||||
hasUser: !!user,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
const initializeStoreAndSnapshot = async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
|
||||||
|
|
||||||
// 1. Create store
|
|
||||||
logger.debug('single-player-page', '🔄 Creating TLStore');
|
|
||||||
const newStore = localStoreService.getStore({
|
|
||||||
schema: customSchema,
|
|
||||||
shapeUtils: allShapeUtils,
|
|
||||||
bindingUtils: allBindingUtils
|
|
||||||
});
|
|
||||||
logger.debug('single-player-page', '✅ TLStore created');
|
|
||||||
|
|
||||||
// 2. Initialize snapshot service
|
|
||||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
|
||||||
if (accessToken) snapshotService.setAccessToken(accessToken);
|
|
||||||
snapshotServiceRef.current = snapshotService;
|
|
||||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
|
||||||
|
|
||||||
// 3. Load initial snapshot if we have a node
|
|
||||||
if (context.node) {
|
|
||||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
|
||||||
if (nodeStoragePath) {
|
|
||||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
|
||||||
dbName: null,
|
|
||||||
node: context.node,
|
|
||||||
node_storage_path: nodeStoragePath,
|
|
||||||
user_type: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
nodeStoragePath,
|
context.node.node_storage_path,
|
||||||
accessToken || '',
|
accessToken || '',
|
||||||
newStore,
|
store,
|
||||||
setLoadingState,
|
setLoadingState,
|
||||||
undefined,
|
undefined,
|
||||||
editorRef.current || undefined
|
editorRef.current || undefined
|
||||||
);
|
);
|
||||||
// Wire auto-save: set the current path on the service instance
|
if (!cancelled) {
|
||||||
snapshotService.setCurrentNodePath(nodeStoragePath);
|
// Repair store in case snapshot overwrote TLINSTANCE
|
||||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
} else {
|
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
|
||||||
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
|
setCanvasPhase('ready');
|
||||||
node: context.node
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) {
|
||||||
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
|
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Set up auto-save with debouncing (only after initial load is complete)
|
|
||||||
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let isAutoSaving = false;
|
|
||||||
|
|
||||||
newStore.listen(() => {
|
|
||||||
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
|
|
||||||
// Skip if already saving
|
|
||||||
if (isAutoSaving) {
|
|
||||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing timeout
|
|
||||||
if (autoSaveTimeout) {
|
|
||||||
clearTimeout(autoSaveTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce auto-save to prevent excessive saves
|
|
||||||
autoSaveTimeout = setTimeout(async () => {
|
|
||||||
if (isAutoSaving) return; // Double-check
|
|
||||||
|
|
||||||
isAutoSaving = true;
|
|
||||||
try {
|
|
||||||
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
|
|
||||||
await snapshotServiceRef.current?.forceSaveCurrentNode();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Auto-save failed', error);
|
|
||||||
} finally {
|
|
||||||
isAutoSaving = false;
|
|
||||||
}
|
|
||||||
}, 2000); // Increased to 2 seconds debounce
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Update store state
|
|
||||||
setStore(newStore);
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
|
||||||
logger.info('single-player-page', '✅ Store initialization complete');
|
|
||||||
|
|
||||||
// 6. Handle cleanup
|
|
||||||
return () => {
|
|
||||||
logger.debug('single-player-page', '🧹 Starting cleanup');
|
|
||||||
if (snapshotServiceRef.current) {
|
|
||||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
|
||||||
logger.error('single-player-page', '❌ Final save failed', error);
|
|
||||||
});
|
|
||||||
snapshotServiceRef.current.clearCurrentNode();
|
|
||||||
snapshotServiceRef.current = null;
|
|
||||||
}
|
|
||||||
newStore.dispose();
|
|
||||||
setStore(undefined);
|
|
||||||
logger.debug('single-player-page', '🧹 Cleanup complete');
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
|
|
||||||
logger.error('single-player-page', '❌ Store initialization failed', error);
|
|
||||||
setLoadingState({ status: 'error', error: errorMessage });
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeStoreAndSnapshot();
|
loadNodeSnapshot();
|
||||||
}, [user, context.node]);
|
return () => { cancelled = true; };
|
||||||
|
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
|
||||||
|
|
||||||
// Handle initial node placement
|
// Handle navigation changes — save previous snapshot, load next one.
|
||||||
useEffect(() => {
|
// No automatic node shape placement: the canvas shows only persisted state.
|
||||||
const placeInitialNode = async () => {
|
|
||||||
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
|
|
||||||
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
|
|
||||||
hasNode: !!context.node,
|
|
||||||
hasEditor: !!editorRef.current,
|
|
||||||
hasStore: !!store,
|
|
||||||
isInitialLoad
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log the actual node structure
|
|
||||||
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
|
|
||||||
node: context.node,
|
|
||||||
nodeKeys: Object.keys(context.node),
|
|
||||||
hasId: !!context.node.id,
|
|
||||||
hasStoragePath: !!context.node.node_storage_path,
|
|
||||||
hasData: !!context.node.data,
|
|
||||||
dataKeys: context.node.data ? Object.keys(context.node.data) : null
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate that the node has required properties
|
|
||||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
|
||||||
if (!context.node.id || !nodeStoragePath) {
|
|
||||||
logger.error('single-player-page', '❌ Node missing required properties', {
|
|
||||||
nodeId: context.node.id,
|
|
||||||
hasStoragePath: !!nodeStoragePath,
|
|
||||||
node: context.node
|
|
||||||
});
|
|
||||||
setLoadingState({
|
|
||||||
status: 'error',
|
|
||||||
error: 'Node is missing required information'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
|
||||||
|
|
||||||
if (context.node.type !== 'workspace') {
|
|
||||||
try {
|
|
||||||
const nodeData = await loadNodeData(context.node);
|
|
||||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
|
||||||
} catch (shapeErr) {
|
|
||||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsInitialLoad(false);
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Failed to place initial node', error);
|
|
||||||
setLoadingState({
|
|
||||||
status: 'error',
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to place initial node'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
placeInitialNode();
|
|
||||||
}, [context.node, store, isInitialLoad]);
|
|
||||||
|
|
||||||
// Handle navigation changes
|
|
||||||
useEffect(() => {
|
|
||||||
const handleNodeChange = async () => {
|
|
||||||
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can safely assert these types because we've checked for null above
|
|
||||||
const editor = editorRef.current as Editor;
|
|
||||||
const snapshotService = snapshotServiceRef.current;
|
|
||||||
const currentNode = context.node;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
|
||||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
|
||||||
nodeId: currentNode.id,
|
|
||||||
node_storage_path: currentNode.node_storage_path,
|
|
||||||
isInitialLoad
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the previous node from navigation history
|
|
||||||
const previousNode = context.history.currentIndex > 0
|
|
||||||
? context.history.nodes[context.history.currentIndex - 1]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Handle navigation in snapshot service (load/save snapshot)
|
|
||||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
|
||||||
|
|
||||||
if (currentNode.type !== 'workspace') {
|
|
||||||
try {
|
|
||||||
const nodeData = await loadNodeData(currentNode);
|
|
||||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
|
||||||
} catch (shapeErr) {
|
|
||||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('single-player-page', '❌ Failed to load node data', error);
|
|
||||||
setLoadingState({
|
|
||||||
status: 'error',
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to load node data'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleNodeChange();
|
|
||||||
}, [context.node, context.history, store]);
|
|
||||||
|
|
||||||
// Inject auth and trigger initial context when token is ready
|
// Inject auth and trigger initial context when token is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -485,7 +330,7 @@ export default function SinglePlayerPage() {
|
|||||||
top: `${HEADER_HEIGHT}px`,
|
top: `${HEADER_HEIGHT}px`,
|
||||||
}}>
|
}}>
|
||||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||||
{(loadingState.status === 'loading' || !store) && (
|
{(loadingState.status === 'loading' || !storeReady) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -513,9 +358,15 @@ export default function SinglePlayerPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|
||||||
{store && <Tldraw
|
<ErrorBoundary fallback={
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
|
||||||
|
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
|
||||||
|
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{storeReady && storeRef.current && <Tldraw
|
||||||
user={tldrawUser}
|
user={tldrawUser}
|
||||||
store={store}
|
store={storeRef.current}
|
||||||
tools={singlePlayerTools}
|
tools={singlePlayerTools}
|
||||||
shapeUtils={allShapeUtils}
|
shapeUtils={allShapeUtils}
|
||||||
bindingUtils={allBindingUtils}
|
bindingUtils={allBindingUtils}
|
||||||
@@ -548,57 +399,17 @@ export default function SinglePlayerPage() {
|
|||||||
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditorReady(true);
|
|
||||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||||
editorId: editor.store.id,
|
editorId: editor.store.id,
|
||||||
presentationMode,
|
presentationMode,
|
||||||
isEditorReady: true
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to safely extract node_storage_path from different node structures
|
|
||||||
const getNodeStoragePath = (node: NavigationNode): string | null => {
|
|
||||||
// Try direct access first
|
|
||||||
if (node.node_storage_path) {
|
|
||||||
return node.node_storage_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try nested under data
|
|
||||||
if (node.data?.node_storage_path) {
|
|
||||||
return node.data.node_storage_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try other possible locations
|
|
||||||
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
|
|
||||||
return node.data.storage_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
|
||||||
if (!node?.id) throw new Error('Node parameter is required');
|
|
||||||
const nodeStoragePath = getNodeStoragePath(node);
|
|
||||||
if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
|
|
||||||
|
|
||||||
const theme = getThemeFromLabel(node.type);
|
|
||||||
return {
|
|
||||||
title: node.label || node.type || '',
|
|
||||||
w: 500,
|
|
||||||
h: 350,
|
|
||||||
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
|
|
||||||
headerColor: theme.headerColor,
|
|
||||||
backgroundColor: theme.backgroundColor,
|
|
||||||
isLocked: false,
|
|
||||||
__primarylabel__: node.type,
|
|
||||||
uuid_string: node.id,
|
|
||||||
node_storage_path: nodeStoragePath,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,319 @@
|
|||||||
|
/**
|
||||||
|
* 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 { logger } from '../../debugConfig';
|
||||||
|
import { supabase } from '../../supabaseClient';
|
||||||
|
import type {
|
||||||
|
AutoMapJobStatus,
|
||||||
|
AutoMapResponse,
|
||||||
|
BatchQueueResponse,
|
||||||
|
BatchResultsResponse,
|
||||||
|
CreateBatchPayload,
|
||||||
|
CreateTemplatePayload,
|
||||||
|
ExamBoundary,
|
||||||
|
ExamQuestion,
|
||||||
|
ExamResponseArea,
|
||||||
|
ExamTemplate,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
ExamTemplateLayout,
|
||||||
|
MarkingBatch,
|
||||||
|
MarkUpsertPayload,
|
||||||
|
Neo4jSyncResult,
|
||||||
|
PatchQuestionPayload,
|
||||||
|
SpecPoint,
|
||||||
|
StudentSubmission,
|
||||||
|
TemplateReplacePayload,
|
||||||
|
UpdateTemplateMetaPayload,
|
||||||
|
} 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}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUuid(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = Math.floor(Math.random() * 16);
|
||||||
|
const v = c === 'x' ? r : (r % 4) + 8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
||||||
|
return {
|
||||||
|
id: idMap?.get(q.id) ?? q.id,
|
||||||
|
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
|
||||||
|
label: q.label,
|
||||||
|
order: q.order,
|
||||||
|
max_marks: q.max_marks,
|
||||||
|
answer_type: q.answer_type,
|
||||||
|
mcq_options: q.mcq_options,
|
||||||
|
mark_scheme: q.mark_scheme ?? {},
|
||||||
|
is_container: q.is_container,
|
||||||
|
spec_ref: q.spec_ref,
|
||||||
|
bounds: q.bounds ?? null,
|
||||||
|
page: q.page ?? null,
|
||||||
|
source: q.source ?? 'manual',
|
||||||
|
confirmed: q.confirmed ?? true,
|
||||||
|
confidence: q.confidence ?? null,
|
||||||
|
derivation: q.derivation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : r.id,
|
||||||
|
question_id: idMap?.get(r.question_id) ?? r.question_id,
|
||||||
|
page: r.page,
|
||||||
|
bounds: r.bounds,
|
||||||
|
kind: r.kind,
|
||||||
|
response_form: r.response_form,
|
||||||
|
context_type: r.context_type ?? null,
|
||||||
|
source: r.source,
|
||||||
|
confirmed: r.confirmed,
|
||||||
|
confidence: r.confidence,
|
||||||
|
mark_subtype: r.mark_subtype ?? null,
|
||||||
|
derivation: r.derivation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : b.id,
|
||||||
|
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
|
||||||
|
label: b.label,
|
||||||
|
page_index: b.page_index,
|
||||||
|
y: b.y,
|
||||||
|
bounds: b.bounds,
|
||||||
|
source: b.source,
|
||||||
|
confirmed: b.confirmed,
|
||||||
|
confidence: b.confidence ?? null,
|
||||||
|
derivation: b.derivation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
|
||||||
|
return {
|
||||||
|
id: duplicate ? newUuid() : layout.id,
|
||||||
|
page_index: layout.page_index,
|
||||||
|
role: layout.role ?? null,
|
||||||
|
margin_left: layout.margin_left ?? null,
|
||||||
|
margin_right: layout.margin_right ?? null,
|
||||||
|
margin_top: layout.margin_top ?? null,
|
||||||
|
margin_bottom: layout.margin_bottom ?? null,
|
||||||
|
margins_enabled: layout.margins_enabled ?? true,
|
||||||
|
source: layout.source ?? 'manual',
|
||||||
|
confirmed: layout.confirmed ?? true,
|
||||||
|
confidence: layout.confidence ?? null,
|
||||||
|
derivation: layout.derivation ?? null,
|
||||||
|
meta: layout.meta ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceTemplate(
|
||||||
|
templateId: string,
|
||||||
|
detail: ExamTemplateDetail,
|
||||||
|
meta?: UpdateTemplateMetaPayload,
|
||||||
|
duplicateIds = false,
|
||||||
|
): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
if (duplicateIds) {
|
||||||
|
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
|
||||||
|
}
|
||||||
|
const res = await axios.put<ExamTemplateDetail>(
|
||||||
|
`${EXAM_BASE}/templates/${templateId}`,
|
||||||
|
{
|
||||||
|
meta,
|
||||||
|
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||||
|
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||||
|
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||||
|
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
||||||
|
headers,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
|
||||||
|
const detail = await this.getTemplate(templateId);
|
||||||
|
const created = await this.createTemplate({
|
||||||
|
title,
|
||||||
|
subject: detail.subject ?? undefined,
|
||||||
|
exam_id: detail.exam_id ?? undefined,
|
||||||
|
exam_code: detail.exam_code ?? undefined,
|
||||||
|
source_file_id: detail.source_file_id ?? undefined,
|
||||||
|
page_count: detail.page_count ?? undefined,
|
||||||
|
institute_id: detail.institute_id,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await this.archiveTemplate(created.id);
|
||||||
|
} catch (archiveError) {
|
||||||
|
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
|
||||||
|
templateId: created.id,
|
||||||
|
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
async 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
include_archived: params.includeArchived ?? false,
|
||||||
|
template_id: params.templateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data.batches ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBatchCsv(batchId: string): Promise<string> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadScan(
|
||||||
|
batchId: string,
|
||||||
|
file: File,
|
||||||
|
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
|
||||||
|
): Promise<StudentSubmission> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('matching_method', opts.matchingMethod ?? 'ordered');
|
||||||
|
if (opts.studentId) form.append('student_id', opts.studentId);
|
||||||
|
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
|
||||||
|
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default examRepository;
|
||||||
@@ -19,12 +19,17 @@ export class SharedStoreService {
|
|||||||
private lastSaveTime: number = Date.now();
|
private lastSaveTime: number = Date.now();
|
||||||
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
||||||
private config: AutoSaveConfig;
|
private config: AutoSaveConfig;
|
||||||
|
private isDirty = false;
|
||||||
|
private dirtyListener: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
||||||
this.config = {
|
this.config = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
...config
|
...config
|
||||||
};
|
};
|
||||||
|
this.dirtyListener = store.listen(() => {
|
||||||
|
this.isDirty = true;
|
||||||
|
});
|
||||||
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,18 +57,16 @@ export class SharedStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
||||||
const now = Date.now();
|
if (!this.isDirty) {
|
||||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
return;
|
||||||
const currentSnapshot = getSnapshot(this.store);
|
|
||||||
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
|
|
||||||
|
|
||||||
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
|
|
||||||
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
|
|
||||||
await this.saveSnapshot(currentSnapshot, setLoadingState);
|
|
||||||
this.lastSaveTime = now;
|
|
||||||
} else {
|
|
||||||
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isDirty = false;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
||||||
|
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
|
||||||
|
this.lastSaveTime = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +112,10 @@ export class SharedStoreService {
|
|||||||
|
|
||||||
public clear(): void {
|
public clear(): void {
|
||||||
this.stopAutoSave();
|
this.stopAutoSave();
|
||||||
|
this.dirtyListener?.();
|
||||||
|
this.dirtyListener = null;
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
|
this.isDirty = false;
|
||||||
logger.debug('shared-store-service', '🧹 Store cleared');
|
logger.debug('shared-store-service', '🧹 Store cleared');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Parameters<typeof loadSnapshot>[1]);
|
// Post-load validation: ensure instance.currentPageId is still valid
|
||||||
} else {
|
const instance = targetStore.get(TLINSTANCE_ID);
|
||||||
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { expect } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
|
|
||||||
|
if (typeof globalThis.matchMedia !== 'function') {
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
media: '',
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
} as unknown as MediaQueryList))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { logger } from './debugConfig';
|
import { logger } from './debugConfig';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
|
||||||
|
// resolved against the current browser origin so supabase-js receives an
|
||||||
|
// absolute URL while every request stays same-origin (no CORS) and routes
|
||||||
|
// through the app host's /__supabase nginx proxy to the backend Supabase.
|
||||||
|
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
|
||||||
|
? `${window.location.origin}${rawSupabaseUrl}`
|
||||||
|
: rawSupabaseUrl;
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
throw new Error('Missing Supabase configuration');
|
throw new Error('Missing Supabase configuration');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* tailwind-compat.css
|
||||||
|
* Minimal CSS shim replacing the Tailwind build pipeline.
|
||||||
|
* Covers only the utility classes actually used in this codebase.
|
||||||
|
* Generated from grep audit of 8 affected files (B3 Tailwind removal sprint).
|
||||||
|
* Over time, replace these with --cc-* tokens or MUI sx props and remove entries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||||||
|
.flex { display: flex; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
.inline-flex { display: inline-flex; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.block { display: block; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-1 { flex: 1 1 0%; }
|
||||||
|
.flex-shrink-0 { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.items-start { align-items: flex-start; }
|
||||||
|
.items-end { align-items: flex-end; }
|
||||||
|
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-end { justify-content: flex-end; }
|
||||||
|
.justify-start { justify-content: flex-start; }
|
||||||
|
|
||||||
|
/* ── Position ───────────────────────────────────────────────────────────── */
|
||||||
|
.relative { position: relative; }
|
||||||
|
.absolute { position: absolute; }
|
||||||
|
.fixed { position: fixed; }
|
||||||
|
.sticky { position: sticky; }
|
||||||
|
|
||||||
|
/* ── Spacing — gap ──────────────────────────────────────────────────────── */
|
||||||
|
.gap-1 { gap: 0.25rem; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-3 { gap: 0.75rem; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
.gap-6 { gap: 1.5rem; }
|
||||||
|
|
||||||
|
/* ── Spacing — padding ──────────────────────────────────────────────────── */
|
||||||
|
.p-0 { padding: 0; }
|
||||||
|
.p-2 { padding: 0.5rem; }
|
||||||
|
.p-4 { padding: 1rem; }
|
||||||
|
.p-5 { padding: 1.25rem; }
|
||||||
|
.p-6 { padding: 1.5rem; }
|
||||||
|
.p-12 { padding: 3rem; }
|
||||||
|
|
||||||
|
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||||
|
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||||
|
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
|
.px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
|
||||||
|
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||||
|
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||||
|
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||||
|
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||||
|
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
|
||||||
|
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||||
|
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||||
|
.py-12{ padding-top: 3rem; padding-bottom: 3rem; }
|
||||||
|
.py-16{ padding-top: 4rem; padding-bottom: 4rem; }
|
||||||
|
|
||||||
|
.pt-4 { padding-top: 1rem; }
|
||||||
|
.pl-10{ padding-left: 2.5rem; }
|
||||||
|
.pr-4 { padding-right: 1rem; }
|
||||||
|
|
||||||
|
/* ── Spacing — margin ───────────────────────────────────────────────────── */
|
||||||
|
.mt-0 { margin-top: 0; }
|
||||||
|
.mt-1 { margin-top: 0.25rem; }
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-4 { margin-top: 1rem; }
|
||||||
|
.mt-8 { margin-top: 2rem; }
|
||||||
|
|
||||||
|
.mb-1 { margin-bottom: 0.25rem; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-3 { margin-bottom: 0.75rem; }
|
||||||
|
.mb-4 { margin-bottom: 1rem; }
|
||||||
|
.mb-6 { margin-bottom: 1.5rem; }
|
||||||
|
.mb-8 { margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
.ml-2 { margin-left: 0.5rem; }
|
||||||
|
.ml-4 { margin-left: 1rem; }
|
||||||
|
.mr-1 { margin-right: 0.25rem; }
|
||||||
|
.mr-2 { margin-right: 0.5rem; }
|
||||||
|
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
/* ── Space-between children ─────────────────────────────────────────────── */
|
||||||
|
.space-x-2 > * + * { margin-left: 0.5rem; }
|
||||||
|
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||||
|
.space-y-4 > * + * { margin-top: 1rem; }
|
||||||
|
.space-y-8 > * + * { margin-top: 2rem; }
|
||||||
|
|
||||||
|
/* ── Sizing ─────────────────────────────────────────────────────────────── */
|
||||||
|
.w-0 { width: 0; }
|
||||||
|
.w-4 { width: 1rem; }
|
||||||
|
.w-8 { width: 2rem; }
|
||||||
|
.w-10 { width: 2.5rem; }
|
||||||
|
.w-12 { width: 3rem; }
|
||||||
|
.w-16 { width: 4rem; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
|
||||||
|
.h-4 { height: 1rem; }
|
||||||
|
.h-8 { height: 2rem; }
|
||||||
|
.h-10 { height: 2.5rem; }
|
||||||
|
.h-12 { height: 3rem; }
|
||||||
|
.h-64 { height: 16rem; }
|
||||||
|
.h-full { height: 100%; }
|
||||||
|
|
||||||
|
.min-h-screen { min-height: 100vh; }
|
||||||
|
.max-w-md { max-width: 28rem; }
|
||||||
|
.max-w-4xl { max-width: 56rem; }
|
||||||
|
.max-w-6xl { max-width: 72rem; }
|
||||||
|
.max-w-7xl { max-width: 80rem; }
|
||||||
|
|
||||||
|
/* ── Typography ─────────────────────────────────────────────────────────── */
|
||||||
|
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||||
|
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||||
|
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||||
|
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||||
|
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||||
|
|
||||||
|
.font-medium { font-weight: 500; }
|
||||||
|
.font-semibold { font-weight: 600; }
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.uppercase { text-transform: uppercase; }
|
||||||
|
.capitalize { text-transform: capitalize; }
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Colours — text ─────────────────────────────────────────────────────── */
|
||||||
|
.text-white { color: #ffffff; }
|
||||||
|
.text-gray-300 { color: #d1d5db; }
|
||||||
|
.text-gray-400 { color: #9ca3af; }
|
||||||
|
.text-gray-500 { color: #6b7280; }
|
||||||
|
.text-gray-600 { color: #4b5563; }
|
||||||
|
.text-gray-700 { color: #374151; }
|
||||||
|
.text-gray-900 { color: #111827; }
|
||||||
|
.text-blue-600 { color: #2563eb; }
|
||||||
|
.text-blue-700 { color: #1d4ed8; }
|
||||||
|
.text-red-600 { color: #dc2626; }
|
||||||
|
.text-red-700 { color: #b91c1c; }
|
||||||
|
.text-red-800 { color: #991b1b; }
|
||||||
|
.text-green-600 { color: #16a34a; }
|
||||||
|
.text-green-700 { color: #15803d; }
|
||||||
|
.text-purple-600 { color: #9333ea; }
|
||||||
|
.text-purple-700 { color: #7e22ce; }
|
||||||
|
.text-yellow-700 { color: #a16207; }
|
||||||
|
|
||||||
|
/* ── Colours — background ───────────────────────────────────────────────── */
|
||||||
|
.bg-white { background-color: #ffffff; }
|
||||||
|
.bg-transparent { background-color: transparent; }
|
||||||
|
.bg-gray-50 { background-color: #f9fafb; }
|
||||||
|
.bg-gray-100 { background-color: #f3f4f6; }
|
||||||
|
.bg-gray-500 { background-color: #6b7280; }
|
||||||
|
.bg-blue-50 { background-color: #eff6ff; }
|
||||||
|
.bg-blue-100 { background-color: #dbeafe; }
|
||||||
|
.bg-blue-600 { background-color: #2563eb; }
|
||||||
|
.bg-red-50 { background-color: #fef2f2; }
|
||||||
|
.bg-red-100 { background-color: #fee2e2; }
|
||||||
|
.bg-red-600 { background-color: #dc2626; }
|
||||||
|
.bg-green-100 { background-color: #dcfce7; }
|
||||||
|
.bg-purple-100 { background-color: #f3e8ff; }
|
||||||
|
.bg-yellow-100 { background-color: #fef9c3; }
|
||||||
|
|
||||||
|
/* ── Borders ────────────────────────────────────────────────────────────── */
|
||||||
|
.border { border-width: 1px; border-style: solid; }
|
||||||
|
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||||
|
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||||
|
.border-2 { border-width: 2px; border-style: solid; }
|
||||||
|
.border-transparent { border-color: transparent; }
|
||||||
|
.border-gray-100 { border-color: #f3f4f6; }
|
||||||
|
.border-gray-200 { border-color: #e5e7eb; }
|
||||||
|
.border-gray-300 { border-color: #d1d5db; }
|
||||||
|
.border-blue-500 { border-color: #3b82f6; }
|
||||||
|
.border-blue-600 { border-color: #2563eb; }
|
||||||
|
.border-red-200 { border-color: #fecaca; }
|
||||||
|
|
||||||
|
/* ── Border radius ──────────────────────────────────────────────────────── */
|
||||||
|
.rounded { border-radius: 0.25rem; }
|
||||||
|
.rounded-md { border-radius: 0.375rem; }
|
||||||
|
.rounded-lg { border-radius: 0.5rem; }
|
||||||
|
.rounded-xl { border-radius: 0.75rem; }
|
||||||
|
.rounded-full { border-radius: 9999px; }
|
||||||
|
|
||||||
|
/* ── Shadows ────────────────────────────────────────────────────────────── */
|
||||||
|
.shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0/.1),0 1px 2px -1px rgb(0 0 0/.1); }
|
||||||
|
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0/.05); }
|
||||||
|
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0/.1),0 2px 4px -2px rgb(0 0 0/.1); }
|
||||||
|
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0/.1),0 4px 6px -4px rgb(0 0 0/.1); }
|
||||||
|
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1); }
|
||||||
|
|
||||||
|
/* ── Overflow / z-index ─────────────────────────────────────────────────── */
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.overflow-y-auto { overflow-y: auto; }
|
||||||
|
.z-50 { z-index: 50; }
|
||||||
|
|
||||||
|
/* ── Misc ───────────────────────────────────────────────────────────────── */
|
||||||
|
.opacity-50 { opacity: 0.5; }
|
||||||
|
.opacity-75 { opacity: 0.75; }
|
||||||
|
.cursor-not-allowed { cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Pseudo-classes ─────────────────────────────────────────────────────── */
|
||||||
|
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
|
||||||
|
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||||||
|
.hover\:bg-blue-200:hover { background-color: #bfdbfe; }
|
||||||
|
.hover\:bg-blue-700:hover { background-color: #1d4ed8; }
|
||||||
|
.hover\:bg-green-200:hover { background-color: #bbf7d0; }
|
||||||
|
.hover\:bg-red-200:hover { background-color: #fecaca; }
|
||||||
|
.hover\:bg-red-700:hover { background-color: #b91c1c; }
|
||||||
|
.hover\:text-gray-500:hover { color: #6b7280; }
|
||||||
|
.hover\:text-gray-700:hover { color: #374151; }
|
||||||
|
.hover\:text-gray-800:hover { color: #1f2937; }
|
||||||
|
.hover\:text-blue-600:hover { color: #2563eb; }
|
||||||
|
.hover\:text-red-800:hover { color: #991b1b; }
|
||||||
|
.hover\:border-blue-300:hover { border-color: #93c5fd; }
|
||||||
|
|
||||||
|
.focus\:outline-none:focus { outline: none; }
|
||||||
|
.focus\:ring-2:focus { box-shadow: 0 0 0 2px #3b82f6; }
|
||||||
|
|
||||||
|
.disabled\:opacity-50:disabled { opacity: 0.5; }
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTemplateMetaPayload {
|
||||||
|
title?: string;
|
||||||
|
subject?: string | null;
|
||||||
|
page_count?: number | null;
|
||||||
|
status?: ExamTemplateStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
|
export type ExamTemplateSource = 'manual' | 'ai';
|
||||||
|
|
||||||
|
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;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExamResponseAreaKind =
|
||||||
|
| 'response'
|
||||||
|
| 'context'
|
||||||
|
| 'question_number'
|
||||||
|
| 'mark_area'
|
||||||
|
| 'reference'
|
||||||
|
| 'furniture';
|
||||||
|
|
||||||
|
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
|
||||||
|
|
||||||
|
export interface ExamResponseArea {
|
||||||
|
id: string;
|
||||||
|
question_id: string;
|
||||||
|
template_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: ExamResponseAreaKind;
|
||||||
|
response_form: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | 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: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateLayout {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
page_index: number;
|
||||||
|
role: string | null;
|
||||||
|
margin_left: number | null;
|
||||||
|
margin_right: number | null;
|
||||||
|
margin_top: number | null;
|
||||||
|
margin_bottom: number | null;
|
||||||
|
margins_enabled: boolean;
|
||||||
|
source: ExamTemplateSource;
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
derivation: string | null;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
|
questions: ExamQuestion[];
|
||||||
|
response_areas: ExamResponseArea[];
|
||||||
|
boundaries: ExamBoundary[];
|
||||||
|
layout: ExamTemplateLayout[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TemplateReplacePayload {
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
subject?: string;
|
||||||
|
page_count?: number;
|
||||||
|
status?: ExamTemplateStatus;
|
||||||
|
};
|
||||||
|
questions: Array<{
|
||||||
|
id?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown | null;
|
||||||
|
mark_scheme?: Record<string, unknown>;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
}>;
|
||||||
|
response_areas: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: ExamResponseArea['kind'];
|
||||||
|
response_form?: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
mark_subtype?: ExamMarkSubtype | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
}>;
|
||||||
|
boundaries: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
}>;
|
||||||
|
layout?: Array<{
|
||||||
|
id?: string;
|
||||||
|
page_index: number;
|
||||||
|
role?: string | null;
|
||||||
|
margin_left?: number | null;
|
||||||
|
margin_right?: number | null;
|
||||||
|
margin_top?: number | null;
|
||||||
|
margin_bottom?: number | null;
|
||||||
|
margins_enabled?: boolean;
|
||||||
|
source?: ExamTemplateSource;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
derivation?: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMapAcceptedResponse {
|
||||||
|
status: 'accepted';
|
||||||
|
job_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMapJobStatus {
|
||||||
|
job_id: string;
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed' | string;
|
||||||
|
template_id: string;
|
||||||
|
updated_at?: number;
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
error?: string;
|
||||||
|
template?: ExamTemplateDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse;
|
||||||
|
|
||||||
|
export interface MarkingBatch {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
class_id: string | null;
|
||||||
|
institute_id: string;
|
||||||
|
teacher_id: string;
|
||||||
|
title: string | null;
|
||||||
|
status: 'open' | 'closed' | 'archived' | string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
submission_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentSubmission {
|
||||||
|
id: string;
|
||||||
|
batch_id: string;
|
||||||
|
student_id: string | null;
|
||||||
|
student_name: string | null;
|
||||||
|
status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
|
||||||
|
storage_path?: string | null;
|
||||||
|
mark_entry_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchQueueResponse {
|
||||||
|
batch: MarkingBatch;
|
||||||
|
submissions: StudentSubmission[];
|
||||||
|
progress: {
|
||||||
|
total: number;
|
||||||
|
absent: number;
|
||||||
|
complete: number;
|
||||||
|
in_progress: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamResultRow {
|
||||||
|
submission_id: string;
|
||||||
|
student_id: string | null;
|
||||||
|
student_name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
marks: Record<string, number | null | undefined>;
|
||||||
|
total: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchResultsResponse {
|
||||||
|
batch: MarkingBatch;
|
||||||
|
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
|
||||||
|
results: ExamResultRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBatchPayload {
|
||||||
|
template_id: string;
|
||||||
|
class_id?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkUpsertPayload {
|
||||||
|
submission_id: string;
|
||||||
|
question_id: string;
|
||||||
|
awarded_marks: number;
|
||||||
|
mark_scheme_detail?: Record<string, unknown>;
|
||||||
|
annotation_shape_ids?: unknown;
|
||||||
|
comment?: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||||
|
import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
|
||||||
|
|
||||||
|
const template: ExamTemplateDetail = {
|
||||||
|
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||||
|
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('exam setup canvas serialization', () => {
|
||||||
|
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
|
||||||
|
const payload = serializeCanvasShapes(template, [
|
||||||
|
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||||
|
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||||
|
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
||||||
|
{ id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const main = payload.questions.find((q) => q.is_container)
|
||||||
|
const part = payload.questions.find((q) => !q.is_container)
|
||||||
|
expect(main?.label).toBe('Q1')
|
||||||
|
expect(part?.parent_id).toBe(main?.id)
|
||||||
|
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
|
||||||
|
expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' })
|
||||||
|
expect(payload.boundaries).toHaveLength(2)
|
||||||
|
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
|
||||||
|
expect(payload.questions.every((q) => isUuid(q.id))).toBe(true)
|
||||||
|
expect(payload.response_areas.every((r) => isUuid(r.id))).toBe(true)
|
||||||
|
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
|
||||||
|
const pages = [
|
||||||
|
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
|
||||||
|
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
|
||||||
|
]
|
||||||
|
expect(pageForY(1050, pages)).toBe(2)
|
||||||
|
const payload = serializeCanvasShapes(template, [
|
||||||
|
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
|
||||||
|
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
|
||||||
|
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
|
||||||
|
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
|
||||||
|
], pages)
|
||||||
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
|
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
|
||||||
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
||||||
|
const shapes = shapesFromTemplate({
|
||||||
|
...template,
|
||||||
|
questions: [
|
||||||
|
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
|
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
|
],
|
||||||
|
response_areas: [
|
||||||
|
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
|
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
||||||
|
],
|
||||||
|
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
|
||||||
|
})
|
||||||
|
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
|
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
||||||
|
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => {
|
||||||
|
const detail: ExamTemplateDetail = {
|
||||||
|
...template,
|
||||||
|
questions: [
|
||||||
|
{ id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
{ id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
{ id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' },
|
||||||
|
],
|
||||||
|
response_areas: [
|
||||||
|
{ id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' },
|
||||||
|
],
|
||||||
|
boundaries: [
|
||||||
|
{ id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
{ id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const shapes = shapesFromTemplate(detail)
|
||||||
|
const part = shapes.find((shape) => shape.kind === 'part')
|
||||||
|
expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||||
|
expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes']))
|
||||||
|
|
||||||
|
const payload = serializeCanvasShapes(template, shapes)
|
||||||
|
expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||||
|
expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' })
|
||||||
|
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
|
||||||
|
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
|
||||||
|
|
||||||
|
export const PAGE_HEIGHT = 1100
|
||||||
|
export const PAGE_WIDTH = 780
|
||||||
|
export const PAGE_GAP = 0
|
||||||
|
|
||||||
|
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
|
|
||||||
|
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
|
export interface ExamCanvasShapeModel {
|
||||||
|
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||||
|
id: string
|
||||||
|
kind: ExamCanvasShapeKind
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
label?: string
|
||||||
|
maxMarks?: number
|
||||||
|
answerType?: 'written' | 'mcq' | 'short' | 'diagram'
|
||||||
|
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||||
|
contextType?: string
|
||||||
|
questionId?: string | null
|
||||||
|
source?: ExamTemplateSource
|
||||||
|
confirmed?: boolean
|
||||||
|
confidence?: number | null
|
||||||
|
derivation?: string | null
|
||||||
|
reviewFlags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||||
|
if (pages?.length) {
|
||||||
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
|
||||||
|
if (hit) return hit.pageNumber
|
||||||
|
const nearest = pages.reduce((best, page) => {
|
||||||
|
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
|
||||||
|
return dy < best.dy ? { page, dy } : best
|
||||||
|
}, { page: pages[0], dy: Number.POSITIVE_INFINITY })
|
||||||
|
return nearest.page.pageNumber
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
|
||||||
|
const hit = pages?.find((p) => p.pageNumber === page)
|
||||||
|
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
|
||||||
|
return pageForY(shape.y + shape.h / 2, pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUuid(value: string | null | undefined): value is string {
|
||||||
|
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newDomainId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = Math.floor(Math.random() * 16)
|
||||||
|
const v = c === 'x' ? r : (r % 4) + 8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
|
||||||
|
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
|
||||||
|
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
|
||||||
|
const page = pageGeometry(pageForShape(shape, pages), pages)
|
||||||
|
return { x: page.x, y: shape.y, w: page.w, h: 8 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedSource(shape: ExamCanvasShapeModel): ExamTemplateSource {
|
||||||
|
return shape.source ?? 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedConfirmed(shape: ExamCanvasShapeModel): boolean {
|
||||||
|
return shape.confirmed ?? persistedSource(shape) !== 'ai'
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedConfidence(shape: ExamCanvasShapeModel): number | null {
|
||||||
|
return typeof shape.confidence === 'number' ? shape.confidence : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedDerivation(shape: ExamCanvasShapeModel): string | null {
|
||||||
|
return shape.derivation ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||||
|
const ox2 = outer.x + outer.w
|
||||||
|
const oy2 = outer.y + outer.h
|
||||||
|
const ix2 = inner.x + inner.w
|
||||||
|
const iy2 = inner.y + inner.h
|
||||||
|
return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean {
|
||||||
|
const minY = Math.min(top.y, bottom.y)
|
||||||
|
const maxY = Math.max(top.y, bottom.y)
|
||||||
|
const cy = shape.y + shape.h / 2
|
||||||
|
return cy >= minY && cy <= maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
|
||||||
|
const orderedBoundaries = shapes
|
||||||
|
.filter((s) => s.kind === 'boundary')
|
||||||
|
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
|
||||||
|
const parts = shapes.filter((s) => s.kind === 'part')
|
||||||
|
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||||
|
|
||||||
|
const questions: TemplateReplacePayload['questions'] = []
|
||||||
|
const boundaries: TemplateReplacePayload['boundaries'] = []
|
||||||
|
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < orderedBoundaries.length; i += 2) {
|
||||||
|
const top = orderedBoundaries[i]
|
||||||
|
const bottom = orderedBoundaries[i + 1]
|
||||||
|
if (!top || !bottom) break
|
||||||
|
const qNum = bands.length + 1
|
||||||
|
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
||||||
|
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||||
|
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
|
||||||
|
bands.push({ questionId, top, bottom })
|
||||||
|
for (const b of [top, bottom]) {
|
||||||
|
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partQuestionIds = new Map<string, string>()
|
||||||
|
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
|
||||||
|
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||||
|
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||||
|
partQuestionIds.set(part.id, qid)
|
||||||
|
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
|
for (const region of regions) {
|
||||||
|
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||||
|
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||||
|
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||||
|
if (!questionId) continue
|
||||||
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
|
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
|
for (const b of detail.boundaries ?? []) {
|
||||||
|
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
||||||
|
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
|
||||||
|
// but render and save a full rendered-page-width horizontal rule.
|
||||||
|
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id, source: b.source, confirmed: b.confirmed, confidence: b.confidence, derivation: b.derivation })
|
||||||
|
}
|
||||||
|
for (const q of detail.questions ?? []) {
|
||||||
|
if (q.is_container || !q.bounds) continue
|
||||||
|
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: (q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id, source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
|
||||||
|
}
|
||||||
|
for (const r of detail.response_areas ?? []) {
|
||||||
|
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||||
|
const q = questions.get(r.question_id)
|
||||||
|
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation })
|
||||||
|
}
|
||||||
|
return addCheapReviewFlags(shapes, pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: ExamCanvasShapeModel, b: ExamCanvasShapeModel): boolean {
|
||||||
|
const ax2 = a.x + a.w
|
||||||
|
const ay2 = a.y + a.h
|
||||||
|
const bx2 = b.x + b.w
|
||||||
|
const by2 = b.y + b.h
|
||||||
|
return a.x < bx2 && ax2 > b.x && a.y < by2 && ay2 > b.y
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksUncertainLabel(label: string | undefined): boolean {
|
||||||
|
return !label || /\b(unknown|uncertain|maybe|todo|tbd)\b|\?/.test(label.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
|
const markAreasByQuestion = new Set(shapes.filter((shape) => shape.kind === 'mark_area' && shape.questionId).map((shape) => shape.questionId as string))
|
||||||
|
return shapes.map((shape, index) => {
|
||||||
|
const flags: string[] = []
|
||||||
|
if (shape.source === 'ai' && shape.confirmed === false) flags.push('unconfirmed AI')
|
||||||
|
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
|
||||||
|
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
|
||||||
|
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
|
||||||
|
const samePageOverlap = shapes.some((other, otherIndex) => otherIndex !== index && shape.kind !== 'boundary' && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && (shape.kind === other.kind || (!contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))))
|
||||||
|
if (samePageOverlap) flags.push('overlapping shapes')
|
||||||
|
return flags.length ? { ...shape, reviewFlags: flags } : shape
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
|
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
|
||||||
import { CCBaseShape } from './cc-types'
|
import { CCBaseShape } from './cc-types'
|
||||||
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
|
|
||||||
import { logger } from '../../../debugConfig'
|
import { logger } from '../../../debugConfig'
|
||||||
|
|
||||||
export interface ToolbarItem {
|
export interface ToolbarItem {
|
||||||
@@ -40,44 +39,32 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
|||||||
|
|
||||||
component(shape: T) {
|
component(shape: T) {
|
||||||
const {
|
const {
|
||||||
props: { w, h, isLocked },
|
props: { w, h, isLocked, headerColor },
|
||||||
} = shape
|
} = shape
|
||||||
const toolbarItems = this.getToolbarItems(shape)
|
const toolbarItems = this.getToolbarItems(shape)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HTMLContainer
|
<HTMLContainer
|
||||||
id={shape.id}
|
id={shape.id}
|
||||||
|
className="cc-shape-container"
|
||||||
style={{
|
style={{
|
||||||
width: toDomPrecision(w),
|
width: toDomPrecision(w),
|
||||||
height: toDomPrecision(h),
|
height: toDomPrecision(h),
|
||||||
backgroundColor: shape.props.headerColor,
|
'--shape-header-color': headerColor,
|
||||||
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
|
} as React.CSSProperties}
|
||||||
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className="cc-shape-header"
|
||||||
backgroundColor: shape.props.headerColor,
|
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
|
||||||
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
|
|
||||||
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: isLocked ? 'not-allowed' : 'move',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
|
<span className="shape-title">{shape.props.title}</span>
|
||||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
|
<div className="cc-shape-toolbar">
|
||||||
{toolbarItems.map((item) => (
|
{toolbarItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
|
style={{ opacity: item.isActive ? 1 : 0.7 }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
|
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -89,46 +76,19 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'white',
|
|
||||||
opacity: item.isActive ? 1 : 0.7,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
fontSize: '16px',
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
zIndex: 100,
|
|
||||||
userSelect: 'none',
|
|
||||||
position: 'relative',
|
|
||||||
touchAction: 'none',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ pointerEvents: 'none' }}>
|
<div style={{ pointerEvents: 'none' }}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
|
{isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className="cc-shape-content"
|
||||||
position: 'absolute',
|
style={{ backgroundColor: shape.props.backgroundColor }}
|
||||||
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
|
|
||||||
backgroundColor: shape.props.backgroundColor,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{this.renderContent(shape)}
|
{this.renderContent(shape)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
|
|||||||
|
|
||||||
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
||||||
const defaultProps = getDefaultCCTimetableLessonNodeProps()
|
const defaultProps = getDefaultCCTimetableLessonNodeProps()
|
||||||
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
|
|
||||||
return {
|
return {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
headerColor: theme.headerColor,
|
headerColor: '',
|
||||||
|
school_db_name: '',
|
||||||
|
school_period_id: '',
|
||||||
|
subject_class: '',
|
||||||
|
date: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
period_code: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -543,6 +543,8 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
|
|||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
period_code: '',
|
period_code: '',
|
||||||
|
school_db_name: '',
|
||||||
|
school_period_id: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getDefaultCCPlannedLessonNodeProps = () => ({
|
export const getDefaultCCPlannedLessonNodeProps = () => ({
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShape
|
|||||||
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
|
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
|
||||||
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
|
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
|
||||||
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
|
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
|
||||||
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
|
||||||
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
|
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
|
||||||
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
|
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
|
||||||
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
|
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
|
||||||
@@ -31,12 +30,11 @@ import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademic
|
|||||||
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
|
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
|
||||||
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
|
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
|
||||||
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
|
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
|
||||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
|
||||||
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
||||||
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
||||||
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
||||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
|
||||||
|
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
||||||
// Create a const object with all node types
|
// Create a const object with all node types
|
||||||
export const NODE_SHAPE_TYPES = {
|
export const NODE_SHAPE_TYPES = {
|
||||||
USER: CCUserNodeShapeUtil.type,
|
USER: CCUserNodeShapeUtil.type,
|
||||||
@@ -118,8 +116,7 @@ export type AllNodeShapes =
|
|||||||
| CCTimetableLessonNodeShape
|
| CCTimetableLessonNodeShape
|
||||||
| CCPlannedLessonNodeShape
|
| CCPlannedLessonNodeShape
|
||||||
| CCDepartmentStructureNodeShape
|
| CCDepartmentStructureNodeShape
|
||||||
| CCUserTeacherTimetableNodeShape
|
| CCUserTeacherTimetableNodeShape;
|
||||||
| CCTimetableLessonNodeShape;
|
|
||||||
|
|
||||||
// Export all shape utils in an object for easy access
|
// Export all shape utils in an object for easy access
|
||||||
export const ShapeUtils = {
|
export const ShapeUtils = {
|
||||||
@@ -159,7 +156,6 @@ export const ShapeUtils = {
|
|||||||
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
||||||
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
||||||
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
||||||
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Add a type guard to check if a shape is a valid node shape
|
// Add a type guard to check if a shape is a valid node shape
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for CCLiveTranscriptionShapeUtil
|
||||||
|
*
|
||||||
|
* Intended production location:
|
||||||
|
* src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts
|
||||||
|
*
|
||||||
|
* Run from classroom-copilot-app root:
|
||||||
|
* npx vitest run transcription-shape
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { Editor, createShapeId, createTLStore } from '@tldraw/tldraw'
|
||||||
|
import { CCLiveTranscriptionShapeUtil, type CCLiveTranscriptionShape, type TranscriptionSegment } from './CCLiveTranscriptionShapeUtil'
|
||||||
|
import { CCSlideShapeUtil } from '../cc-slideshow/CCSlideShapeUtil'
|
||||||
|
import { CCSlideShowShapeUtil } from '../cc-slideshow/CCSlideShowShapeUtil'
|
||||||
|
import { getDefaultCCLiveTranscriptionProps } from '../cc-props'
|
||||||
|
|
||||||
|
// ─── Mock TranscriptionManager ──────────────────────────────────────────────
|
||||||
|
// Must be hoisted before the module under test is imported in a real project.
|
||||||
|
// Here we patch the module directly since we control the import order.
|
||||||
|
|
||||||
|
// Declare symbols before vi.mock so hoisting/factory capture stays consistent.
|
||||||
|
const mockStartTranscription = () => {}
|
||||||
|
const mockStopTranscription = () => {}
|
||||||
|
|
||||||
|
vi.mock('./TranscriptionManager', () => {
|
||||||
|
const start = vi.fn()
|
||||||
|
const stop = vi.fn()
|
||||||
|
Object.assign(globalThis, { __mockStartTranscription: start, __mockStopTranscription: stop })
|
||||||
|
return {
|
||||||
|
TranscriptionManager: {
|
||||||
|
getManager: vi.fn().mockReturnValue({ startTranscription: start, stopTranscription: stop }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sm = () => (globalThis as unknown as Record<string, any>).__mockStartTranscription
|
||||||
|
const st = () => (globalThis as unknown as Record<string, any>).__mockStopTranscription
|
||||||
|
|
||||||
|
const clearMocks = () => { sm().mockClear(); st().mockClear() }
|
||||||
|
|
||||||
|
// Provide a minimal matchMedia so tldraw's UserPreferencesManager
|
||||||
|
// can call addEventListener/removeEventListener in jsdom.
|
||||||
|
if (typeof globalThis.matchMedia !== 'function') {
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: false,
|
||||||
|
media: '',
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
} as unknown as MediaQueryList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Minimal shape utils needed to boot the Editor for transcription tests. */
|
||||||
|
const shapeUtils = [CCLiveTranscriptionShapeUtil, CCSlideShapeUtil, CCSlideShowShapeUtil]
|
||||||
|
|
||||||
|
function makeEditor(): Editor {
|
||||||
|
const store = createTLStore({ shapeUtils, bindingUtils: [] })
|
||||||
|
return new Editor({
|
||||||
|
shapeUtils,
|
||||||
|
bindingUtils: [],
|
||||||
|
tools: [],
|
||||||
|
store,
|
||||||
|
getContainer: () => document.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a transcription shape with optional prop overrides and return its id. */
|
||||||
|
function createTranscriptionShape(
|
||||||
|
editor: Editor,
|
||||||
|
overrides: Partial<CCLiveTranscriptionShape['props']> = {}
|
||||||
|
) {
|
||||||
|
const id = createShapeId()
|
||||||
|
editor.createShape<CCLiveTranscriptionShape>({
|
||||||
|
id,
|
||||||
|
type: 'cc-live-transcription',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
props: {
|
||||||
|
...getDefaultCCLiveTranscriptionProps(),
|
||||||
|
...overrides,
|
||||||
|
} as CCLiveTranscriptionShape['props'],
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed getter for the shape record. */
|
||||||
|
function getShape(editor: Editor, id: ReturnType<typeof createShapeId>) {
|
||||||
|
return editor.getShape<CCLiveTranscriptionShape>(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a finished TranscriptionSegment fixture. */
|
||||||
|
function makeSegment(text: string, overrides: Partial<TranscriptionSegment> = {}): TranscriptionSegment {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text,
|
||||||
|
completed: true,
|
||||||
|
start: '0.000',
|
||||||
|
end: '1.000',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test Suite ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('CCLiveTranscriptionShapeUtil', () => {
|
||||||
|
let editor: Editor
|
||||||
|
let util: CCLiveTranscriptionShapeUtil
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
editor = makeEditor()
|
||||||
|
util = editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription')
|
||||||
|
clearMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
editor.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getDefaultProps ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getDefaultProps()', () => {
|
||||||
|
it('returns the expected default structure', () => {
|
||||||
|
const defaults = util.getDefaultProps()
|
||||||
|
|
||||||
|
expect(defaults).toMatchObject({
|
||||||
|
isRecording: false,
|
||||||
|
segments: [],
|
||||||
|
currentSegment: undefined,
|
||||||
|
lastProcessedSegment: undefined,
|
||||||
|
})
|
||||||
|
// Base props present
|
||||||
|
expect(typeof defaults.title).toBe('string')
|
||||||
|
expect(typeof defaults.w).toBe('number')
|
||||||
|
expect(typeof defaults.h).toBe('number')
|
||||||
|
expect(typeof defaults.headerColor).toBe('string')
|
||||||
|
expect(typeof defaults.backgroundColor).toBe('string')
|
||||||
|
expect(defaults.isLocked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('segments array is empty, not null or undefined', () => {
|
||||||
|
const { segments } = util.getDefaultProps()
|
||||||
|
expect(Array.isArray(segments)).toBe(true)
|
||||||
|
expect(segments).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── updateText — unconfirmed (live partial) ─────────────────────────────
|
||||||
|
|
||||||
|
describe('updateText() — unconfirmed segment (isConfirmed = false)', () => {
|
||||||
|
it('updates only currentSegment, does not touch the segments array', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
|
||||||
|
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
expect(shape.props.segments).toHaveLength(0)
|
||||||
|
expect(shape.props.currentSegment).toMatchObject({
|
||||||
|
text: 'hello wor',
|
||||||
|
completed: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites currentSegment when called again with new partial text', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
|
||||||
|
util.updateText(id, 'hello world', false, { start: 0, end: 1.2 })
|
||||||
|
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
expect(shape.props.currentSegment?.text).toBe('hello world')
|
||||||
|
expect(shape.props.segments).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT call updateShape when the partial text is unchanged', () => {
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: true,
|
||||||
|
currentSegment: makeSegment('same text', { completed: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const spy = vi.spyOn(editor, 'updateShape')
|
||||||
|
util.updateText(id, 'same text', false, { start: 0, end: 1.0 })
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled()
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── updateText — confirmed (completed) segment ─────────────────────────
|
||||||
|
|
||||||
|
describe('updateText() — confirmed segment (isConfirmed = true)', () => {
|
||||||
|
it('appends to segments array when text is new', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'First sentence.', true, { start: 0, end: 2.1 })
|
||||||
|
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
expect(shape.props.segments).toHaveLength(1)
|
||||||
|
expect(shape.props.segments[0]).toMatchObject({
|
||||||
|
text: 'First sentence.',
|
||||||
|
completed: true,
|
||||||
|
start: '0.000',
|
||||||
|
end: '2.100',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates lastProcessedSegment to the confirmed text', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'Second sentence.', true, { start: 2, end: 4 })
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.lastProcessedSegment).toBe('Second sentence.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears currentSegment when its text matches the confirmed text', () => {
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: true,
|
||||||
|
currentSegment: makeSegment('Final words.', { completed: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
util.updateText(id, 'Final words.', true, { start: 5, end: 7 })
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.currentSegment).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves currentSegment when its text differs from the confirmed text', () => {
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: true,
|
||||||
|
currentSegment: makeSegment('still typing...', { completed: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
util.updateText(id, 'Earlier sentence confirmed.', true, { start: 0, end: 3 })
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.currentSegment?.text).toBe('still typing...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DEDUPLICATION — does not add a segment whose text is already in segments[]', () => {
|
||||||
|
const existingSegment = makeSegment('Duplicate text.')
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: true,
|
||||||
|
segments: [existingSegment],
|
||||||
|
lastProcessedSegment: 'Duplicate text.',
|
||||||
|
})
|
||||||
|
|
||||||
|
util.updateText(id, 'Duplicate text.', true, { start: 10, end: 12 })
|
||||||
|
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
expect(shape.props.segments).toHaveLength(1)
|
||||||
|
expect(shape.props.segments[0].id).toBe(existingSegment.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DEDUPLICATION — blocks re-add even when lastProcessedSegment differs (isDuplicate text check)', () => {
|
||||||
|
const existingSegment = makeSegment('Already confirmed.')
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: true,
|
||||||
|
segments: [existingSegment],
|
||||||
|
lastProcessedSegment: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
util.updateText(id, 'Already confirmed.', true, { start: 15, end: 17 })
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.segments).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accumulates multiple unique confirmed segments in order', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'Sentence one.', true, { start: 0, end: 2 })
|
||||||
|
util.updateText(id, 'Sentence two.', true, { start: 2, end: 4 })
|
||||||
|
util.updateText(id, 'Sentence three.', true, { start: 4, end: 6 })
|
||||||
|
|
||||||
|
const { segments } = getShape(editor, id).props
|
||||||
|
expect(segments).toHaveLength(3)
|
||||||
|
expect(segments.map((s) => s.text)).toEqual([
|
||||||
|
'Sentence one.',
|
||||||
|
'Sentence two.',
|
||||||
|
'Sentence three.',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assigns unique ids to each confirmed segment', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'Alpha.', true, { start: 0, end: 1 })
|
||||||
|
util.updateText(id, 'Beta.', true, { start: 1, end: 2 })
|
||||||
|
|
||||||
|
const { segments } = getShape(editor, id).props
|
||||||
|
expect(segments[0].id).not.toBe(segments[1].id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats numeric start/end timestamps to 3 decimal places', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
util.updateText(id, 'Timestamped.', true, { start: 1.1, end: 2.5678 })
|
||||||
|
|
||||||
|
const seg = getShape(editor, id).props.segments[0]
|
||||||
|
expect(seg.start).toBe('1.100')
|
||||||
|
expect(seg.end).toBe('2.568')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── toggleRecording ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('toggleRecording() — via private accessor', () => {
|
||||||
|
// TypeScript private methods are still callable at runtime; we use
|
||||||
|
// bracket notation to bypass the access check cleanly in tests.
|
||||||
|
const toggle = (u: CCLiveTranscriptionShapeUtil, shape: CCLiveTranscriptionShape) =>
|
||||||
|
(u as unknown as { toggleRecording(s: CCLiveTranscriptionShape): void }).toggleRecording(shape)
|
||||||
|
|
||||||
|
it('STARTING — sets isRecording to true', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
|
||||||
|
toggle(util, shape)
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.isRecording).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('STARTING — resets segments to an empty array', () => {
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: false,
|
||||||
|
segments: [makeSegment('old transcript'), makeSegment('more old text')],
|
||||||
|
})
|
||||||
|
|
||||||
|
toggle(util, getShape(editor, id))
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.segments).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('STARTING — clears currentSegment and lastProcessedSegment', () => {
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: false,
|
||||||
|
currentSegment: makeSegment('partial', { completed: false }),
|
||||||
|
lastProcessedSegment: 'some prior segment',
|
||||||
|
})
|
||||||
|
|
||||||
|
toggle(util, getShape(editor, id))
|
||||||
|
|
||||||
|
const props = getShape(editor, id).props
|
||||||
|
expect(props.currentSegment).toBeUndefined()
|
||||||
|
expect(props.lastProcessedSegment).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('STARTING — calls TranscriptionManager.startTranscription with the shape id', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
|
||||||
|
toggle(util, shape)
|
||||||
|
|
||||||
|
expect(sm()).toHaveBeenCalledOnce()
|
||||||
|
expect(sm()).toHaveBeenCalledWith(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('STOPPING — sets isRecording to false', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
const shape = getShape(editor, id)
|
||||||
|
|
||||||
|
toggle(util, shape)
|
||||||
|
|
||||||
|
expect(getShape(editor, id).props.isRecording).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('STOPPING — preserves existing segments (does not wipe them)', () => {
|
||||||
|
const seg1 = makeSegment('Keep me.')
|
||||||
|
const seg2 = makeSegment('Keep me too.')
|
||||||
|
const id = createTranscriptionShape(editor, {
|
||||||
|
isRecording: true,
|
||||||
|
segments: [seg1, seg2],
|
||||||
|
})
|
||||||
|
|
||||||
|
toggle(util, getShape(editor, id))
|
||||||
|
|
||||||
|
const { segments } = getShape(editor, id).props
|
||||||
|
expect(segments).toHaveLength(2)
|
||||||
|
expect(segments[0].text).toBe('Keep me.')
|
||||||
|
expect(segments[1].text).toBe('Keep me too.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('STOPPING — calls TranscriptionManager.stopTranscription', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
|
||||||
|
toggle(util, getShape(editor, id))
|
||||||
|
|
||||||
|
expect(st()).toHaveBeenCalledOnce()
|
||||||
|
expect(sm()).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('start-stop cycle leaves shape in consistent state', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||||
|
|
||||||
|
toggle(util, getShape(editor, id))
|
||||||
|
expect(getShape(editor, id).props.isRecording).toBe(true)
|
||||||
|
|
||||||
|
util.updateText(id, 'Live lesson note.', true, { start: 0, end: 3 })
|
||||||
|
|
||||||
|
toggle(util, getShape(editor, id))
|
||||||
|
|
||||||
|
const props = getShape(editor, id).props
|
||||||
|
expect(props.isRecording).toBe(false)
|
||||||
|
expect(props.segments).toHaveLength(1)
|
||||||
|
expect(props.segments[0].text).toBe('Live lesson note.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Edge cases ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('updateText() — edge cases', () => {
|
||||||
|
it('does nothing if the shape id does not exist in the store', () => {
|
||||||
|
const ghostId = createShapeId('nonexistent')
|
||||||
|
expect(() => util.updateText(ghostId, 'text', true, { start: 0, end: 1 })).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty string text without crashing', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
expect(() => util.updateText(id, '', true, { start: 0, end: 0 })).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles string start/end metadata (passed as already-formatted strings)', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
util.updateText(id, 'String timestamps.', true, { start: '1.234' as unknown as number, end: '5.678' as unknown as number })
|
||||||
|
|
||||||
|
const seg = getShape(editor, id).props.segments[0]
|
||||||
|
expect(typeof seg.start).toBe('string')
|
||||||
|
expect(typeof seg.end).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles missing metadata gracefully, falling back to "0.000"', () => {
|
||||||
|
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||||
|
util.updateText(id, 'No timestamps.', true, undefined as unknown as { start: number; end: number })
|
||||||
|
|
||||||
|
const seg = getShape(editor, id).props.segments[0]
|
||||||
|
expect(seg.start).toBe('0.000')
|
||||||
|
expect(seg.end).toBe('0.000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ import { TranscriptionManager } from '../cc-transcription/TranscriptionManager';
|
|||||||
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
|
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
|
||||||
import { ccShapeMigrations } from '../cc-migrations';
|
import { ccShapeMigrations } from '../cc-migrations';
|
||||||
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
|
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
|
||||||
|
import { logger } from '../../../../debugConfig';
|
||||||
|
|
||||||
export interface TranscriptionSegment {
|
export interface TranscriptionSegment {
|
||||||
id: string
|
id: string
|
||||||
@@ -83,7 +84,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
height: '48px',
|
height: '48px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
|
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -105,7 +106,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: isRecording ? '#f44336' : '#4CAF50'
|
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
|
||||||
}}>
|
}}>
|
||||||
{isRecording ? 'Recording...' : 'Ready'}
|
{isRecording ? 'Recording...' : 'Ready'}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,11 +208,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toggleRecording(shape: CCLiveTranscriptionShape) {
|
private toggleRecording(shape: CCLiveTranscriptionShape) {
|
||||||
console.log('🎤 Toggle recording clicked');
|
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
|
||||||
const { id } = shape;
|
const { id } = shape;
|
||||||
const { isRecording } = shape.props;
|
const { isRecording } = shape.props;
|
||||||
|
|
||||||
console.log('Current state:', { id, isRecording });
|
logger.debug('cc-live-transcription', 'Current state', { id, isRecording });
|
||||||
|
|
||||||
// When starting new recording, preserve existing props but reset segments
|
// When starting new recording, preserve existing props but reset segments
|
||||||
const newProps = !isRecording ? {
|
const newProps = !isRecording ? {
|
||||||
@@ -232,13 +233,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
});
|
});
|
||||||
|
|
||||||
const manager = TranscriptionManager.getManager(this.editor);
|
const manager = TranscriptionManager.getManager(this.editor);
|
||||||
console.log('Got transcription manager');
|
logger.debug('cc-live-transcription', 'Got transcription manager');
|
||||||
|
|
||||||
if (!isRecording) {
|
if (!isRecording) {
|
||||||
console.log('Starting transcription...');
|
logger.debug('cc-live-transcription', 'Starting transcription...');
|
||||||
manager.startTranscription(id);
|
manager.startTranscription(id);
|
||||||
} else {
|
} else {
|
||||||
console.log('Stopping transcription...');
|
logger.debug('cc-live-transcription', 'Stopping transcription...');
|
||||||
manager.stopTranscription();
|
manager.stopTranscription();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,10 +250,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
isConfirmed: boolean,
|
isConfirmed: boolean,
|
||||||
metadata?: { start: string | number, end: string | number }
|
metadata?: { start: string | number, end: string | number }
|
||||||
) {
|
) {
|
||||||
console.log('📝 Updating text:', { id, text, isConfirmed, metadata });
|
logger.debug('cc-live-transcription', '📝 Updating text', { id, text, isConfirmed, metadata });
|
||||||
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
|
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
|
||||||
if (!shape) {
|
if (!shape) {
|
||||||
console.warn('❌ Shape not found for updating text:', id);
|
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +309,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Text updated');
|
logger.debug('cc-live-transcription', '✅ Text updated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export class TranscriptionManager {
|
|||||||
this.transcriptionService = new TranscriptionService();
|
this.transcriptionService = new TranscriptionService();
|
||||||
|
|
||||||
// Set up callback for transcription updates
|
// Set up callback for transcription updates
|
||||||
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
|
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
|
||||||
console.log('📝 Transcription update received:', { text, metadata });
|
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
|
||||||
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
|
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
|
||||||
if (!util) {
|
if (!util) {
|
||||||
console.warn('❌ Shape util not found');
|
console.warn('❌ Shape util not found');
|
||||||
@@ -36,6 +36,10 @@ export class TranscriptionManager {
|
|||||||
}
|
}
|
||||||
console.log('Found transcription util:', !!util);
|
console.log('Found transcription util:', !!util);
|
||||||
|
|
||||||
|
const text = segments.map(s => s.text).join('') || '';
|
||||||
|
const metadata = segments.length ? { start: segments[0].start, end: segments[segments.length - 1].end } : { start: 0, end: 0 };
|
||||||
|
const isFinal = isLastLive;
|
||||||
|
|
||||||
// Check if text is stable (same output multiple times)
|
// Check if text is stable (same output multiple times)
|
||||||
const isStable = text === this.lastText;
|
const isStable = text === this.lastText;
|
||||||
if (isStable) {
|
if (isStable) {
|
||||||
|
|||||||
@@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: 'var(--cc-navy-50)',
|
||||||
borderLeft: '1px solid #ddd',
|
borderLeft: '1px solid var(--cc-border)',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
margin: '0 0 10px 0',
|
margin: '0 0 10px 0',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#333',
|
color: 'var(--cc-text-primary)',
|
||||||
},
|
},
|
||||||
line: {
|
line: {
|
||||||
padding: '5px',
|
padding: '5px',
|
||||||
marginBottom: '5px',
|
marginBottom: '5px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#333',
|
color: 'var(--cc-text-primary)',
|
||||||
backgroundColor: '#fff',
|
backgroundColor: 'var(--cc-bg-canvas)',
|
||||||
border: '1px solid #eee',
|
border: '1px solid var(--cc-border-strong)',
|
||||||
},
|
},
|
||||||
activeLine: {
|
activeLine: {
|
||||||
backgroundColor: '#e3f2fd',
|
backgroundColor: 'var(--cc-navy-50)',
|
||||||
border: '1px solid #2196f3',
|
border: '1px solid var(--cc-action-primary)',
|
||||||
color: '#1565c0',
|
color: 'var(--cc-action-primary)',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
|
||||||
transform: 'scale(1.02)',
|
transform: 'scale(1.02)',
|
||||||
transition: 'all 0.2s ease-in-out',
|
transition: 'all 0.2s ease-in-out',
|
||||||
},
|
},
|
||||||
timestamp: {
|
timestamp: {
|
||||||
color: '#666',
|
color: 'var(--cc-text-tertiary)',
|
||||||
marginRight: '8px',
|
marginRight: '8px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
color: '#666',
|
color: 'var(--cc-text-muted)',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
backgroundColor: '#f0f0f0',
|
backgroundColor: 'var(--cc-navy-50)',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid var(--cc-border)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: '#333',
|
color: 'var(--cc-text-primary)',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: '#e0e0e0',
|
backgroundColor: 'var(--cc-border-strong)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,976 @@
|
|||||||
|
/* ============================================================
|
||||||
|
CLASSROOM COPILOT — DESIGN SYSTEM
|
||||||
|
Aesthetic Direction: Technical Editorial / Industrial Utilitarian
|
||||||
|
|
||||||
|
Philosophy: Drafting table discipline. Every element earns
|
||||||
|
its place. Tone contrast over shadow depth. Grid over flow.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── TYPOGRAPHY IMPORTS ───────────────────────────────────── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
/* ─── ROOT TOKENS — LIGHT MODE ─────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* Typography */
|
||||||
|
--cc-font-sans: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--cc-font-mono: 'IBM Plex Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
|
||||||
|
--cc-text-xs: 0.6875rem; /* 11px */
|
||||||
|
--cc-text-sm: 0.75rem; /* 12px */
|
||||||
|
--cc-text-base: 0.875rem; /* 14px */
|
||||||
|
--cc-text-md: 1rem; /* 16px */
|
||||||
|
--cc-text-lg: 1.125rem; /* 18px */
|
||||||
|
--cc-text-xl: 1.25rem; /* 20px */
|
||||||
|
|
||||||
|
--cc-weight-normal: 400;
|
||||||
|
--cc-weight-medium: 500;
|
||||||
|
--cc-weight-semibold: 600;
|
||||||
|
|
||||||
|
--cc-leading-tight: 1.2;
|
||||||
|
--cc-leading-normal: 1.5;
|
||||||
|
--cc-leading-loose: 1.7;
|
||||||
|
|
||||||
|
--cc-tracking-tight: -0.01em;
|
||||||
|
--cc-tracking-normal: 0;
|
||||||
|
--cc-tracking-wide: 0.04em;
|
||||||
|
--cc-tracking-widest: 0.12em;
|
||||||
|
|
||||||
|
/* ── Color Primitives ── */
|
||||||
|
--cc-navy-950: #0D1117;
|
||||||
|
--cc-navy-900: #14191F;
|
||||||
|
--cc-navy-800: #1C2330;
|
||||||
|
--cc-navy-700: #263040;
|
||||||
|
--cc-navy-600: #344256;
|
||||||
|
--cc-navy-500: #435470;
|
||||||
|
--cc-navy-400: #5A6E8A;
|
||||||
|
--cc-navy-300: #7A8FA8;
|
||||||
|
--cc-navy-200: #A4B4C8;
|
||||||
|
--cc-navy-100: #D2DCE8;
|
||||||
|
--cc-navy-50: #EBF0F5;
|
||||||
|
|
||||||
|
--cc-parchment-950: #2A2519;
|
||||||
|
--cc-parchment-900: #3D3626;
|
||||||
|
--cc-parchment-800: #5C5238;
|
||||||
|
--cc-parchment-700: #7A6E4E;
|
||||||
|
--cc-parchment-600: #9A8C6A;
|
||||||
|
--cc-parchment-500: #B8A882;
|
||||||
|
--cc-parchment-400: #CEC0A0;
|
||||||
|
--cc-parchment-300: #DED2BA;
|
||||||
|
--cc-parchment-200: #EDE5D4;
|
||||||
|
--cc-parchment-100: #F5F2ED;
|
||||||
|
--cc-parchment-50: #FAF8F5;
|
||||||
|
|
||||||
|
--cc-amber-700: #8A5C1A;
|
||||||
|
--cc-amber-600: #A8711F;
|
||||||
|
--cc-amber-500: #C9882A;
|
||||||
|
--cc-amber-400: #D9A24E;
|
||||||
|
--cc-amber-300: #E6BC78;
|
||||||
|
--cc-amber-200: #F0D4A8;
|
||||||
|
--cc-amber-100: #F8EDDA;
|
||||||
|
|
||||||
|
--cc-sage-700: #2E3E36;
|
||||||
|
--cc-sage-600: #3A5046;
|
||||||
|
--cc-sage-500: #4A5E52;
|
||||||
|
--cc-sage-400: #607A6C;
|
||||||
|
--cc-sage-300: #7E9688;
|
||||||
|
--cc-sage-200: #A8BDB4;
|
||||||
|
--cc-sage-100: #D2DDD9;
|
||||||
|
|
||||||
|
--cc-crimson-600: #8B1A1A;
|
||||||
|
--cc-crimson-500: #A82828;
|
||||||
|
--cc-crimson-400: #C44040;
|
||||||
|
--cc-crimson-100: #F5DADA;
|
||||||
|
|
||||||
|
--cc-signal-green-600: #1A5C36;
|
||||||
|
--cc-signal-green-500: #246B42;
|
||||||
|
--cc-signal-green-400: #347A52;
|
||||||
|
--cc-signal-green-100: #D4EDE0;
|
||||||
|
|
||||||
|
/* ── Semantic Color Assignments — Light ── */
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--cc-bg-canvas: var(--cc-parchment-100);
|
||||||
|
--cc-bg-panel: var(--cc-parchment-50);
|
||||||
|
--cc-bg-panel-alt: #FFFFFF;
|
||||||
|
--cc-bg-shape: #FFFFFF;
|
||||||
|
--cc-bg-shape-hover: var(--cc-parchment-100);
|
||||||
|
--cc-bg-overlay: rgba(13, 17, 23, 0.48);
|
||||||
|
--cc-bg-code: var(--cc-navy-900);
|
||||||
|
|
||||||
|
/* Shape / card header tones */
|
||||||
|
--cc-header-default: var(--cc-navy-700);
|
||||||
|
--cc-header-accent: var(--cc-amber-500);
|
||||||
|
--cc-header-sage: var(--cc-sage-600);
|
||||||
|
--cc-header-dim: var(--cc-navy-500);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--cc-text-primary: var(--cc-navy-900);
|
||||||
|
--cc-text-secondary: var(--cc-navy-500);
|
||||||
|
--cc-text-tertiary: var(--cc-navy-300);
|
||||||
|
--cc-text-inverse: var(--cc-parchment-100);
|
||||||
|
--cc-text-accent: var(--cc-amber-500);
|
||||||
|
--cc-text-code: var(--cc-parchment-200);
|
||||||
|
--cc-text-link: var(--cc-amber-600);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--cc-border: var(--cc-navy-100);
|
||||||
|
--cc-border-strong: var(--cc-navy-200);
|
||||||
|
--cc-border-accent: var(--cc-amber-500);
|
||||||
|
--cc-border-focus: var(--cc-amber-400);
|
||||||
|
--cc-border-error: var(--cc-crimson-500);
|
||||||
|
--cc-border-success: var(--cc-signal-green-500);
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--cc-action-primary: var(--cc-amber-500);
|
||||||
|
--cc-action-primary-hover: var(--cc-amber-400);
|
||||||
|
--cc-action-primary-active: var(--cc-amber-600);
|
||||||
|
--cc-action-secondary: var(--cc-navy-700);
|
||||||
|
--cc-action-secondary-hover: var(--cc-navy-600);
|
||||||
|
--cc-action-danger: var(--cc-crimson-500);
|
||||||
|
--cc-action-success: var(--cc-signal-green-500);
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
--cc-selected: var(--cc-amber-100);
|
||||||
|
--cc-hover: var(--cc-parchment-200);
|
||||||
|
--cc-active: var(--cc-parchment-300);
|
||||||
|
--cc-disabled: var(--cc-navy-100);
|
||||||
|
|
||||||
|
/* Recording / live indicator */
|
||||||
|
--cc-recording-active: var(--cc-crimson-500);
|
||||||
|
--cc-recording-idle: var(--cc-signal-green-500);
|
||||||
|
|
||||||
|
/* ── Geometry ── */
|
||||||
|
--cc-radius-none: 0;
|
||||||
|
--cc-radius-xs: 1px;
|
||||||
|
--cc-radius-sm: 2px; /* maximum for shapes */
|
||||||
|
--cc-radius-md: 4px; /* panels, menus */
|
||||||
|
--cc-radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ── Spacing scale (8pt grid) ── */
|
||||||
|
--cc-space-0: 0;
|
||||||
|
--cc-space-px: 1px;
|
||||||
|
--cc-space-0_5: 2px;
|
||||||
|
--cc-space-1: 4px;
|
||||||
|
--cc-space-2: 8px;
|
||||||
|
--cc-space-3: 12px;
|
||||||
|
--cc-space-4: 16px;
|
||||||
|
--cc-space-5: 20px;
|
||||||
|
--cc-space-6: 24px;
|
||||||
|
--cc-space-8: 32px;
|
||||||
|
--cc-space-10: 40px;
|
||||||
|
--cc-space-12: 48px;
|
||||||
|
--cc-space-16: 64px;
|
||||||
|
|
||||||
|
/* ── Shape dimensions ── */
|
||||||
|
--cc-shape-header-h: 32px;
|
||||||
|
--cc-shape-header-pad: var(--cc-space-2);
|
||||||
|
--cc-shape-content-pad: var(--cc-space-2);
|
||||||
|
--cc-shape-min-w: 120px;
|
||||||
|
--cc-shape-min-h: 80px;
|
||||||
|
|
||||||
|
/* ── Panel dimensions ── */
|
||||||
|
--cc-panel-w: 280px;
|
||||||
|
--cc-panel-header-h: 44px;
|
||||||
|
--cc-panel-handle-w: 20px;
|
||||||
|
|
||||||
|
/* ── Borders ── */
|
||||||
|
--cc-border-width: 1px;
|
||||||
|
--cc-border-width-thick: 2px;
|
||||||
|
|
||||||
|
/* No blurry box shadows. Depth through tone. */
|
||||||
|
--cc-shadow-none: none;
|
||||||
|
--cc-shadow-edge: 1px 0 0 0 var(--cc-border);
|
||||||
|
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-200);
|
||||||
|
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-200);
|
||||||
|
|
||||||
|
/* ── Z-index system ── */
|
||||||
|
--cc-z-canvas: 0;
|
||||||
|
--cc-z-shape: 1;
|
||||||
|
--cc-z-panel: 200;
|
||||||
|
--cc-z-toolbar: 300;
|
||||||
|
--cc-z-menu: 400;
|
||||||
|
--cc-z-modal: 500;
|
||||||
|
--cc-z-toast: 600;
|
||||||
|
|
||||||
|
/* ── Motion ── */
|
||||||
|
--cc-transition-snap: 80ms linear;
|
||||||
|
--cc-transition-quick: 120ms ease-out;
|
||||||
|
--cc-transition-base: 200ms ease-out;
|
||||||
|
--cc-transition-slow: 320ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── DARK MODE TOKENS ──────────────────────────────────────── */
|
||||||
|
[data-color-mode="dark"] {
|
||||||
|
/* Surfaces */
|
||||||
|
--cc-bg-canvas: var(--cc-navy-950);
|
||||||
|
--cc-bg-panel: var(--cc-navy-900);
|
||||||
|
--cc-bg-panel-alt: var(--cc-navy-800);
|
||||||
|
--cc-bg-shape: var(--cc-navy-800);
|
||||||
|
--cc-bg-shape-hover: var(--cc-navy-700);
|
||||||
|
--cc-bg-overlay: rgba(13, 17, 23, 0.72);
|
||||||
|
--cc-bg-code: var(--cc-navy-950);
|
||||||
|
|
||||||
|
/* Shape headers get slightly brighter in dark */
|
||||||
|
--cc-header-default: var(--cc-navy-600);
|
||||||
|
--cc-header-accent: var(--cc-amber-600);
|
||||||
|
--cc-header-sage: var(--cc-sage-500);
|
||||||
|
--cc-header-dim: var(--cc-navy-700);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--cc-text-primary: var(--cc-parchment-100);
|
||||||
|
--cc-text-secondary: var(--cc-navy-200);
|
||||||
|
--cc-text-tertiary: var(--cc-navy-300);
|
||||||
|
--cc-text-inverse: var(--cc-navy-900);
|
||||||
|
--cc-text-accent: var(--cc-amber-400);
|
||||||
|
--cc-text-code: var(--cc-parchment-300);
|
||||||
|
--cc-text-link: var(--cc-amber-400);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--cc-border: var(--cc-navy-700);
|
||||||
|
--cc-border-strong: var(--cc-navy-600);
|
||||||
|
--cc-border-accent: var(--cc-amber-500);
|
||||||
|
--cc-border-focus: var(--cc-amber-300);
|
||||||
|
--cc-border-error: var(--cc-crimson-400);
|
||||||
|
--cc-border-success: var(--cc-signal-green-400);
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--cc-action-primary: var(--cc-amber-500);
|
||||||
|
--cc-action-primary-hover: var(--cc-amber-300);
|
||||||
|
--cc-action-primary-active: var(--cc-amber-600);
|
||||||
|
--cc-action-secondary: var(--cc-navy-400);
|
||||||
|
--cc-action-secondary-hover: var(--cc-navy-300);
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
--cc-selected: rgba(201, 136, 42, 0.15);
|
||||||
|
--cc-hover: var(--cc-navy-800);
|
||||||
|
--cc-active: var(--cc-navy-700);
|
||||||
|
--cc-disabled: var(--cc-navy-700);
|
||||||
|
|
||||||
|
/* Shadows become tone dividers in dark */
|
||||||
|
--cc-shadow-edge: 1px 0 0 0 var(--cc-navy-700);
|
||||||
|
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-950);
|
||||||
|
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── BASE RESET (minimal — respects tldraw's own base) ────── */
|
||||||
|
.cc-root * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: var(--cc-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-root code,
|
||||||
|
.cc-root pre,
|
||||||
|
.cc-mono {
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SHAPE CARD SYSTEM ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
.cc-shape-container
|
||||||
|
└── .cc-shape-header
|
||||||
|
└── .cc-shape-content
|
||||||
|
*/
|
||||||
|
|
||||||
|
.cc-shape-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: var(--cc-shape-header-h) 1fr;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--cc-bg-shape);
|
||||||
|
border: var(--cc-border-width) solid var(--cc-border);
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
/* Flat depth — no blur */
|
||||||
|
box-shadow: var(--cc-shadow-crisp);
|
||||||
|
transition: box-shadow var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-container:hover {
|
||||||
|
box-shadow: var(--cc-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-container--locked {
|
||||||
|
opacity: 0.72;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header strip — the primary identification band */
|
||||||
|
.cc-shape-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--cc-shape-header-pad);
|
||||||
|
height: var(--cc-shape-header-h);
|
||||||
|
background: var(--cc-header-default);
|
||||||
|
color: var(--cc-text-inverse);
|
||||||
|
/* Hairline rule below header, not shadow */
|
||||||
|
border-bottom: var(--cc-border-width) solid rgba(255,255,255,0.12);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent variant headers */
|
||||||
|
.cc-shape-header--accent { background: var(--cc-header-accent); color: var(--cc-navy-900); }
|
||||||
|
.cc-shape-header--sage { background: var(--cc-header-sage); }
|
||||||
|
.cc-shape-header--dim { background: var(--cc-header-dim); }
|
||||||
|
|
||||||
|
.cc-shape-header__title {
|
||||||
|
font-size: var(--cc-text-sm);
|
||||||
|
font-weight: var(--cc-weight-semibold);
|
||||||
|
letter-spacing: var(--cc-tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-header__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cc-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar buttons inside shape headers */
|
||||||
|
.cc-shape-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--cc-radius-xs);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
touch-action: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--cc-transition-snap),
|
||||||
|
background var(--cc-transition-snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-btn:active {
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-btn--active {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.cc-shape-content {
|
||||||
|
padding: var(--cc-shape-content-pad);
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--cc-bg-shape);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
font-size: var(--cc-text-base);
|
||||||
|
line-height: var(--cc-leading-normal);
|
||||||
|
/* Hide scrollbar visually, keep functionality */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--cc-border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-content::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--cc-border-strong);
|
||||||
|
border-radius: var(--cc-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── PANEL SYSTEM ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
.cc-panel
|
||||||
|
└── .cc-panel-header
|
||||||
|
└── .cc-panel-content
|
||||||
|
*/
|
||||||
|
|
||||||
|
.cc-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: var(--cc-panel-header-h) 1fr;
|
||||||
|
width: var(--cc-panel-w);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--cc-bg-panel);
|
||||||
|
border-right: var(--cc-border-width) solid var(--cc-border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--cc-z-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cc-space-2);
|
||||||
|
padding: 0 var(--cc-space-2);
|
||||||
|
height: var(--cc-panel-header-h);
|
||||||
|
background: var(--cc-bg-panel);
|
||||||
|
border-bottom: var(--cc-border-width) solid var(--cc-border-strong);
|
||||||
|
/* Left accent stripe — the editorial mark */
|
||||||
|
box-shadow: inset 2px 0 0 0 var(--cc-border-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--cc-space-2);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--cc-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-content::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--cc-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel tab / type selector */
|
||||||
|
.cc-panel-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cc-space-1);
|
||||||
|
padding: var(--cc-space-1) var(--cc-space-2);
|
||||||
|
background: transparent;
|
||||||
|
border: var(--cc-border-width) solid var(--cc-border);
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
font-family: var(--cc-font-sans);
|
||||||
|
font-size: var(--cc-text-sm);
|
||||||
|
font-weight: var(--cc-weight-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--cc-transition-quick),
|
||||||
|
background var(--cc-transition-quick);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-selector:hover {
|
||||||
|
border-color: var(--cc-border-strong);
|
||||||
|
background: var(--cc-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-selector__icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-selector__chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: transform var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-selector[aria-expanded="true"] .cc-panel-selector__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel collapse handle */
|
||||||
|
.cc-panel-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--cc-panel-handle-w);
|
||||||
|
height: 40px;
|
||||||
|
background: var(--cc-bg-panel);
|
||||||
|
border: var(--cc-border-width) solid var(--cc-border);
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 var(--cc-radius-md) var(--cc-radius-md) 0;
|
||||||
|
color: var(--cc-text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: var(--cc-z-panel);
|
||||||
|
transition:
|
||||||
|
color var(--cc-transition-quick),
|
||||||
|
background var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-panel-handle:hover {
|
||||||
|
background: var(--cc-hover);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pin button */
|
||||||
|
.cc-pin-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--cc-transition-quick),
|
||||||
|
background var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pin-btn:hover {
|
||||||
|
background: var(--cc-hover);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pin-btn--active {
|
||||||
|
color: var(--cc-action-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── PANEL MENU / DROPDOWN ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-menu {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--cc-bg-panel-alt);
|
||||||
|
border: var(--cc-border-width) solid var(--cc-border-strong);
|
||||||
|
border-radius: var(--cc-radius-md);
|
||||||
|
padding: var(--cc-space-1) 0;
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: var(--cc-z-menu);
|
||||||
|
/* Hard-edged drop — no blur */
|
||||||
|
box-shadow: 4px 4px 0 0 var(--cc-navy-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-color-mode="dark"] .cc-menu {
|
||||||
|
box-shadow: 4px 4px 0 0 var(--cc-navy-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--cc-space-2);
|
||||||
|
padding: var(--cc-space-2) var(--cc-space-3);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--cc-font-sans);
|
||||||
|
font-size: var(--cc-text-sm);
|
||||||
|
transition: background var(--cc-transition-snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item:hover {
|
||||||
|
background: var(--cc-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item--selected {
|
||||||
|
background: var(--cc-selected);
|
||||||
|
color: var(--cc-text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item__icon {
|
||||||
|
color: var(--cc-text-secondary);
|
||||||
|
transition: color var(--cc-transition-snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item:hover .cc-menu-item__icon,
|
||||||
|
.cc-menu-item--selected .cc-menu-item__icon {
|
||||||
|
color: var(--cc-action-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item__label {
|
||||||
|
font-weight: var(--cc-weight-medium);
|
||||||
|
line-height: var(--cc-leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-item__desc {
|
||||||
|
font-size: var(--cc-text-xs);
|
||||||
|
color: var(--cc-text-secondary);
|
||||||
|
line-height: var(--cc-leading-normal);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-menu-divider {
|
||||||
|
height: var(--cc-border-width);
|
||||||
|
background: var(--cc-border);
|
||||||
|
margin: var(--cc-space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── BUTTONS ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-btn {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--cc-space-1) var(--cc-space-3);
|
||||||
|
border: var(--cc-border-width) solid transparent;
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
font-family: var(--cc-font-sans);
|
||||||
|
font-size: var(--cc-text-sm);
|
||||||
|
font-weight: var(--cc-weight-medium);
|
||||||
|
letter-spacing: var(--cc-tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background var(--cc-transition-quick),
|
||||||
|
border-color var(--cc-transition-quick),
|
||||||
|
transform var(--cc-transition-snap),
|
||||||
|
box-shadow var(--cc-transition-snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn:active {
|
||||||
|
transform: translate(1px, 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--primary {
|
||||||
|
background: var(--cc-action-primary);
|
||||||
|
color: var(--cc-navy-900);
|
||||||
|
border-color: var(--cc-amber-600);
|
||||||
|
box-shadow: 2px 2px 0 0 var(--cc-amber-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--primary:hover {
|
||||||
|
background: var(--cc-action-primary-hover);
|
||||||
|
box-shadow: 3px 3px 0 0 var(--cc-amber-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--primary:active {
|
||||||
|
box-shadow: none;
|
||||||
|
background: var(--cc-action-primary-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
border-color: var(--cc-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--secondary:hover {
|
||||||
|
background: var(--cc-hover);
|
||||||
|
border-color: var(--cc-navy-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-action-danger);
|
||||||
|
border-color: var(--cc-border-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--danger:hover {
|
||||||
|
background: var(--cc-crimson-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn--icon:hover {
|
||||||
|
background: var(--cc-hover);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── INPUT / FORM ELEMENTS ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--cc-space-1) var(--cc-space-2);
|
||||||
|
background: var(--cc-bg-panel-alt);
|
||||||
|
border: var(--cc-border-width) solid var(--cc-border-strong);
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
font-family: var(--cc-font-sans);
|
||||||
|
font-size: var(--cc-text-base);
|
||||||
|
line-height: var(--cc-leading-normal);
|
||||||
|
transition: border-color var(--cc-transition-quick);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input::placeholder {
|
||||||
|
color: var(--cc-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input:focus {
|
||||||
|
border-color: var(--cc-border-focus);
|
||||||
|
box-shadow: 0 0 0 2px rgba(201,136,42,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input:disabled {
|
||||||
|
background: var(--cc-disabled);
|
||||||
|
color: var(--cc-text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── RECORDING / LIVE INDICATOR ────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-record-btn {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
transition:
|
||||||
|
background var(--cc-transition-quick),
|
||||||
|
transform var(--cc-transition-snap),
|
||||||
|
box-shadow var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-record-btn--idle {
|
||||||
|
background: var(--cc-recording-idle);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 2px 2px 0 0 var(--cc-signal-green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-record-btn--idle:hover {
|
||||||
|
background: var(--cc-signal-green-400);
|
||||||
|
box-shadow: 3px 3px 0 0 var(--cc-signal-green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-record-btn--active {
|
||||||
|
background: var(--cc-recording-active);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 2px 2px 0 0 var(--cc-crimson-600);
|
||||||
|
/* Pulsing border — CSS-only, no JS */
|
||||||
|
animation: cc-record-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cc-record-pulse {
|
||||||
|
0%,100% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 0 rgba(168,40,40,0.4); }
|
||||||
|
50% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 6px rgba(168,40,40,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-record-btn--active:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status text accompanying record button */
|
||||||
|
.cc-record-status {
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: var(--cc-text-sm);
|
||||||
|
font-weight: var(--cc-weight-medium);
|
||||||
|
letter-spacing: var(--cc-tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-record-status--idle { color: var(--cc-recording-idle); }
|
||||||
|
.cc-record-status--active { color: var(--cc-recording-active); }
|
||||||
|
|
||||||
|
/* ─── TRANSCRIPT SEGMENTS ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-transcript-segment {
|
||||||
|
padding: var(--cc-space-2);
|
||||||
|
border-left: 2px solid var(--cc-border);
|
||||||
|
margin-bottom: var(--cc-space-1);
|
||||||
|
transition: border-color var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-transcript-segment:hover {
|
||||||
|
border-color: var(--cc-border-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-transcript-segment--current {
|
||||||
|
border-color: var(--cc-action-primary);
|
||||||
|
background: var(--cc-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-transcript-timestamp {
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: var(--cc-text-xs);
|
||||||
|
color: var(--cc-text-tertiary);
|
||||||
|
letter-spacing: var(--cc-tracking-wide);
|
||||||
|
margin-bottom: var(--cc-space-0_5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-transcript-text {
|
||||||
|
font-size: var(--cc-text-base);
|
||||||
|
color: var(--cc-text-primary);
|
||||||
|
line-height: var(--cc-leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── GRAPH NODE CARDS ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-graph-node {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: var(--cc-shape-header-h) 1fr;
|
||||||
|
border: var(--cc-border-width) solid var(--cc-border);
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--cc-bg-shape);
|
||||||
|
box-shadow: var(--cc-shadow-crisp);
|
||||||
|
transition: box-shadow var(--cc-transition-quick);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-graph-node:hover {
|
||||||
|
box-shadow: var(--cc-shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node type label pill */
|
||||||
|
.cc-node-type-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px var(--cc-space-1);
|
||||||
|
border: var(--cc-border-width) solid currentColor;
|
||||||
|
border-radius: var(--cc-radius-xs);
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: var(--cc-text-xs);
|
||||||
|
font-weight: var(--cc-weight-medium);
|
||||||
|
letter-spacing: var(--cc-tracking-widest);
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SECTION / DIVIDER TYPOGRAPHY ─────────────────────────── */
|
||||||
|
|
||||||
|
.cc-section-label {
|
||||||
|
font-size: var(--cc-text-xs);
|
||||||
|
font-weight: var(--cc-weight-semibold);
|
||||||
|
letter-spacing: var(--cc-tracking-widest);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--cc-text-tertiary);
|
||||||
|
padding-bottom: var(--cc-space-1);
|
||||||
|
border-bottom: var(--cc-border-width) solid var(--cc-border);
|
||||||
|
margin-bottom: var(--cc-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-divider {
|
||||||
|
height: var(--cc-border-width);
|
||||||
|
background: var(--cc-border);
|
||||||
|
margin: var(--cc-space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── LOADING STATES ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--cc-border-strong);
|
||||||
|
border-top-color: var(--cc-action-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: cc-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cc-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--cc-border) 25%,
|
||||||
|
var(--cc-hover) 50%,
|
||||||
|
var(--cc-border) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: cc-shimmer 1.4s ease-in-out infinite;
|
||||||
|
border-radius: var(--cc-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cc-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TOASTS / ALERTS ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-alert {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--cc-space-2);
|
||||||
|
padding: var(--cc-space-2) var(--cc-space-3);
|
||||||
|
border: var(--cc-border-width) solid;
|
||||||
|
border-radius: var(--cc-radius-sm);
|
||||||
|
font-size: var(--cc-text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-alert--error {
|
||||||
|
background: var(--cc-crimson-100);
|
||||||
|
border-color: var(--cc-border-error);
|
||||||
|
color: var(--cc-crimson-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-alert--success {
|
||||||
|
background: var(--cc-signal-green-100);
|
||||||
|
border-color: var(--cc-border-success);
|
||||||
|
color: var(--cc-signal-green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-color-mode="dark"] .cc-alert--error {
|
||||||
|
background: rgba(168,40,40,0.12);
|
||||||
|
color: var(--cc-crimson-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-color-mode="dark"] .cc-alert--success {
|
||||||
|
background: rgba(36,107,66,0.12);
|
||||||
|
color: var(--cc-signal-green-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── UTILITY CLASSES ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-font-mono { font-family: var(--cc-font-mono); }
|
||||||
|
.cc-text-xs { font-size: var(--cc-text-xs); }
|
||||||
|
.cc-text-sm { font-size: var(--cc-text-sm); }
|
||||||
|
.cc-text-base { font-size: var(--cc-text-base); }
|
||||||
|
|
||||||
|
.cc-text-primary { color: var(--cc-text-primary); }
|
||||||
|
.cc-text-secondary { color: var(--cc-text-secondary); }
|
||||||
|
.cc-text-tertiary { color: var(--cc-text-tertiary); }
|
||||||
|
.cc-text-accent { color: var(--cc-text-accent); }
|
||||||
|
|
||||||
|
.cc-uppercase { text-transform: uppercase; letter-spacing: var(--cc-tracking-wide); }
|
||||||
|
|
||||||
|
.cc-border-accent { border-left: 2px solid var(--cc-border-accent); }
|
||||||
|
.cc-border-top { border-top: var(--cc-border-width) solid var(--cc-border); }
|
||||||
|
.cc-border-bottom { border-bottom: var(--cc-border-width) solid var(--cc-border); }
|
||||||
|
|
||||||
|
.cc-sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── GRID LAYOUT HELPERS ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cc-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--cc-space-2); }
|
||||||
|
.cc-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--cc-space-2); }
|
||||||
|
.cc-stack { display: grid; gap: var(--cc-space-2); }
|
||||||
|
.cc-stack-sm { display: grid; gap: var(--cc-space-1); }
|
||||||
|
|
||||||
|
/* ─── FOCUS VISIBLE ──────────────────────────────────────────── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--cc-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--cc-radius-xs);
|
||||||
|
}
|
||||||
@@ -1,2 +1,12 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap");
|
|
||||||
@import url("@tldraw/tldraw/tldraw.css");
|
@import url("@tldraw/tldraw/tldraw.css");
|
||||||
|
@import url('./cc-design-system.css');
|
||||||
|
|
||||||
|
.tl-container {
|
||||||
|
--color-panel: var(--cc-bg-panel);
|
||||||
|
--color-text: var(--cc-text-primary);
|
||||||
|
--color-muted: var(--cc-border);
|
||||||
|
--color-muted-1: var(--cc-border-strong);
|
||||||
|
--color-background: var(--cc-bg-canvas);
|
||||||
|
--color-selected: var(--cc-selected);
|
||||||
|
--color-accent: var(--cc-action-primary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BasePanel } from './shared/BasePanel';
|
import { BasePanel } from './shared/BasePanel';
|
||||||
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
|
|
||||||
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
||||||
|
|
||||||
interface CCPanelProps {
|
interface CCPanelProps {
|
||||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
@@ -12,7 +10,6 @@ interface CCPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||||
examMarkerProps,
|
|
||||||
isExpanded,
|
isExpanded,
|
||||||
isPinned,
|
isPinned,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
@@ -32,7 +29,6 @@ export const CCPanel: React.FC<CCPanelProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
examMarkerProps={examMarkerProps}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
onExpandedChange={handleExpandedChange}
|
onExpandedChange={handleExpandedChange}
|
||||||
|
|||||||
@@ -1,41 +1,14 @@
|
|||||||
import React, { useEffect, useRef, useMemo } 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 {
|
|
||||||
Button,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
styled,
|
|
||||||
ThemeProvider,
|
|
||||||
createTheme,
|
|
||||||
useMediaQuery
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
PushPin as PushPinIcon,
|
|
||||||
PushPinOutlined as PushPinOutlinedIcon,
|
|
||||||
ExpandMore as ExpandMoreIcon,
|
|
||||||
Category as ShapesIcon,
|
|
||||||
Slideshow as SlidesIcon,
|
|
||||||
YouTube as YouTubeIcon,
|
|
||||||
AccountTree as GraphIcon,
|
|
||||||
Search as SearchIcon,
|
|
||||||
Navigation as NavigationIcon,
|
|
||||||
Save as NodeIcon,
|
|
||||||
Assignment as ExamIcon,
|
|
||||||
Mic as MicIcon
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import { CCShapesPanel } from './CCShapesPanel';
|
import { CCShapesPanel } from './CCShapesPanel';
|
||||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||||
import { CCFilesPanel } from './CCFilesPanel';
|
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'
|
|
||||||
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
||||||
import './panel.css';
|
import './panel.css';
|
||||||
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
|
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
|
||||||
@@ -56,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;
|
||||||
@@ -78,49 +47,95 @@ interface BasePanelProps {
|
|||||||
onMenuOpenChange?: (open: boolean) => void;
|
onMenuOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PanelTypeButton = styled(Button)(() => ({
|
function getIconSvg(panelId: PanelType, size = '1.25rem') {
|
||||||
textTransform: 'none',
|
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||||||
padding: '6px 12px',
|
switch (panelId) {
|
||||||
gap: '8px',
|
case 'cabinets':
|
||||||
backgroundColor: 'var(--color-panel)',
|
return (
|
||||||
color: 'var(--color-text)',
|
<svg {...common}>
|
||||||
border: '1px solid transparent',
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
transition: 'border-color 200ms ease',
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
justifyContent: 'space-between',
|
</svg>
|
||||||
minWidth: '200px',
|
);
|
||||||
'&:hover': {
|
case 'transcription':
|
||||||
backgroundColor: 'var(--color-panel)',
|
return (
|
||||||
borderColor: 'var(--color-text)',
|
<svg {...common}>
|
||||||
},
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
'& .MuiSvgIcon-root': {
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
fontSize: '1.25rem',
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
color: 'inherit',
|
<line x1="8" y1="23" x2="6" y2="21" />
|
||||||
|
<line x1="16" y1="23" x2="14" y2="21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'cc-shapes':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'slides':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="2" y="3" width="13" height="18" rx="2" />
|
||||||
|
<path d="M15 8h4l3 3v7h-7V8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'youtube':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="4" />
|
||||||
|
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'graph':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'search':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'navigation':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<polygon points="3 11 22 2 13 21 11 13 3 11" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'node-snapshot':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledMenuItem = styled(MenuItem)(() => ({
|
function isDarkMode() {
|
||||||
gap: '8px',
|
if (typeof window === 'undefined') return false;
|
||||||
padding: '8px 16px',
|
return document.documentElement.dataset.colorMode === 'dark' ||
|
||||||
transition: 'background-color 200ms ease',
|
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--color-hover)',
|
|
||||||
'& .MuiListItemIcon-root': {
|
|
||||||
color: 'var(--color-selected)',
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
'& .MuiListItemIcon-root': {
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
minWidth: '32px',
|
|
||||||
transition: 'color 200ms ease',
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
fontSize: '1.25rem',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -128,38 +143,28 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
isMenuOpen = false,
|
isMenuOpen = false,
|
||||||
onMenuOpenChange = () => {},
|
onMenuOpenChange = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
|
||||||
const { tldrawPreferences } = useTLDraw();
|
const { tldrawPreferences } = useTLDraw();
|
||||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
||||||
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Create a dynamic theme based on TLDraw preferences
|
useEffect(() => {
|
||||||
const theme = useMemo(() => {
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
let mode: 'light' | 'dark';
|
const handler = () => setPrefersDarkMode(isDarkMode());
|
||||||
|
mq.addEventListener?.('change', handler);
|
||||||
|
return () => mq.removeEventListener?.('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (tldrawPreferences?.colorScheme === 'system') {
|
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
|
||||||
mode = prefersDarkMode ? 'dark' : 'light';
|
? 'dark'
|
||||||
} else {
|
: 'light';
|
||||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
return createTheme({
|
const availablePanels = PANEL_TYPES.default;
|
||||||
palette: {
|
|
||||||
mode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
|
||||||
|
|
||||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
|
||||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
|
||||||
|
|
||||||
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
|
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
||||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
||||||
);
|
|
||||||
|
|
||||||
// Use controlled state if provided, otherwise use internal state
|
|
||||||
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
|
|
||||||
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
|
|
||||||
|
|
||||||
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
||||||
const isPinned = controlledIsPinned ?? internalIsPinned;
|
const isPinned = controlledIsPinned ?? internalIsPinned;
|
||||||
@@ -178,92 +183,25 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
|
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
|
||||||
|
|
||||||
// Handle click outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
// Don't close if pinned
|
|
||||||
if (isPinned) return;
|
if (isPinned) return;
|
||||||
|
|
||||||
// Check if click is outside panel
|
|
||||||
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
|
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
|
||||||
|
|
||||||
// Check if click is not on a panel-related element
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
|
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
|
||||||
|
|
||||||
if (isClickOutside && !isPanelElement) {
|
if (isClickOutside && !isPanelElement) {
|
||||||
handleExpandedChange(false);
|
handleExpandedChange(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isExpanded, isPinned]);
|
}, [isExpanded, isPinned]);
|
||||||
|
|
||||||
const getIconForPanel = (panelId: PanelType) => {
|
|
||||||
switch (panelId) {
|
|
||||||
case 'cabinets':
|
|
||||||
return <NavigationIcon />;
|
|
||||||
case 'cc-shapes':
|
|
||||||
return <ShapesIcon />;
|
|
||||||
case 'transcription':
|
|
||||||
return <MicIcon />;
|
|
||||||
case 'slides':
|
|
||||||
return <SlidesIcon />;
|
|
||||||
case 'youtube':
|
|
||||||
return <YouTubeIcon />;
|
|
||||||
case 'graph':
|
|
||||||
return <GraphIcon />;
|
|
||||||
case 'search':
|
|
||||||
return <SearchIcon />;
|
|
||||||
case 'navigation':
|
|
||||||
return <NavigationIcon />;
|
|
||||||
case 'node-snapshot':
|
|
||||||
return <NodeIcon />;
|
|
||||||
case 'exam-marker':
|
|
||||||
return <ExamIcon />;
|
|
||||||
default:
|
|
||||||
return <ShapesIcon />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDescriptionForPanel = (panelId: PanelType) => {
|
|
||||||
switch (panelId) {
|
|
||||||
case 'cabinets':
|
|
||||||
return 'Manage file cabinets';
|
|
||||||
case 'transcription':
|
|
||||||
return 'Record and transcribe lessons';
|
|
||||||
case 'cc-shapes':
|
|
||||||
return 'Add shapes and elements to your canvas';
|
|
||||||
case 'slides':
|
|
||||||
return 'Manage presentation slides';
|
|
||||||
case 'youtube':
|
|
||||||
return 'Embed YouTube videos';
|
|
||||||
case 'graph':
|
|
||||||
return 'View and manage graph connections';
|
|
||||||
case 'search':
|
|
||||||
return 'Search through your content';
|
|
||||||
case 'navigation':
|
|
||||||
return 'Navigate through different contexts';
|
|
||||||
case 'node-snapshot':
|
|
||||||
return 'Manage node snapshots';
|
|
||||||
case 'exam-marker':
|
|
||||||
return 'Mark and grade exams';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 />;
|
||||||
@@ -288,16 +226,9 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle menu button click
|
const toggleMenu = (open: boolean) => {
|
||||||
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
setMenuOpen(open);
|
||||||
setMenuAnchorEl(event.currentTarget);
|
onMenuOpenChange(open);
|
||||||
onMenuOpenChange(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle menu close
|
|
||||||
const handleMenuClose = () => {
|
|
||||||
setMenuAnchorEl(null);
|
|
||||||
onMenuOpenChange(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -327,55 +258,60 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<ThemeProvider theme={theme}>
|
<button
|
||||||
<PanelTypeButton
|
type="button"
|
||||||
onClick={handleMenuClick}
|
className="cc-btn cc-btn--secondary cc-panel-selector"
|
||||||
endIcon={<ExpandMoreIcon />}
|
onClick={() => toggleMenu(!menuOpen)}
|
||||||
startIcon={getIconForPanel(currentPanelType)}
|
|
||||||
>
|
>
|
||||||
|
<span className="cc-btn-start-icon">
|
||||||
|
{getIconSvg(currentPanelType)}
|
||||||
|
</span>
|
||||||
|
<span className="cc-btn-label">
|
||||||
{availablePanels.find(p => p.id === currentPanelType)?.label}
|
{availablePanels.find(p => p.id === currentPanelType)?.label}
|
||||||
</PanelTypeButton>
|
</span>
|
||||||
|
<span className="cc-btn-end-icon">
|
||||||
|
<svg width="1.25rem" height="1.25rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Menu
|
{menuOpen && (
|
||||||
anchorEl={menuAnchorEl}
|
<div className="cc-menu panel-type-menu" role="menu">
|
||||||
open={isMenuOpen}
|
|
||||||
onClose={handleMenuClose}
|
|
||||||
PaperProps={{
|
|
||||||
elevation: 8,
|
|
||||||
sx: {
|
|
||||||
border: '1px solid var(--color-divider)',
|
|
||||||
boxShadow: 'var(--shadow-popup)',
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[...availablePanels]
|
{[...availablePanels]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map(type => (
|
.map(type => (
|
||||||
<StyledMenuItem
|
<button
|
||||||
key={type.id}
|
key={type.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={`cc-menu-item ${type.id === currentPanelType ? 'cc-menu-item--active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPanelType(type.id as PanelType);
|
setCurrentPanelType(type.id as PanelType);
|
||||||
handleMenuClose();
|
toggleMenu(false);
|
||||||
}}
|
}}
|
||||||
selected={currentPanelType === type.id}
|
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<span className="cc-menu-item-icon">
|
||||||
{getIconForPanel(type.id as PanelType)}
|
{getIconSvg(type.id as PanelType)}
|
||||||
</ListItemIcon>
|
</span>
|
||||||
<ListItemText
|
<span className="cc-menu-item-text">
|
||||||
primary={type.label}
|
<span className="cc-menu-item-primary">{type.label}</span>
|
||||||
secondary={getDescriptionForPanel(type.id as PanelType)}
|
<span className="cc-menu-item-secondary">
|
||||||
primaryTypographyProps={{
|
{type.id === 'cabinets' && 'Manage file cabinets'}
|
||||||
sx: { color: 'var(--color-text)' }
|
{type.id === 'transcription' && 'Record and transcribe lessons'}
|
||||||
}}
|
{type.id === 'cc-shapes' && 'Add shapes and elements to your canvas'}
|
||||||
secondaryTypographyProps={{
|
{type.id === 'slides' && 'Manage presentation slides'}
|
||||||
sx: { color: 'var(--color-text-secondary)' }
|
{type.id === 'youtube' && 'Embed YouTube videos'}
|
||||||
}}
|
{type.id === 'graph' && 'View and manage graph connections'}
|
||||||
/>
|
{type.id === 'search' && 'Search through your content'}
|
||||||
</StyledMenuItem>
|
{type.id === 'navigation' && 'Navigate through different contexts'}
|
||||||
|
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</div>
|
||||||
</ThemeProvider>
|
)}
|
||||||
|
|
||||||
<div className="panel-header-actions">
|
<div className="panel-header-actions">
|
||||||
<TldrawUiButton
|
<TldrawUiButton
|
||||||
@@ -383,16 +319,36 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
onClick={handlePinToggle}
|
onClick={handlePinToggle}
|
||||||
className="pin-button"
|
className="pin-button"
|
||||||
>
|
>
|
||||||
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
|
<span className="cc-icon-button">
|
||||||
|
<svg
|
||||||
|
width="1.25rem"
|
||||||
|
height="1.25rem"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{isPinned ? (
|
||||||
|
<>
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M7.5 7.5l9 9" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</TldrawUiButton>
|
</TldrawUiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<div className="panel-content">
|
<div className="panel-content">
|
||||||
{renderCurrentPanel()}
|
{renderCurrentPanel()}
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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], { 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], { 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -346,9 +346,10 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
|||||||
ref={dirInputRef}
|
ref={dirInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
webkitdirectory=""
|
|
||||||
multiple
|
multiple
|
||||||
onChange={handleFallbackDirectorySelect}
|
onChange={handleFallbackDirectorySelect}
|
||||||
|
// @ts-ignore WebKit directory picker attribute for folder upload
|
||||||
|
webkitdirectory=""
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
|
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
import React, { useState, useEffect, useCallback, createContext, useContext, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
|
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
|
||||||
ToggleButtonGroup, ToggleButton,
|
ToggleButtonGroup, ToggleButton,
|
||||||
@@ -518,7 +518,7 @@ export function CCGraphNavPanel() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setTree(data.tree);
|
setTree(data.tree);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('graph-nav-panel', 'Failed to load graph tree', err);
|
logger.error('navigation-panel', 'Failed to load graph tree', err);
|
||||||
setError('Failed to load navigation tree');
|
setError('Failed to load navigation tree');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -650,12 +650,12 @@ export function CCGraphNavPanel() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCalendarWizardComplete = useCallback(() => {
|
const handleCalendarWizardComplete = useCallback(() => {
|
||||||
logger.info('graph-nav-panel', 'School calendar setup complete');
|
logger.info('navigation-panel', 'School calendar setup complete');
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
|
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
|
||||||
logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
|
logger.info('navigation-panel', 'Teacher timetable setup complete', { timetableId });
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
||||||
import Save from '@mui/icons-material/Save';
|
import Save from '@mui/icons-material/Save';
|
||||||
import Reset from '@mui/icons-material/RestartAlt';
|
import { RestartAlt } from '@mui/icons-material';
|
||||||
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
||||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
|
|||||||
@@ -269,3 +269,65 @@
|
|||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CC Base Shape Styles */
|
||||||
|
.cc-shape-container {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px var(--color-muted-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: all;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--shape-header-color, var(--color-muted));
|
||||||
|
padding: 8px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-header .shape-title {
|
||||||
|
color: var(--cc-text-inverse);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-toolbar button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: all;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
z-index: 100;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-shape-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
@@ -117,6 +117,20 @@ export default defineConfig(({ mode }) => {
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: mode === 'development',
|
sourcemap: mode === 'development',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'vendor-tldraw': ['@tldraw/tldraw', '@tldraw/state', '@tldraw/store'],
|
||||||
|
'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
|
||||||
|
'vendor-supabase': ['@supabase/supabase-js'],
|
||||||
|
'vendor-calendar': ['@fullcalendar/core', '@fullcalendar/daygrid', '@fullcalendar/interaction',
|
||||||
|
'@fullcalendar/list', '@fullcalendar/multimonth', '@fullcalendar/react',
|
||||||
|
'@fullcalendar/timegrid'],
|
||||||
|
'vendor-xyflow': ['@xyflow/react'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user