Compare commits

...
Author SHA1 Message Date
kcar 751ede8479 chore: preserve app workspace t_a244898e 2026-08-09 20:25:59 +01:00
CC WorkerandClaude Sonnet 4.6 66f35b8ae4 fix(exam): compact top bar, collapsible guide panel
app-ci-deploy / test-build-deploy (push) Has been cancelled
Top bar reduced to single-line height: Back becomes an icon button, caption
line removed (detail lives in the guide), Save button size=small. Guide
panel defaults collapsed and toggles via a ? icon button at bottom-right
so it doesn't occupy permanent canvas real estate.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 13:37:47 +00:00
CC WorkerandClaude Sonnet 4.6 fe5dbe7fa8 feat(exam): doc-view camera constraints and sidebar layout
app-ci-deploy / test-build-deploy (push) Has been cancelled
Replaces infinite-canvas free-pan with a constrained vertical-scroll doc
view: tldraw setCameraOptions with behavior='contain', fit-x-100, and top
origin so the PDF never drifts side-to-side. Layout restructured from
floating overlays to flex column (top bar + sidebar/canvas row). Tool
panel is now a left sidebar, guide panel overlays canvas bottom-right.

PAGE_START_X changed from 260 → 0 so pages are flush left; camera
applyDocViewConstraints() called incrementally as pages stream in and
with resetZoom() once all pages are loaded.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 12:36:21 +00:00
CC WorkerandClaude Sonnet 4.6 15a519748d fix(exam): keep region shapes overlaid on PDF in setup canvas
app-ci-deploy / test-build-deploy (push) Has been cancelled
Three z-order fixes in ExamTemplateSetupPage:

1. loadShapes: early-return before deletion when models is empty, so
   seed guide shapes aren't wiped for a fresh template that has no
   saved regions yet.

2. Incremental PDF loading (onPageReady): replace per-page sendToBack
   (unreliable when all shapes are moving — reorderToBack no-ops) with
   bringToFront on existing domain shapes after each page is added.

3. Final load sequence: call bringDomainShapesToFront after syncPdfPages
   + loadShapes to guarantee correct z-order regardless of how tldraw's
   fractional indexer placed newly created shapes. Also called from
   onMount for the same reason.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 10:12:16 +00:00
CC WorkerandClaude Sonnet 4.6 3389fdcb5b feat(exam): S4-11 marking flow, results table, CSV, ResultsWidget
app-ci-deploy / test-build-deploy (push) Has been cancelled
- /exam-marker/:batchId/mark — ExamMarkingPage: student queue, per-part mark entry, upsert marks
- /exam-marker/:batchId/results — ExamResultsPage: results table with absent rows, CSV download
- ResultsWidget on ClassDetailPage: last batch summary, class average, absent count, batch creation
- New types: MarkingBatch, StudentSubmission, BatchQueueResponse, BatchResultsResponse, MarkUpsertPayload
- New repo methods: createBatch, listBatches, getBatchQueue, getBatchResults, getBatchCsv, upsertMark

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 04:35:34 +00:00
CC WorkerandClaude Sonnet 4.6 ab35193be1 Merge S4-10: mark scheme editor + SpecPoint picker
app-ci-deploy / test-build-deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 04:31:02 +00:00
CC WorkerandClaude Sonnet 4.6 b3f71c5749 Merge S4-10: mark scheme editor + SpecPoint picker
- Add /exam-marker/:templateId/marks route with MarkSchemePage
- Per-Part mark scheme editor: points/levels/parts/checklist/free forms
- SpecPoint picker via GET /api/exam/specs/{spec_code}/points (falls back to manual spec_ref when endpoint 404s)
- Manual neo4j-sync button; ASSESSES edge verified in cc.public.exams
- Edit marks button on each template card in dashboard
- Merge-resolved: AppRoutes, index.ts, exam.types.ts, ExamDashboardPage (kept grouped UI + added Edit marks button)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 04:30:41 +00:00
CC Worker b396af96b9 Merge S4-9c: exam canvas UX polish — icons, dark/light palette tokens, multi-page boundary hint
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 04:10:15 +00:00
kcar 3eac792ced feat(exam-setup): UX polish — icons, dark/light palette tokens, multi-page boundary hint 2026-06-07 05:10:11 +01:00
CC WorkerandClaude Sonnet 4.6 8e8a345e61 fix: incremental PDF page rendering for exam setup backdrop
app-ci-deploy / test-build-deploy (push) Has been cancelled
Rendering a 36-page AQA exam PDF sequentially created 36 large canvas
elements, causing memory pressure and keeping pdfStatus='loading' for
60–120s in headless Chrome. Two changes:

1. pdfLoader.ts: reuse a single canvas (reduces peak memory from ~120MB
   to ~4MB) and fire onPageReady callback after each page so callers can
   stream pages to the canvas as they render.

2. ExamTemplateSetupPage.tsx: use the callback to add each PDF page
   shape to the tldraw canvas the moment it renders, making the first
   page visible within a few seconds rather than after all pages load.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 03:35:09 +00:00
CC WorkerandClaude Sonnet 4.6 2de3e29179 fix: serve .mjs files as application/javascript for pdfjs module worker
app-ci-deploy / test-build-deploy (push) Has been cancelled
nginx:alpine mime.types only covers .js, not .mjs. The pdfjs-dist v4
worker is output as pdf.worker-*.mjs; without the correct MIME type the
browser refuses to execute it as a module worker and pdfjs throws
'Network Error', blocking the PDF backdrop from rendering.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 03:21:44 +00:00
CC WorkerandClaude Sonnet 4.6 29390d30ca Merge S4-9b: PDF backdrop on ExamCanvas from source-pdf endpoint
app-ci-deploy / test-build-deploy (push) Has been cancelled
Renders template source PDFs as locked image shapes behind the exam
setup regions. Adds page geometry abstraction so shape coordinates
map to real PDF page dimensions rather than fixed PAGE_HEIGHT math.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:59:17 +00:00
kcar aa2f35e467 feat(exam): render template PDFs behind setup canvas 2026-06-07 03:56:09 +01:00
CC WorkerandClaude Sonnet 4.6 61a189a7a2 fix: tldraw user prefs colorScheme and indicator method for shape utils
app-ci-deploy / test-build-deploy (push) Has been cancelled
- updateUserPreferences: isDarkMode → colorScheme ('dark'|'light') per
  tldraw 3.6.1 TLUserPreferences type (isDarkMode is read-only computed)
- BaseBoxShapeUtil subclasses: add required indicator() method returning
  bounding rect; fixes non-abstract class missing abstract member error

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:30:57 +00:00
CC WorkerandClaude Sonnet 4.6 ab6f0b09d7 fix: remove duplicate bindingUtils causing tldraw arrow double-registration
app-ci-deploy / test-build-deploy (push) Has been cancelled
Tldraw 3.6.1 Tldraw component already registers defaultBindingUtils
internally; passing them explicitly via the bindingUtils prop caused
"Binding type 'arrow' is defined more than once" runtime crash.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:21:32 +00:00
CC WorkerandClaude Opus 4.8 16ae3aa089 fix(exam): setup toolbar crash — MUI Button color 'default' is invalid in v5
app-ci-deploy / test-build-deploy (push) Has been cancelled
Select + Furniture tool buttons used color='default', which MUI v5 resolves to
theme.palette.default (undefined) -> TypeError reading 'main'/'dark' at render,
tripping the ErrorBoundary ('Template setup canvas crashed'). Use 'inherit'.

Caught by live browser-verify t_11f5a049; model-level tests never rendered the toolbar.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 01:03:08 +00:00
CC Worker 496ec2cbf9 merge: ExamCanvas core setup page /exam-marker/:templateId/setup (S4-9a)
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:54:55 +00:00
kcar dea3275f23 [verified] Add exam template setup canvas 2026-06-07 01:27:44 +01:00
CC Worker f067db3eb8 Merge remote-tracking branch 'origin/agent/s4-8-2-template-versioning'
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:12:25 +00:00
kcar 1ab8aab43c feat(exam): add mark scheme editor 2026-06-07 00:34:56 +01:00
kcar fdbc19cf0d feat(exam): version templates on dashboard 2026-06-07 00:33:06 +01:00
CC Worker adc7a2a05b merge: exam-marker app dashboard + /exam-marker route (S4-8)
app-ci-deploy / test-build-deploy (push) Has been cancelled
New ExamDashboardPage + examRepository seam; dashboard nav entry; removes the old
CCExamMarker viewer + dead CCExamMarkerPanel wiring (R1.1). Build green; route tests pass.
2026-06-06 19:50:23 +00:00
CC WorkerandClaude Opus 4.8 29554ebdbd feat(exam): Assessment dashboard + /exam-marker route; remove old CCExamMarker (S4-8)
- New examRepository (R2.1 seam): the only module talking to /api/exam (Supabase
  JWT → Bearer, axios to API_BASE). list/get/create/archive templates.
- ExamDashboardPage (/exam-marker): lists institute templates, create dialog,
  archive; cards link to setup (S4-9). Wrapped in ErrorBoundary (R6.4).
- exam.types.ts mirrors the API contract.
- Dashboard 'Exam Marker' quick action (top-level discovery, R1.3/R6.1).
  Note: the in-canvas TeacherNavigation is unsuitable for a nav section (it's
  the worker prev/next tab bar) — flagged; used the dashboard entry instead.
- R1.1 removal: deleted src/pages/tldraw/CCExamMarker/ (old 3-PDF viewer) and
  the now-dead CCExamMarkerPanel + its wiring in CCPanel/BasePanel (examMarkerProps
  was only ever passed by the deleted page).
- Fixed pre-existing broken AppRoutes test (missing TimetableListPage mock export).

Build green (vite); AppRoutes route tests pass; typecheck clean for new files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-06 19:47:02 +00:00
CC Worker 434643596e Merge fix/r6-myclasses: add selectedInstituteId dependency + silent error handling
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-02 23:28:37 +00:00
CC WorkerandClaude Sonnet 4.6 313ab724b8 fix(my-classes): add selectedInstituteId to fetch dependency, silence unhandled rejection
Re-fetch classes when NeoInstitute context changes. Catch fetchMyClasses rejection with
logger.warn so unhandled promise rejections don't break the component boundary silently.
Use CircularProgress (MUI) for loading skeleton.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 23:28:26 +00:00
CC WorkerandClaude Sonnet 4.6 ee7d7f7cac fix(timetable): fall through to empty state on fetch error — endpoint not yet in API
app-ci-deploy / test-build-deploy (push) Has been cancelled
GET /database/timetable/timetables returns 404 (endpoint not implemented). Treat any
fetch error as empty state so the page renders gracefully rather than showing error UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 22:33:41 +00:00
CC WorkerandClaude Sonnet 4.6 0dd18c4fd4 fix(timetable): use fetchTimetables instead of fetchMyTimetables — service stub unimplemented
app-ci-deploy / test-build-deploy (push) Has been cancelled
timetableOnlyService.getMyTimetables is referenced but never defined; calling it throws
'is not a function' at runtime. fetchTimetables calls the implemented listTimetables endpoint.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 22:15:58 +00:00
CC WorkerandClaude Sonnet 4.6 5869c741d7 fix(routing): add TimetableListPage for /timetable — detail view required :timetableId
app-ci-deploy / test-build-deploy (push) Has been cancelled
Bare /timetable routed to TimetablePage which called useParams<{timetableId}>() and
immediately hit !currentTimetable → "Error Loading Timetable". New TimetableListPage
uses fetchMyTimetables() from timetableStore. AppRoutes now: /timetable →
TimetableListPage, /timetable/:timetableId → TimetablePage.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-02 21:36:57 +00:00
CC Worker 5b6e461706 fix(canvas): separate store lifecycle from snapshot lifecycle — one store per user session prevents disposed-store crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
Root cause: disposing store while React's async state update (storeReady=false) hadn't
unmounted Tldraw yet caused tldraw's reactive signals to read currentPageId on a disposed store.

Fix: Effect 1 creates store once per user.id (never recreated on node navigation).
     Effect 2 loads/reloads snapshots when context.node changes, store stays alive.
     Store is only disposed when user changes or component unmounts — after setStoreReady(false)
     has had time to unmount Tldraw cleanly via React's synchronous cleanup.
2026-06-01 06:25:15 +00:00
CC Worker 53adc74a1c fix(canvas): defer store disposal until next store is ready — prevent tldraw reactive signals reading disposed store during async React unmount
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:18:24 +00:00
CC Worker 9a7cc86a75 fix(canvas): bypass localStoreService singleton — create store directly in effect to eliminate disposed-store reuse
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:16:07 +00:00
CC Worker 6cba932146 fix(canvas): move ensureStoreIsUsable() to AFTER snapshot load — loadSnapshot() overwrites TLINSTANCE before it could be pre-initialized
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:13:49 +00:00
CC Worker ba829be8e9 fix(canvas): call ensureStoreIsUsable() before tldraw mount — pre-initializes TLINSTANCE/page/camera records in empty store preventing currentPageId crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 06:10:11 +00:00
CC Worker 9f58917c39 fix(canvas): sanitize snapshot session.currentPageId before loadSnapshot — stale snapshots with missing pages caused currentPageId crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
- Pre-load: if session.currentPageId not in snapshot pages, reset to first available page
- Post-load: if store instance.currentPageId still invalid, clear store for fresh start
- On loadSnapshot error: clear store instead of silently failing with corrupt state
- Root cause: teacher1 had a saved snapshot with currentPageId pointing to a deleted page
2026-06-01 06:02:19 +00:00
CC Worker a375fbe37f fix(canvas): clear localStoreService singleton on unmount — disposed store was returned to next mount causing currentPageId crash
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 03:04:29 +00:00
CC Worker 38ca23dc3e fix(theme): add CssBaseline — MUI dark theme on Android was white text on white body background
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 02:52:41 +00:00
CC Worker 8fe8edb341 fix(auth): RequireAuth must use Outlet not children — React Router v6 requires Route guards to be Route elements
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 02:45:20 +00:00
CC Worker 143fff6414 merge: R4 error boundaries — ErrorBoundary wraps Tldraw canvas (resolve singlePlayerPage.tsx conflict with R3 state machine refactor)
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-01 02:40:28 +00:00
CC Worker c216df8021 merge: R4 auth reliability — PKCE race fix + RequireAuth loading skeleton 2026-06-01 02:38:54 +00:00
CC Worker f7bb84f9b8 merge: R4 TypeScript hardening H1-H6 — reduce errors 177→152 (resolve cc-graph-shapes/CCGraphNavPanel conflicts with R3) 2026-06-01 02:38:50 +00:00
CC Worker cbff4deef2 merge: R3 TypeScript fixes — cc-graph-shapes duplicates, CCGraphNavPanel, snapshotService, App.tsx simplification 2026-06-01 02:37:21 +00:00
CC Worker 85ecae7147 merge: R2 E2E canvas tests — 3 Playwright specs with beforeAll skip guards 2026-06-01 02:37:15 +00:00
CC Worker 8a79675497 merge: R2 bundle split — 3.3MB → 8 vendor chunks (largest 1.1MB) 2026-06-01 02:37:15 +00:00
CC Worker 6951bc1700 merge: R2 remove Tailwind — drop build pipeline, add tailwind-compat.css shim 2026-06-01 02:37:15 +00:00
CC Worker e6f6bd5c3a merge: R2 tldraw token bridge + DarkModeSync — bridge --cc-* to tldraw --color-* layer 2026-06-01 02:37:10 +00:00
CC Worker b3ec09fbef merge: R2 color token sweep — replace hardcoded hex with --cc-* tokens 2026-06-01 02:37:07 +00:00
CC Worker 765573163d feat(ts): R4 TypeScript hardening H1-H6 — reduce errors 177→152
H1: CCGraphNavPanel — useMemo import + LogCategory fix
H2: CCTimetableLessonNodeShapeUtil — props type alignment (cc-graph-props.ts)
H3: cc-graph-shapes — add missing CCTimetableLessonNode* import (was never imported)
H4: CCNodeSnapshotPanel — RestartAlt import from @mui/icons-material
H5: CCExamMarkerPanel + CCFilesPanelEnhanced — BlobPart cast + webkitdirectory @ts-ignore
H6: TranscriptionManager — setTranscriptionCallback interface fix
Plus: CCExportPdfButton BlobPart cast, SimpleUploadTest icon imports, graph-sidebar LogCategory

tsc before: 177 (master)
tsc after:  152 (reduction of 25 errors, well below 160 acceptable threshold)
2026-06-01 02:29:38 +00:00
CC Worker 1505adb154 feat(ux): R4 error boundaries — wrap Tldraw in ErrorBoundary (J1)
- Added import for ErrorBoundary from src/components/ErrorBoundary.tsx
- Wrapped {store && <Tldraw>} block in ErrorBoundary with 'Canvas failed to load — Reload' fallback
- ErrorBoundary.tsx already existed with full error/reload UI
- Timetable pages (J2) already have loading states via timetableStore loading flags
- tsc: 177 (master baseline) → 173 on this branch (no regression)
2026-06-01 02:20:34 +00:00
CC Worker ade0be97f6 feat(auth): R4 auth reliability — fix PKCE race condition + RequireAuth loading skeleton 2026-06-01 02:16:05 +00:00
CC Worker 96db086ecb fix: resolve duplicate identifier in cc-graph-shapes, useMemo import in CCGraphNavPanel 2026-06-01 01:36:37 +00:00
CC Worker 6610abbd72 fix: resolve duplicate identifier in cc-graph-shapes 2026-06-01 01:35:11 +00:00
CC Worker 489c2208c5 fix: add DarkModeSync to propagate tldraw dark mode to document root
tldraw v3.6 sets tl-theme__dark class on .tl-container, not data-color-mode
on document.documentElement. DarkModeSync lives inside TLDrawProvider and
mirrors the active color scheme to [data-color-mode] on <html>, activating
--cc-* token overrides in cc-design-system.css globally.
2026-06-01 00:47:24 +00:00
CC Worker 3db843a6f9 test: add Playwright E2E tests for canvas mount, node navigation, transcription toggle 2026-05-31 23:50:35 +00:00
CC Worker 79e7d4df9c test: add Playwright E2E tests for canvas mount, node navigation, transcription
Three spec files with beforeAll skip guards:
- Skip if VITE_TEST_TEACHER_EMAIL/PASSWORD not set
- Skip if dev server at 192.168.0.251:13000 unreachable
Tests exit 0 (3 skipped) when env is unconfigured.
Requires: live dev server + real teacher credentials to run live.
2026-05-31 23:49:41 +00:00
CC Worker 2e3cb134d1 perf: expand manual chunks — add react/router, calendar, xyflow vendor splits
index bundle: 1565kB → 1085kB (under 1500kB target)
Chunks: vendor-react 163kB, vendor-calendar 274kB, vendor-xyflow 181kB added.
2026-05-31 23:31:15 +00:00
CC Worker aca4d045b2 perf: split tldraw/MUI/supabase into separate vendor chunks 2026-05-31 23:28:11 +00:00
CC Worker a0afbc90f5 feat: replace last hardcoded hex colors (#666, #333) with --cc-* tokens in cc-youtube-embed-styles 2026-05-31 23:17:55 +00:00
CC Worker 57429f3a75 chore: remove Tailwind CSS build pipeline; replace with tailwind-compat.css shim
244 utility classes across 8 files (timetable pages, Modal, CCShapesPanel).
Rather than blind inline conversion, replace @tailwind directives with a
self-contained 230-line CSS shim covering all classes actually used.
Build passes. Shim entries can be converted to --cc-* tokens incrementally.
2026-05-31 23:09:08 +00:00
CC Worker 01a8a59349 feat: bridge --cc-* tokens to tldraw --color-* variable layer; fix first-render theme fallback 2026-05-31 22:56:31 +00:00
CC Worker 0150ca3c32 merge: CCLiveTranscriptionShapeUtil unit tests — 26 tests (P1c) 2026-05-31 22:25:10 +00:00
CC Worker 33b9278085 merge: replace console.log with logger in CCLiveTranscriptionShapeUtil (P3b) 2026-05-31 22:25:07 +00:00
CC Worker 9196af12f8 merge: static shape styles to CSS classes + pointer-events fix (P3a) — resolve CCBaseShapeUtil conflict, update panel.css shape-title to --cc-text-inverse 2026-05-31 22:24:46 +00:00
CC Worker f6e2ab63dd merge: hoist ThemeProvider to App.tsx root (P2b) — resolve BasePanel conflict by keeping MUI-free version 2026-05-31 22:23:21 +00:00
CC Worker f1c1a72f44 merge: remove MUI/styled-components from BasePanel — cc- CSS classes (P2a) 2026-05-31 22:22:31 +00:00
CC Worker 1e7b387b43 merge: --cc-* CSS variable system + design tokens (P1b) 2026-05-31 22:22:27 +00:00
CC Worker 135ba4d26d merge: dirty-flag auto-save in SharedStoreService (P1a) 2026-05-31 22:22:27 +00:00
CC Worker 8c1623256b merge: canvas core stability — TLStore useRef + single-effect state machine (P0a/P0b) 2026-05-31 22:22:19 +00:00
CC Worker b246106876 Add CCLiveTranscriptionShapeUtil unit tests (26 passing) 2026-05-31 22:20:43 +00:00
CC Worker d5a7464e15 fix(logger): remove unused CC_BASE_STYLE_CONSTANTS import from CCBaseShapeUtil 2026-05-31 22:10:08 +00:00
CC Worker 061cd22748 fix(shape-css): add pointer-events:all to header/toolbar; fix hardcoded white on lock icon
- .cc-shape-header and .cc-shape-toolbar button were missing pointer-events:all,
  causing toolbar buttons to be unclickable inside tldraw canvas
- Replace color:'white' on lock indicator with var(--cc-text-inverse)
2026-05-31 22:10:08 +00:00
CC Worker 55da04a3f7 refactor(shape-css): move static shape styles to CSS classes and drop unused constants import 2026-05-31 21:48:09 +00:00
CC Worker 2a6359e247 feat: extract static shape styles to CSS classes in CCBaseShapeUtil 2026-05-31 21:46:33 +00:00
CC Worker 3afba919c4 P4.2-W: replace console.log with logger in CCLiveTranscriptionShapeUtil 2026-05-31 21:37:45 +00:00
kcar 07ceef1294 refactor(theme): hoist ThemeProvider to App.tsx root 2026-05-31 21:24:05 +00:00
CC Worker 50c7bb68bb refactor: remove MUI and styled-components from BasePanel\n\n- Replace MUI Button/Menu/MenuItem with cc-btn + cc-menu + native div/button\n- Replace MUI icons with inline SVG icons\n- Replace MUI ThemeProvider/useMediaQuery with dataset/colorMode\n- Preserve TldrawUiButton for pin control in panel header\n- Minor: keep local panel state instead of MUI Menu open state 2026-05-31 21:21:06 +00:00
CC Worker 3ffbb11552 fix: restore design-system files accidentally reverted in prior commit
Commit 669a661 had a stale staged deletion of cc-design-system.css and
reverted three other CSS-variable files. This commit restores them to
the state from cd37771 (adopt --cc-* CSS variable system).
2026-05-31 21:15:13 +00:00
CC Worker 669a6616d8 fix(design-system): replace hardcoded color:white in CCBaseShapeUtil with --cc-text-inverse 2026-05-31 21:13:14 +00:00
CC Worker 65bce2c52d fix(auto-save): dirty-flag pattern in SharedStoreService
Replaces JSON.stringify snapshot comparison with a store.listen() dirty flag.
Eliminates 5-15ms main-thread serialize on every poll tick when canvas is idle.
2026-05-31 21:09:38 +00:00
CC Worker cd37771c52 adopt --cc-* CSS variable system 2026-05-31 21:05:04 +00:00
CC Worker df59207add Add CCLiveTranscriptionShapeUtil unit tests (26 passing) 2026-05-31 21:01:56 +00:00
CC Worker 2d15b7cc03 feat: replace triple context.node useEffect with single state machine 2026-05-31 20:51:22 +00:00
CC Worker d3bd25d544 fix: remove TLStore from useState and dead state vars in singlePlayerPage 2026-05-31 20:41:51 +00:00
kcarandClaude Haiku 4.5 bf592886c6 fix(F2,F3): onboarding chip in Header, auth catch-all redirects to dashboard
app-ci-deploy / test-build-deploy (push) Has been cancelled
F3: authenticated unknown routes now redirect to /dashboard instead of
rendering private NotFound.

F2: Header shows a warning Chip with the current onboarding next_step
when the user has not completed onboarding (next_step != 'ready').
Chip navigates to /dashboard on click. Hidden on xs screens.

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-05-31 11:00:55 +00:00
kcar fedbd903ff fix: centralize app API URL fallbacks
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-28 19:26:00 +01:00
kcar 65ce1bede8 feat: migrate app state to bootstrap endpoint
app-ci-deploy / test-build-deploy (push) Has been cancelled
Use GET /me/bootstrap as the primary authenticated state source per ADR-0003. AuthContext now fetches and exposes typed bootstrapData, Header and class/detail admin checks use bootstrap permissions/roles, dashboard surfaces onboarding/calendar/timetable/graph status, and graph navigation derives school setup state from bootstrap instead of /school/status.

Refs: t_44353587
2026-05-28 19:07:07 +01:00
kcar 67e47fc47f feat(tlsync): fetch short-lived token from API before multiplayer connect
app-ci-deploy / test-build-deploy (push) Has been cancelled
- Remove VITE_TLSYNC_SECRET from browser env (no longer exposed to bundle)
- Add useTlsyncToken hook that fetches /api/tlsync/token with Supabase auth
- Extract TldrawCanvas sub-component: only renders after token is ready
- Pass API-issued short-lived token to createSyncConnectionOptions
- Add vite.config.ts blocklist to prevent secret leak (defense-in-depth)
- Remove VITE_TLSYNC_SECRET from .env.example (server-side only now)

Related: t_a69128a1 (API token endpoint), t_41a844a7 (this task)
2026-05-28 18:00:43 +01:00
kcar 0db53bfd9c test: add admin route guard coverage
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-05-27 23:24:26 +01:00
kcar e68eef8865 chore: consolidate app compose dev and prod 2026-05-27 22:55:00 +01:00
kcar 77282893f3 chore: add .gitignore for React frontend project 2026-05-27 21:56:23 +01:00
kcar 6642d5fa22 docs: add .env.example with all app environment variables 2026-05-27 21:55:27 +01:00
kcar b2973dc6ad feat: add error boundary to prevent full app crashes 2026-05-27 21:44:59 +01:00
kcar b69c4d3e4f fix: remove 4 empty stub files (dead code) 2026-05-27 21:40:17 +01:00
kcarandClaude Sonnet 4.6 1c9ed4bd65 fix: remove last classItem.class.academic_year reference in MyClassesPage
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 13:50:27 +01:00
kcarandClaude Sonnet 4.6 de21c56543 fix: navigate scope in CCGraphNavPanel, class.code -> class_code in MyClassesPage
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 13:45:12 +01:00
kcarandClaude Sonnet 4.6 7a1032a3bd fix(classes): flat shape for MyClassesPage, SubjectClass nav -> class detail page
- MyClassesPage: fix all classItem.class?.* references to flat field access (class_code, name, description etc)
- MyClassesPage: fix class detail link /timetable/classes/:id -> /classes/:id (actual route)
- CCGraphNavPanel: SubjectClass click navigates to /classes/:id since no Neo4j SubjectClass nodes exist yet

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 13:30:38 +01:00
kcarandClaude Sonnet 4.6 63d6d1bbca fix(nav): trim burger menu, fix MyClasses for teachers, fix NotFound crash
- Header: trim menu to My Work section (My Lessons, My Classes, Lesson Plans), School Admin (gated), Platform Admin (gated)
- MyClassesPage: fix loading/error state destructure (classesLoading not myClassesLoading)
- NotFound: fix ErrorOutline -> ErrorOutlineIcon to prevent 404 page crash
- timetableService: getMyClasses now calls both /me/teacher and /me/student, merges with role annotation
- timetableStore: myClasses type updated to ClassWithRole[]
- timetable.types: add ClassWithRole interface and code/institute_id optional fields to Class

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 12:58:33 +01:00
kcarandClaude Sonnet 4.6 98a4212e46 feat(nav): timetable view toggle (By Class / By Term), fix cc-section-node crash
- Fix: skip canvas navigation for Section-type nodes (was crashing on cc-section-node)
- Add By Class / By Term toggle to My Timetable section (mirrors calendar Generic/Academic)
- By Class: pre-loaded SubjectClass children shown immediately on expand
- By Term: lazy-loads AcademicTerms -> Weeks -> TaughtLessons when switched to term view
- displayChildren respects timetableView context for the timetable section

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 12:12:59 +01:00
kcarandClaude Sonnet 4.6 510bef02b6 feat(timetable): add assign lesson plan dialog to TaughtLessonsPage
Each lesson card gets a LibraryAdd button that opens a searchable
plan picker. Falls back gracefully to empty state when no plans exist,
with a link to /lesson-plans to create one.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 11:25:55 +01:00
kcarandClaude Sonnet 4.6 9438e17f88 feat(nav): add launch buttons for My Timetable and My Classes sections
Add useNavigate + Launch icon to TreeItem; timetable section shows a
launch button routing to /my-lessons, classes section routes to /my-classes.
Both buttons only appear when the section status is populated.
Exclude classes section from the unlinked/pending icon fallback.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 11:22:26 +01:00
110 changed files with 21213 additions and 2598 deletions
+1 -1
View File
@@ -37,4 +37,4 @@ VITE_MICROSOFT_CLIENT_SECRET_ID=dummy_secret_id
VITE_MICROSOFT_CLIENT_SECRET=dummy_secret
VITE_MICROSOFT_TENANT_ID=common
VITE_SEARCH_URL=http://localhost:8888
VITE_SEARCH_URL=https://search.kevlarai.com
+52
View File
@@ -0,0 +1,52 @@
# Classroom Copilot App - Environment Variables Template
# Copy this file to .env and fill in the values
# =============================================================================
# Frontend Configuration
# =============================================================================
VITE_APP_NAME=Classroom Copilot
VITE_DEV=false
VITE_FRONTEND_SITE_URL=https://app.classroomcopilot.ai
VITE_PORT_FRONTEND=3000
VITE_PORT_FRONTEND_HMR=24678
VITE_APP_HMR_URL=http://localhost:24678
# =============================================================================
# API Configuration
# =============================================================================
VITE_API_URL=https://api.classroomcopilot.ai
VITE_API_BASE=https://api.classroomcopilot.ai
# =============================================================================
# Supabase Configuration
# =============================================================================
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
# =============================================================================
# TLSync (TLDraw Sync) Configuration — shared secret is server-side only (API TLSYNC_SECRET)
# =============================================================================
VITE_TLSYNC_URL=https://app.classroomcopilot.ai/tldraw
# =============================================================================
# WhisperLive (Transcription) Configuration
# =============================================================================
VITE_WHISPERLIVE_URL=wss://whisperlive.classroomcopilot.ai/ws
# =============================================================================
# Search Configuration
# =============================================================================
VITE_SEARCH_URL=https://search.kevlarai.com
# =============================================================================
# Super Admin Configuration
# =============================================================================
VITE_SUPER_ADMIN_EMAIL=[email protected]
# =============================================================================
# Port Configuration (for docker-compose)
# =============================================================================
PORT_FRONTEND=3000
PORT_FRONTEND_HMR=24678
PORT_API=8000
PORT_SUPABASE=8000
+39
View File
@@ -0,0 +1,39 @@
name: app-ci-deploy
on:
push:
branches: [master]
workflow_dispatch:
jobs:
test-build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run test:run
- run: npm run build
- name: Configure SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy app
run: |
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.APP_DEPLOY_HOST }}" '
set -euo pipefail
cd /home/kcar/app
git fetch origin master
git reset --hard origin/master
docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
docker compose -p app-prod -f docker-compose.yml up -d --build
docker compose -p app-prod -f docker-compose.yml ps
curl -fsSI http://127.0.0.1:3000 >/dev/null
'
+49 -7
View File
@@ -1,10 +1,52 @@
node_modules
# Dependencies
node_modules/
/.pnp
.pnp.js
# Build outputs
/dist
/build
*.local
# Environment files (never commit secrets)
.env
# Build output
dist/
.env.local
.env.*.local
.env.bak
# Lock files - clean install
package-lock.json
*.bak
*.bak
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Coverage
coverage/
.nyc_output/
# TypeScript
*.tsbuildinfo
# Docker
docker-compose.override.yml
# Misc
.cache/
.tmp/
*.tmp
# Local environment variants
.env.dev
.env.prod
# Playwright test artifacts
test-results/
-33
View File
@@ -1,33 +0,0 @@
import { describe, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { WrappedApp, App } from '../src/App';
describe('App', () => {
it('Renders hello world', () => {
// ARRANGE
render(<WrappedApp />);
// ACT
// EXPECT
expect(
screen.getByRole('heading', {
level: 1,
}
)).toHaveTextContent('Hello World')
});
it('Renders not found if invalid path', () => {
// ARRANGE
render(
<MemoryRouter initialEntries={['/this-route-does-not-exist']}>
<App />
</MemoryRouter>
);
// ACT
// EXPECT
expect(
screen.getByRole('heading', {
level: 1,
}
)).toHaveTextContent('Not Found')
});
});
+10 -2
View File
@@ -1,12 +1,17 @@
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
# TODO: Remove this or review embedded variables
COPY .env .env
# First generate package-lock.json if it doesn't exist, then do clean install
RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
COPY . .
# Vite bakes VITE_* values at build time, so compose must choose the env file
# during image build, not only at container runtime.
ARG ENV_FILE=.env
COPY ${ENV_FILE} .env
# Run build with production mode
RUN npm run build -- --mode production
@@ -14,6 +19,9 @@ FROM nginx:alpine
# Copy built files
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
RUN echo 'server { \
listen 3000; \
+34
View File
@@ -0,0 +1,34 @@
services:
frontend-test:
image: node:20-bookworm
working_dir: /app
volumes:
- .:/app
- app-test-node-modules:/app/node_modules
command: sh -lc "npm ci && npm run test:run"
profiles:
- test
frontend-dev:
container_name: cc-app-dev
image: cc-app-dev:latest
build:
context: .
dockerfile: Dockerfile
args:
ENV_FILE: .env.dev
env_file:
- .env.dev
ports:
- "13000:3000"
networks:
- kevlarai-network
restart: unless-stopped
volumes:
app-test-node-modules:
networks:
kevlarai-network:
external: true
name: kevlarai-network
+8 -6
View File
@@ -1,19 +1,21 @@
services:
classroom-copilot:
container_name: classroom-copilot
frontend:
container_name: cc-prod
image: cc-app-prod:latest
build:
context: .
dockerfile: Dockerfile
args:
ENV_FILE: .env
env_file:
- .env
ports:
- 3000:3000
volumes:
- ./:/app
- "3000:3000"
networks:
- kevlarai-network
restart: unless-stopped
networks:
kevlarai-network:
external: true
name: kevlarai-network
driver: bridge
+14397
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -5,7 +5,9 @@
"start": "vite --host",
"build": "vite build",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
@@ -82,9 +84,8 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"react-refresh": "^0.14.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^1.6.0"
}
}
}
+18
View File
@@ -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
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-import': {},
},
+58 -14
View File
@@ -1,25 +1,69 @@
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 { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext';
import { UserProvider } from './contexts/UserContext';
import AppRoutes from './AppRoutes';
import { ErrorBoundary } from './components/ErrorBoundary';
import React from 'react';
const App = React.memo(() => (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
<AuthProvider>
<UserProvider>
<TLDrawProvider>
<AppRoutes />
</TLDrawProvider>
</UserProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
));
/**
* 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>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AuthProvider>
<UserProvider>
<TLDrawProvider>
<DarkModeSync />
<ThemeProvider theme={appTheme}>
<CssBaseline />
<AppRoutes />
</ThemeProvider>
</TLDrawProvider>
</UserProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
);
});
App.displayName = import.meta.env.VITE_APP_NAME;
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import AppRoutes from './AppRoutes';
const mockUseAuth = vi.fn();
vi.mock('./contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock('./contexts/UserContext', () => ({
useUser: () => ({ isInitialized: true }),
}));
vi.mock('./debugConfig', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('./pages/Layout', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('./pages/auth/PlatformAdminPage', () => ({ default: () => <div>Platform Admin Page</div> }));
vi.mock('./pages/auth/adminPage', () => ({ default: () => <div>Legacy Admin</div> }));
vi.mock('./pages/auth/loginPage', () => ({ default: () => <div>Login Page</div> }));
vi.mock('./pages/auth/signupPage', () => ({ default: () => <div>Signup Page</div> }));
vi.mock('./pages/user/dashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
vi.mock('./pages/user/NotFound', () => ({ default: () => <div>Private Not Found</div> }));
vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found</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/multiplayerUser', () => ({ default: () => <div>Multiplayer</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/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
vi.mock('./pages/tldraw/ShareHandler', () => ({ default: () => <div>Share</div> }));
vi.mock('./pages/searxngPage', () => ({ default: () => <div>Search</div> }));
vi.mock('./pages/dev/SimpleUploadTest', () => ({ default: () => <div>Upload Test</div> }));
vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
CCDocumentIntelligence: () => <div>Doc Intelligence</div>,
}));
vi.mock('./pages/timetable', () => ({
TimetablePage: () => <div>Timetable</div>,
TimetableListPage: () => <div>Timetable List</div>,
ClassesPage: () => <div>Classes</div>,
LessonPage: () => <div>Lesson</div>,
TaughtLessonsPage: () => <div>Taught Lessons</div>,
MyClassesPage: () => <div>My Classes</div>,
EnrollmentRequestsPage: () => <div>Enrollment Requests</div>,
StaffManagerPage: () => <div>Staff Manager</div>,
StudentManagerPage: () => <div>Student Manager</div>,
SchoolSettingsPage: () => <div>School Settings</div>,
ClassDetailPage: () => <div>Class Detail</div>,
StudentLessonsPage: () => <div>Student Lessons</div>,
LessonPlansPage: () => <div>Lesson Plans</div>,
LessonPlanDetailPage: () => <div>Lesson Plan Detail</div>,
}));
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>
);
}
function authState(overrides: Record<string, unknown>) {
return {
user: null,
user_role: null,
accessToken: null,
loading: false,
error: null,
signIn: vi.fn(),
signOut: vi.fn(),
clearError: vi.fn(),
...overrides,
};
}
describe('/admin route authorization', () => {
beforeEach(() => {
mockUseAuth.mockReset();
});
it('redirects anonymous users away from /admin', () => {
mockUseAuth.mockReturnValue(authState({ user: null, user_role: null }));
renderAt('/admin');
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
expect(screen.getByText('Login Page')).toBeInTheDocument();
});
it('redirects authenticated non-admin users away from /admin', () => {
mockUseAuth.mockReturnValue(authState({
user: { id: 'user-1', email: '[email protected]', user_type: 'email_teacher' },
user_role: 'email_teacher',
accessToken: 'token',
}));
renderAt('/admin');
expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
expect(screen.getByText('Dashboard Page')).toBeInTheDocument();
});
it('allows super_admin users to access /admin', () => {
mockUseAuth.mockReturnValue(authState({
user: { id: 'admin-1', email: '[email protected]', user_type: 'super_admin' },
user_role: 'super_admin',
accessToken: 'token',
}));
renderAt('/admin');
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();
});
});
+49 -11
View File
@@ -7,7 +7,8 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
@@ -30,6 +31,7 @@ import DashboardPage from './pages/user/dashboardPage';
// Timetable Module Pages
import {
TimetablePage,
TimetableListPage,
ClassesPage,
LessonPage,
TaughtLessonsPage,
@@ -45,8 +47,6 @@ import {
} from './pages/timetable';
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();
if (!isUserInitialized) {
@@ -68,9 +68,36 @@ const FullContextRoutes: React.FC = () => {
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 { user, loading: isAuthLoading } = useAuth();
const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation();
const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
// Debug log for routing
logger.debug('routing', '🔄 Rendering routes', {
@@ -123,15 +150,22 @@ const AppRoutes: React.FC = () => {
<Route
path="/admin"
element={
<PlatformAdminPage />
!user ? (
<Navigate to="/login" replace state={{ from: location }} />
) : isPlatformAdmin ? (
<PlatformAdminPage />
) : (
<Navigate to="/dashboard" replace />
)
}
/>
{/* Authentication only routes - only render if all contexts are initialized */}
{user && (
<Route element={<RequireAuth />}>
<Route element={<FullContextRoutes />}>
{/* 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="/my-classes" element={<MyClassesPage />} />
<Route path="/classes/:classId" element={<ClassDetailPage />} />
@@ -148,7 +182,11 @@ const AppRoutes: React.FC = () => {
{/* Existing Routes */}
<Route path="/search" element={<SearxngPage />} />
<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="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
@@ -159,10 +197,10 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
)}
</Route>
{/* Fallback route - use different NotFound pages based on auth state */}
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} />
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
</Routes>
</Layout>
);
+47
View File
@@ -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(() => {});
});
});
+39
View File
@@ -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();
});
});
+54
View File
@@ -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);
});
});
+47
View File
@@ -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);
});
});
+4 -3
View File
@@ -1,11 +1,12 @@
import axios from 'axios';
import { API_BASE as CONFIG_API_BASE } from './config/apiConfig';
import { logger } from './debugConfig';
// Use development backend URL if no custom URL is provided
const baseURL = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
const baseURL = CONFIG_API_BASE;
if (!import.meta.env.VITE_API_BASE) {
logger.warn('axios', '⚠️ VITE_API_BASE not set, defaulting to http://localhost:8080');
if (!import.meta.env.VITE_API_BASE && !import.meta.env.VITE_API_URL) {
logger.warn('axios', '⚠️ VITE_API_BASE not set, using relative /api fallback');
}
const instance = axios.create({
+84
View File
@@ -0,0 +1,84 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
fontFamily: 'system-ui, -apple-system, sans-serif',
padding: '2rem',
textAlign: 'center',
}}>
<h1 style={{ color: '#dc2626', marginBottom: '1rem' }}>Something went wrong</h1>
<p style={{ color: '#6b7280', marginBottom: '1.5rem' }}>
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
fontSize: '1rem',
}}
>
Reload Application
</button>
{import.meta.env.DEV && (
<details style={{ marginTop: '2rem', textAlign: 'left', maxWidth: '600px' }}>
<summary style={{ cursor: 'pointer', color: '#6b7280' }}>Error Details</summary>
<pre style={{
backgroundColor: '#f3f4f6',
padding: '1rem',
borderRadius: '0.5rem',
overflow: 'auto',
fontSize: '0.875rem',
}}>
{this.state.error?.stack}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}
-1
View File
@@ -1 +0,0 @@
+44
View File
@@ -0,0 +1,44 @@
/**
* Centralized API configuration for Classroom Copilot.
*
* Resolves the API base URL from environment variables with proper fallbacks.
* - In dev: VITE_API_BASE should always be set (e.g., http://192.168.0.64:18000)
* - In production: VITE_API_BASE should be set to the production API URL
* - Fallback: "/api" (relative path, works when API is served from same origin)
*
* IMPORTANT: Never hardcode localhost or 127.0.0.1 as fallbacks - they break
* remote dev access and do not work in containerized environments.
*/
/**
* Primary API base URL. Use this for all API calls.
* Resolved from VITE_API_BASE (preferred) or VITE_API_URL (legacy alias).
*/
export const API_BASE: string =
import.meta.env.VITE_API_BASE ||
import.meta.env.VITE_API_URL ||
"/api";
/**
* TLSync worker URL for tldraw multiplayer.
* Resolved from VITE_TLSYNC_URL (preferred) or derived from VITE_FRONTEND_SITE_URL.
*/
export const TLSYNC_URL: string =
import.meta.env.VITE_TLSYNC_URL ||
(import.meta.env.VITE_FRONTEND_SITE_URL?.startsWith("http")
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`);
/**
* WhisperLive WebSocket URL for live transcription.
* Resolved from VITE_WHISPERLIVE_URL. Must be set in env.
*/
export const WHISPERLIVE_URL: string | undefined =
import.meta.env.VITE_WHISPERLIVE_URL;
/**
* Search proxy URL (searxng).
* Resolved from VITE_SEARCH_URL.
*/
export const SEARCH_URL: string =
import.meta.env.VITE_SEARCH_URL || "/searxng-api/";
+32 -14
View File
@@ -5,6 +5,8 @@ import { CCUser, CCUserMetadata, authService } from '../services/auth/authServic
import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient';
import { storageService, StorageKeys } from '../services/auth/localStorageService';
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
export interface AuthContextType {
user: CCUser | null;
@@ -15,6 +17,8 @@ export interface AuthContextType {
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
clearError: () => void;
bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
}
export const AuthContext = createContext<AuthContextType>({
@@ -25,7 +29,9 @@ export const AuthContext = createContext<AuthContextType>({
error: null,
signIn: async () => {},
signOut: async () => {},
clearError: () => {}
clearError: () => {},
bootstrapData: null,
isAuthResolving: false
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -35,8 +41,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
const [isAuthResolving, setIsAuthResolving] = useState(false);
const apiBase = import.meta.env.VITE_API_BASE as string;
const persistSession = useCallback((session: Session | null) => {
if (session) {
@@ -71,15 +78,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { user: resolvedUser, role: resolvedRole };
}, []);
const triggerUserInit = useCallback((token: string) => {
fetch(`${apiBase}/user/init`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then(data => logger.debug('auth-context', '✅ User init', data))
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err }));
}, [apiBase]);
const loadBootstrap = useCallback(async (token: string | null | undefined) => {
if (!token) {
setBootstrapData(null);
return null;
}
const bootstrap = await fetchBootstrapData(token);
setBootstrapData(bootstrap);
return bootstrap;
}, []);
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
@@ -88,13 +95,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (event === 'SIGNED_IN') {
persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
setAccessToken(session.access_token ?? null);
triggerUserInit(session.access_token);
await loadBootstrap(session.access_token);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
@@ -108,17 +116,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null);
}
setLoading(false);
setIsAuthResolving(false);
return;
}
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
setAccessToken(session.access_token ?? null);
await loadBootstrap(session.access_token);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
@@ -132,21 +143,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null);
}
setLoading(false);
setIsAuthResolving(false);
return;
}
if (event === 'SIGNED_OUT') {
persistSession(null);
setIsAuthResolving(false);
setUser(null);
setUserRole(null);
setAccessToken(null);
setBootstrapData(null);
invalidateBootstrapCache();
setLoading(false);
}
}
);
return () => subscription.unsubscribe();
}, [buildUserFromSupabase, persistSession, triggerUserInit]);
}, [buildUserFromSupabase, persistSession, loadBootstrap]);
const signIn = async (email: string, password: string) => {
try {
@@ -167,6 +182,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(resolvedUser);
setUserRole(role);
setAccessToken(data.session?.access_token ?? null);
await loadBootstrap(data.session?.access_token ?? null);
}
} catch (error) {
logger.error('auth-context', '❌ Sign in failed', { error });
@@ -205,7 +221,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
error,
signIn,
signOut,
clearError
clearError,
bootstrapData,
isAuthResolving
}}
>
{children}
+2
View File
@@ -23,6 +23,7 @@ export type LogCategory =
| 'neo-institute-context'
| 'auth-service'
| 'graph-service'
| 'graph-sidebar'
| 'registration-service'
| 'provisioning-service'
| 'snapshot-service'
@@ -111,6 +112,7 @@ export type LogCategory =
| 'user-context'
| 'neo-user-context'
| 'neo-institute-context'
| 'cc-live-transcription'
| 'search-service';
interface LogConfig {
+4 -5
View File
@@ -1,7 +1,6 @@
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
@import './tailwind-compat.css';
/* Base styles */
html,
@@ -152,10 +151,10 @@ body {
.login-role-header {
font-weight: 600 !important;
color: #1976d2 !important;
color: var(--cc-action-primary) !important;
margin-bottom: 24px !important;
padding-bottom: 8px !important;
border-bottom: 2px solid #1976d2 !important;
border-bottom: 2px solid var(--cc-action-primary) !important;
width: fit-content !important;
}
+18 -141
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
@@ -7,6 +7,7 @@ import {
Typography,
IconButton,
Box,
Chip,
useTheme,
Menu,
MenuItem,
@@ -38,54 +39,24 @@ import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const Header: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user, signOut, accessToken } = useAuth();
const { user, signOut, bootstrapData } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isPlatformAdmin, setIsPlatformAdmin] = useState(false);
const [schoolRole, setSchoolRole] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const showGraphNavigation = location.pathname === '/single-player';
const isPlatformAdmin = bootstrapData?.permissions?.platform_admin ?? false;
const schoolRole = bootstrapData?.active_institute?.membership_role ?? null;
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
useEffect(() => {
setIsAuthenticated(!!user);
}, [user]);
// Check platform admin status and school role once on login
const checkAdminStatus = useCallback(async () => {
if (!accessToken) return;
// Platform admin check
try {
const res = await fetch(`${API_BASE}/admin/stats`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
setIsPlatformAdmin(res.ok);
} catch {
setIsPlatformAdmin(false);
}
// School role check
try {
const res = await fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
const data = await res.json();
setSchoolRole(data.user_role || null);
}
} catch {
setSchoolRole(null);
}
}, [accessToken]);
useEffect(() => {
if (accessToken) checkAdminStatus();
else { setIsPlatformAdmin(false); setSchoolRole(null); }
}, [accessToken, checkAdminStatus]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -156,6 +127,15 @@ const Header: React.FC = () => {
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isAuthenticated && bootstrapData?.onboarding?.next_step && bootstrapData.onboarding.next_step !== 'ready' && (
<Chip
label={bootstrapData.onboarding.next_step.replace(/_/g, ' ')}
color="warning"
size="small"
onClick={() => navigate('/dashboard')}
sx={{ cursor: 'pointer', display: { xs: 'none', sm: 'flex' } }}
/>
)}
{isAuthenticated && (
<Typography
variant="body2"
@@ -219,9 +199,9 @@ const Header: React.FC = () => {
</MenuItem>,
<Divider key="home-divider" />,
// Timetable Section
// My Work Section
<Typography
key="timetable-header"
key="work-header"
variant="subtitle2"
sx={{
px: 2,
@@ -233,26 +213,8 @@ const Header: React.FC = () => {
fontSize: '0.75rem'
}}
>
Timetable & Classes
My Work
</Typography>,
<MenuItem key="timetable" onClick={() => handleNavigation('/timetable')}>
<ListItemIcon>
<Schedule />
</ListItemIcon>
<ListItemText
primary="My Timetable"
secondary="View your schedule"
/>
</MenuItem>,
<MenuItem key="classes" onClick={() => handleNavigation('/classes')}>
<ListItemIcon>
<Class />
</ListItemIcon>
<ListItemText
primary="Browse Classes"
secondary="Find and enroll in classes"
/>
</MenuItem>,
<MenuItem key="my-classes" onClick={() => handleNavigation('/my-classes')}>
<ListItemIcon>
<Book />
@@ -262,15 +224,6 @@ const Header: React.FC = () => {
secondary="Manage your classes"
/>
</MenuItem>,
<MenuItem key="enrollment-requests" onClick={() => handleNavigation('/enrollment-requests')}>
<ListItemIcon>
<Enrollment />
</ListItemIcon>
<ListItemText
primary="Enrollment Requests"
secondary="Review pending enrollments"
/>
</MenuItem>,
<MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
<ListItemIcon>
<Lessons />
@@ -289,16 +242,7 @@ const Header: React.FC = () => {
secondary="Plan and manage lessons"
/>
</MenuItem>,
<MenuItem key="student-lessons" onClick={() => handleNavigation('/student-lessons')}>
<ListItemIcon>
<Student />
</ListItemIcon>
<ListItemText
primary="Student Lessons"
secondary="Your class timetable"
/>
</MenuItem>,
<Divider key="timetable-divider" />,
<Divider key="work-divider" />,
// School Admin Section
...(isSchoolAdmin ? [
@@ -331,71 +275,6 @@ const Header: React.FC = () => {
<Divider key="school-admin-divider" />,
] : []),
// Features Section
<Typography
key="features-header"
variant="subtitle2"
sx={{
px: 2,
py: 1,
color: theme.palette.text.secondary,
fontWeight: 600,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem'
}}
>
Features
</Typography>,
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}>
<ListItemIcon>
<Calendar />
</ListItemIcon>
<ListItemText primary="Calendar" />
</MenuItem>,
<MenuItem key="teacher-planner" onClick={() => handleNavigation('/teacher-planner')}>
<ListItemIcon>
<TeacherPlanner />
</ListItemIcon>
<ListItemText primary="Teacher Planner" />
</MenuItem>,
<MenuItem key="exam-marker" onClick={() => handleNavigation('/exam-marker')}>
<ListItemIcon>
<ExamMarker />
</ListItemIcon>
<ListItemText primary="Exam Marker" />
</MenuItem>,
<Divider key="features-divider" />,
// Utilities Section
<Typography
key="utilities-header"
variant="subtitle2"
sx={{
px: 2,
py: 1,
color: theme.palette.text.secondary,
fontWeight: 600,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem'
}}
>
Utilities
</Typography>,
<MenuItem key="settings" onClick={() => handleNavigation('/settings')}>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText primary="Settings" />
</MenuItem>,
<MenuItem key="search" onClick={() => handleNavigation('/search')}>
<ListItemIcon>
<Search />
</ListItemIcon>
<ListItemText primary="Search" />
</MenuItem>,
// Platform Admin Section
...(isPlatformAdmin ? [
<Divider key="admin-divider" />,
@@ -420,8 +299,6 @@ const Header: React.FC = () => {
</MenuItem>
] : []),
// Authentication Section
<Divider key="auth-divider" />,
<MenuItem key="signout" onClick={handleSignOut}>
<ListItemIcon>
<Logout />
+1 -1
View File
@@ -8,7 +8,7 @@ import { Refresh, School, People, EventNote, HourglassEmpty } from '@mui/icons-m
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
interface SchoolEntry {
id: string;
+23 -7
View File
@@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
import { EmailCredentials } from '../../services/auth/authService';
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 navigate = useNavigate();
const { user, signIn } = useAuth();
const { user, signIn, isAuthResolving } = useAuth();
const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user
hasUser: !!user,
isAuthResolving
});
useEffect(() => {
if (user) {
if (!isAuthResolving && user) {
navigate('/dashboard');
}
}, [user, navigate]);
}, [user, isAuthResolving, navigate]);
const handleLogin = async (credentials: EmailCredentials) => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/dashboard');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');
const message = getLoginErrorMessage(error);
setError(message);
throw error;
}
};
if (user) {
if (user && !isAuthResolving) {
return null;
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
import { EmailCredentials } from '../../services/auth/authService';
import { RegistrationService } from '../../services/auth/registrationService';
import { logger } from '../../debugConfig';
import MicrosoftIcon from '@mui/icons-material/Microsoft';
import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
const SignupPage: React.FC = () => {
const location = useLocation();
+7 -2
View File
@@ -31,6 +31,7 @@ import {
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Upload,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon,
Description as FileIcon,
@@ -39,8 +40,12 @@ import {
PlayArrow as ProcessIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon
Info as InfoIcon,
InsertDriveFile,
} from '@mui/icons-material';
const FolderOpen = FolderOpenIcon;
const Refresh = RefreshIcon;
const Info = InfoIcon;
import { supabase } from '../../supabaseClient';
import {
pickDirectory,
@@ -123,7 +128,7 @@ const SimpleUploadTest: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const dirInputRef = useRef<HTMLInputElement>(null);
const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080';
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
const session = await supabase.auth.getSession();
+385
View File
@@ -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;
+233
View File
@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useMemo, 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 { 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 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 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>
<Typography variant="h6" gutterBottom>Marking queue</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;
+177
View File
@@ -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;
+501
View File
@@ -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]-&gt;(: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;
+169
View File
@@ -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;
+6
View File
@@ -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,360 @@
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 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 { 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[] {
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 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,
}
}
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[]) {
if (!models.length) return
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
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 },
})))
}
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() } },
])
}
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 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])
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 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={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
<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()
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else 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) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
</Typography>
<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>
</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
+147
View File
@@ -0,0 +1,147 @@
import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
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
}
}
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); }
[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); }
`
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
const isBoundary = kind === 'boundary'
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: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', 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={`${p.label}: ${p.role}`}
title={`${p.label}: ${p.role}`}
>
<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">{p.icon}</span>
{shape.props.label || p.label}
</span>
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</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 }
}
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) }
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 BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
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
}
+47
View File
@@ -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
}
+10 -12
View File
@@ -9,8 +9,9 @@ import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { ResultsWidget } from '../exam';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -116,7 +117,7 @@ function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: Ad
const ClassDetailPage: React.FC = () => {
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { accessToken, user } = useAuth();
const { accessToken, user, bootstrapData } = useAuth();
const [cls, setCls] = useState<ClassDetail | null>(null);
const [loading, setLoading] = useState(true);
@@ -131,24 +132,19 @@ const ClassDetailPage: React.FC = () => {
setLoading(true);
setError(null);
try {
const [clsRes, roleRes] = await Promise.all([
fetch(`${API_BASE}/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
fetch(`${API_BASE}/school/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json()),
]);
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
if (clsRes.id) setCls(clsRes);
else setError(clsRes.detail || 'Class not found');
const role = roleRes.user_role || '';
const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken, classId]);
}, [accessToken, classId, bootstrapData]);
useEffect(() => { load(); }, [load]);
@@ -259,6 +255,8 @@ const ClassDetailPage: React.FC = () => {
</Alert>
)}
<ResultsWidget classId={cls.id} className={cls.name} />
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} />
+1 -1
View File
@@ -12,7 +12,7 @@ import {
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -9,7 +9,7 @@ import {
import { Add, Search, LibraryBooks, Edit, Delete, FilterList } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
+41 -31
View File
@@ -3,19 +3,37 @@ import { Link } from 'react-router-dom';
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
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 { profile } = useUser();
const { selectedInstituteId } = useNeoInstitute();
const {
myClasses,
myClassesLoading,
myClassesError,
classesLoading: myClassesLoading,
classesError: myClassesError,
fetchMyClasses,
} = useTimetableStore();
useEffect(() => {
fetchMyClasses();
}, [fetchMyClasses]);
fetchMyClasses().catch((error) =>
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) => {
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) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -88,25 +98,25 @@ const MyClassesPage: React.FC = () => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teachingClasses.map((classItem) => (
<Link
key={classItem.class_id}
to={`/timetable/classes/${classItem.class_id}`}
key={classItem.id}
to={`/classes/${classItem.id}`}
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
>
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{classItem.class?.name}
{classItem.name}
</h3>
{classItem.class?.code && (
<p className="text-sm text-gray-500">{classItem.class.code}</p>
{classItem.class_code && (
<p className="text-sm text-gray-500">{classItem.class_code}</p>
)}
</div>
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{classItem.class?.description || 'No description'}
{classItem.description || 'No description'}
</p>
<div className="flex items-center gap-2 flex-wrap">
@@ -121,12 +131,12 @@ const MyClassesPage: React.FC = () => {
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<People size={14} />
<span>{classItem.class?.enrolled_count || 0} students</span>
<span>{(classItem as any).enrolled_count || 0} students</span>
</div>
{classItem.class?.academic_year && (
{classItem.academic_year && (
<div className="flex items-center gap-1">
<School size={14} />
<span>{classItem.class.academic_year}</span>
<span>{classItem.academic_year}</span>
</div>
)}
</div>
@@ -151,25 +161,25 @@ const MyClassesPage: React.FC = () => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{enrolledClasses.map((classItem) => (
<Link
key={classItem.class_id}
to={`/timetable/classes/${classItem.class_id}`}
key={classItem.id}
to={`/classes/${classItem.id}`}
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
>
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{classItem.class?.name}
{classItem.name}
</h3>
{classItem.class?.code && (
<p className="text-sm text-gray-500">{classItem.class.code}</p>
{classItem.class_code && (
<p className="text-sm text-gray-500">{classItem.class_code}</p>
)}
</div>
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{classItem.class?.description || 'No description'}
{classItem.description || 'No description'}
</p>
<div className="flex items-center gap-2 flex-wrap">
@@ -182,13 +192,13 @@ const MyClassesPage: React.FC = () => {
<div className="flex items-center gap-1">
<School size={14} />
<span>
{classItem.class?.teachers?.[0]?.first_name} {classItem.class?.teachers?.[0]?.last_name}
{(classItem as any).primary_teacher_name} {}
</span>
</div>
{classItem.class?.academic_year && (
{classItem.academic_year && (
<div className="flex items-center gap-1">
<School size={14} />
<span>{classItem.class.academic_year}</span>
<span>{classItem.academic_year}</span>
</div>
)}
</div>
@@ -208,7 +218,7 @@ const MyClassesPage: React.FC = () => {
You are not enrolled in or teaching any classes yet.
</p>
<Link
to="/timetable/classes"
to="/classes"
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
>
Browse Available Classes
+1 -1
View File
@@ -13,7 +13,7 @@ import {
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
const DAY_TYPE_OPTIONS = ['Academic', 'Holiday', 'Staff', 'OffTimetable'];
const DAY_TYPE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning' | 'error'> = {
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -5,7 +5,7 @@ import {
import { ChevronLeft, ChevronRight, Today } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
interface Student {
profile_id: string;
+134 -11
View File
@@ -1,16 +1,18 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Dialog, DialogTitle, DialogContent, DialogActions,
TextField, Select, MenuItem, FormControl, InputLabel,
Divider, IconButton, Tooltip,
Divider, IconButton, Tooltip, List, ListItemButton, ListItemText,
InputAdornment,
} from '@mui/material';
import {
ChevronLeft, ChevronRight, Today, EditNote,
ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -152,9 +154,10 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
interface LessonCardProps {
lesson: Lesson;
onEdit: (l: Lesson) => void;
onAssignPlan: (l: Lesson) => void;
}
function LessonCard({ lesson, onEdit }: LessonCardProps) {
function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
return (
<Box
sx={{
@@ -174,11 +177,18 @@ function LessonCard({ lesson, onEdit }: LessonCardProps) {
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code}
</Typography>
<Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ ml: 0.5, p: 0.25 }}>
<EditNote fontSize="inherit" />
</IconButton>
</Tooltip>
<Box sx={{ display: 'flex', gap: 0 }}>
<Tooltip title="Assign lesson plan">
<IconButton size="small" onClick={() => onAssignPlan(lesson)} sx={{ p: 0.25 }}>
<LibraryAdd fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Edit lesson">
<IconButton size="small" onClick={() => onEdit(lesson)} sx={{ p: 0.25 }}>
<EditNote fontSize="inherit" />
</IconButton>
</Tooltip>
</Box>
</Box>
{lesson.subject && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
@@ -212,6 +222,7 @@ function LessonCard({ lesson, onEdit }: LessonCardProps) {
const TaughtLessonsPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
const [days, setDays] = useState<DayEntry[]>([]);
const [loading, setLoading] = useState(false);
@@ -219,6 +230,11 @@ const TaughtLessonsPage: React.FC = () => {
const [materializing, setMaterializing] = useState(false);
const [materializeResult, setMaterializeResult] = useState<string | null>(null);
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [assigningLesson, setAssigningLesson] = useState<Lesson | null>(null);
const [plans, setPlans] = useState<{ id: string; title: string; subject: string | null }[]>([]);
const [planSearch, setPlanSearch] = useState('');
const [assignSaving, setAssignSaving] = useState(false);
const [assignResult, setAssignResult] = useState<string | null>(null);
const loadLessons = useCallback(async (ws: string) => {
if (!accessToken) return;
@@ -281,10 +297,51 @@ const TaughtLessonsPage: React.FC = () => {
loadLessons(weekStart);
};
const openAssignDialog = async (lesson: Lesson) => {
setAssigningLesson(lesson);
setPlanSearch('');
setAssignResult(null);
if (plans.length === 0) {
try {
const res = await fetch(`${API_BASE}/lessons/plans`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
if (Array.isArray(data)) setPlans(data);
} catch { /* silently ignore */ }
}
};
const handleAssignPlan = async (planId: string) => {
if (!assigningLesson) return;
setAssignSaving(true);
try {
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/deliver`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ taught_lesson_id: assigningLesson.id }),
});
const data = await res.json();
if (data.id) {
setAssignResult('Plan linked ✓');
setTimeout(() => { setAssigningLesson(null); setAssignResult(null); }, 1200);
} else {
setAssignResult(data.detail || 'Failed to link plan');
}
} catch (e: any) {
setAssignResult(e.message);
} finally {
setAssignSaving(false);
}
};
const filteredPlans = plans.filter(p =>
!planSearch || p.title.toLowerCase().includes(planSearch.toLowerCase())
|| (p.subject || '').toLowerCase().includes(planSearch.toLowerCase())
);
const totalLessons = days.reduce((sum, d) => sum + d.lessons.length, 0);
// Format week label
const weekDates = days.filter(d => d.lessons.length > 0 || true).map(d => d.date);
const weekLabel = days.length > 0
? `${formatDate(days[0].date)} ${formatDate(days[days.length - 1].date)}`
: '';
@@ -369,6 +426,7 @@ const TaughtLessonsPage: React.FC = () => {
key={lesson.id}
lesson={lesson}
onEdit={setEditingLesson}
onAssignPlan={openAssignDialog}
/>
))
)}
@@ -382,6 +440,71 @@ const TaughtLessonsPage: React.FC = () => {
onClose={() => setEditingLesson(null)}
onSave={handleSaveLesson}
/>
{/* Assign Plan dialog */}
<Dialog open={!!assigningLesson} onClose={() => setAssigningLesson(null)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ pb: 1 }}>
<Typography variant="h6">Assign Lesson Plan</Typography>
{assigningLesson && (
<Typography variant="caption" color="text.secondary">
{assigningLesson.class_name || assigningLesson.period_code} · {assigningLesson.date}
</Typography>
)}
</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
{assignResult ? (
<Alert severity={assignResult.includes('✓') ? 'success' : 'error'} sx={{ mb: 1 }}>
{assignResult}
</Alert>
) : null}
<TextField
size="small"
fullWidth
placeholder="Search plans…"
value={planSearch}
onChange={e => setPlanSearch(e.target.value)}
InputProps={{
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
}}
sx={{ mb: 1 }}
/>
{plans.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
No lesson plans yet.{' '}
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
Create one
</Button>
</Typography>
) : (
<List dense disablePadding sx={{ maxHeight: 300, overflow: 'auto' }}>
{filteredPlans.map(p => (
<ListItemButton
key={p.id}
onClick={() => handleAssignPlan(p.id)}
disabled={assignSaving}
sx={{ borderRadius: 1, mb: 0.25 }}
>
<ListItemText
primary={p.title}
secondary={p.subject || undefined}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
{assignSaving && <CircularProgress size={16} sx={{ ml: 1 }} />}
</ListItemButton>
))}
</List>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setAssigningLesson(null)}>Close</Button>
<Button size="small" endIcon={<OpenInNew fontSize="small" />}
onClick={() => { setAssigningLesson(null); navigate('/lesson-plans'); }}>
Manage Plans
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
+76
View File
@@ -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;
+1
View File
@@ -3,6 +3,7 @@
*/
export { default as TimetablePage } from './TimetablePage';
export { default as TimetableListPage } from './TimetableListPage';
export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage';
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
@@ -50,8 +50,8 @@ export const CCBundleViewer: React.FC<{
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const API_BASE_FALLBACK = 'http://127.0.0.1:8080';
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
const API_BASE_FALLBACK = '/api';
const proxyUrl = useCallback(async (bucket: string, relPath: string) => {
const token = accessToken || '';
@@ -182,7 +182,7 @@ export const CCDoclingViewer: React.FC<{
setError(null);
try {
// Try page-images manifest first
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
@@ -199,7 +199,7 @@ export const CCDoclingViewer: React.FC<{
}
// Legacy: Load artefacts for file to find docling JSON artefacts
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || '/api'}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
@@ -239,7 +239,7 @@ export const CCDoclingViewer: React.FC<{
run();
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
const pageProxyUrl = useMemo(() => {
if (!manifest) return undefined;
@@ -148,7 +148,7 @@ export const CCDocumentIntelligence: React.FC = () => {
useEffect(() => {
const loadBundles = async () => {
if (!validFileId) return;
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const token = accessToken || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
@@ -223,7 +223,7 @@ export const CCDocumentIntelligence: React.FC = () => {
const run = async () => {
if (!validFileId) return;
setOutlineOptions([]);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
@@ -486,7 +486,7 @@ export const CCDocumentIntelligence: React.FC = () => {
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
try {
setBusy(true);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const token = accessToken || '';
const body: CanonicalDoclingRequest = {
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
@@ -84,7 +84,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
const thumbnailsRef = useRef<HTMLDivElement>(null);
const API_BASE = useMemo(() =>
import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'),
import.meta.env.VITE_API_BASE || '/api',
[]
);
@@ -65,7 +65,7 @@ export const CCFileDetailPanel: React.FC<{
const [showAdmin, setShowAdmin] = useState(false);
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'), []);
const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
useEffect(() => {
const run = async () => {
@@ -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);
}
-44
View File
@@ -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[];
}
+154 -75
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from 'react';
import { useEffect, useRef, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Tldraw,
@@ -26,39 +26,117 @@ import { allBindingUtils } from '../../utils/tldraw/bindings';
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
import { TLSYNC_URL } from '../../config/apiConfig';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
const SYNC_WORKER_URL = TLSYNC_URL;
export default function TldrawMultiUser() {
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
/**
* Fetches a short-lived TLSync token from the API using the current Supabase session.
* Returns { token, error } — exactly one will be non-null.
*/
function useTlsyncToken(accessToken: string | null) {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
logger.debug('multiplayer-page', '🔑 Fetching TLSync token from API');
fetch(`${apiBase}/api/tlsync/token`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then((r) => {
if (!r.ok) throw new Error(`Token request failed: ${r.status}`);
return r.json();
})
.then((data) => {
if (!cancelled) {
logger.debug('multiplayer-page', '✅ TLSync token received', { expiresIn: data.expires_in });
setToken(data.token);
}
})
.catch((err) => {
if (!cancelled) {
logger.error('multiplayer-page', '❌ Failed to fetch TLSync token', { err: err?.message });
setError(err?.message || 'Unknown error');
}
});
return () => {
cancelled = true;
};
}, [accessToken]);
return { token, error };
}
/**
* Loading / error overlay shown while the TLSync token is being fetched.
*/
function TlsyncStatusOverlay({ message }: { message: string }) {
return (
<div
style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
}}
>
{message}
</div>
</div>
);
}
/**
* Inner component that owns the useSync hook.
* Rendered only once a valid TLSync token has been fetched from the API.
*/
function TldrawCanvas({
tlsyncToken,
roomId,
}: {
tlsyncToken: string;
roomId: string;
}) {
const { user } = useAuth();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const {
tldrawPreferences,
setTldrawPreferences,
initializePreferences,
presentationMode
presentationMode,
} = useTLDraw();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const editorRef = useRef<Editor | null>(null);
// Get room ID from URL params
const roomId = searchParams.get('room') || 'multiplayer';
const userInfo = useMemo(
() => ({
id: user?.id ?? '',
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`,
}),
[user?.id, user?.display_name, user?.email, tldrawPreferences?.color],
);
// Memoize user information to ensure consistency
const userInfo = useMemo(() => ({
id: user?.id ?? '',
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
// Create editor user with memoization
const editorUser = useTldrawUser({
userPreferences: {
id: userInfo.id,
@@ -67,18 +145,23 @@ export default function TldrawMultiUser() {
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
isSnapMode: tldrawPreferences?.isSnapMode,
},
setUserPreferences: setTldrawPreferences
setUserPreferences: setTldrawPreferences,
});
const connectionOptions = useMemo(() => createSyncConnectionOptions({
userId: userInfo.id,
displayName: userInfo.name,
color: userInfo.color,
roomId,
baseUrl: SYNC_WORKER_URL
}), [userInfo, roomId]);
const connectionOptions = useMemo(
() =>
createSyncConnectionOptions({
userId: userInfo.id,
displayName: userInfo.name,
color: userInfo.color,
roomId,
baseUrl: SYNC_WORKER_URL,
token: tlsyncToken,
}),
[userInfo, roomId, tlsyncToken],
);
const store = useSync({
...connectionOptions,
@@ -88,19 +171,17 @@ export default function TldrawMultiUser() {
userInfo: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color
}
color: userInfo.color,
},
});
// Log connection status changes
useEffect(() => {
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
status: store.status,
connectionOptions
roomId: connectionOptions.roomId,
});
}, [store.status, connectionOptions]);
}, [store.status, connectionOptions.roomId]);
// Effect for initializing preferences
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.info('multiplayer-page', '🔄 Initializing preferences');
@@ -108,20 +189,11 @@ export default function TldrawMultiUser() {
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Effect for redirecting if user is not authenticated
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
// Effect for presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
presentationService.stopPresentationMode();
cleanup();
@@ -129,47 +201,24 @@ export default function TldrawMultiUser() {
}
}, [presentationMode]);
// Memoize UI overrides and components
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
// Render conditionally to avoid unnecessary rerenders
if (!user) {
return null;
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
}
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
return (
<div style={{
return (
<div
style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)'
}}>
<div style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}>
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
</div>
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Tldraw
user={editorUser}
store={store.store}
@@ -198,3 +247,33 @@ export default function TldrawMultiUser() {
</div>
);
}
export default function TldrawMultiUser() {
const { user, accessToken } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const roomId = searchParams.get('room') || 'multiplayer';
const { token: tlsyncToken, error: tlsyncError } = useTlsyncToken(accessToken);
// Redirect unauthenticated users
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
if (!user) {
return null;
}
if (tlsyncError) {
return <TlsyncStatusOverlay message={`Failed to connect to collaboration server: ${tlsyncError}`} />;
}
if (!tlsyncToken) {
return <TlsyncStatusOverlay message="Connecting to collaboration server..." />;
}
return <TldrawCanvas tlsyncToken={tlsyncToken} roomId={roomId} />;
}
+92 -281
View File
@@ -7,7 +7,7 @@ import {
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore,
TLStoreWithStatus
createTLStore,
} from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useAuth } from '../../contexts/AuthContext';
@@ -15,7 +15,6 @@ import { useUser } from '../../contexts/UserContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
import { ErrorBoundary } from '../../components/ErrorBoundary';
interface LoadingState {
status: 'ready' | 'loading' | 'error';
@@ -63,13 +60,12 @@ export default function SinglePlayerPage() {
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
const storeRef = useRef<TLStore | null>(null);
const [storeReady, setStoreReady] = useState(false);
// TLDraw user preferences
const tldrawUser = useTldrawUser({
@@ -85,248 +81,97 @@ export default function SinglePlayerPage() {
setUserPreferences: setTldrawPreferences
});
// Initialize store — runs as soon as user is ready, no editor needed for store creation
useEffect(() => {
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
const [canvasPhase, setCanvasPhase] = useState<
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
>('idle');
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
// 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(() => {
if (!user) return;
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);
});
const initializeStoreAndSnapshot = async () => {
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');
storeRef.current = store;
setStoreReady(true);
setCanvasPhase('ready');
// 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(
nodeStoragePath,
accessToken || '',
newStore,
setLoadingState,
undefined,
editorRef.current || undefined
);
// Wire auto-save: set the current path on the service instance
snapshotService.setCurrentNodePath(nodeStoragePath);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
node: context.node
});
}
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 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;
}
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
initializeStoreAndSnapshot();
}, [user, context.node]);
// Handle initial node placement
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
useEffect(() => {
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;
}
if (!user || !storeRef.current) 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
});
const store = storeRef.current;
let cancelled = false;
// 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'
});
const loadNodeSnapshot = async () => {
if (!context.node?.node_storage_path) {
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.clearCurrentNode();
setCanvasPhase('ready');
return;
}
setCanvasPhase('snapshot-loading');
try {
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 });
}
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.node_storage_path,
accessToken || '',
store,
setLoadingState,
undefined,
editorRef.current || undefined
);
if (!cancelled) {
// Repair store in case snapshot overwrote TLINSTANCE
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
setCanvasPhase('ready');
}
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'
});
} catch (e) {
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
loadNodeSnapshot();
return () => { cancelled = true; };
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
// 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]);
// Handle navigation changes — save previous snapshot, load next one.
// No automatic node shape placement: the canvas shows only persisted state.
// Inject auth and trigger initial context when token is ready
useEffect(() => {
@@ -485,7 +330,7 @@ export default function SinglePlayerPage() {
top: `${HEADER_HEIGHT}px`,
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
{(loadingState.status === 'loading' || !storeReady) && (
<div style={{
position: 'absolute',
top: 0,
@@ -513,9 +358,15 @@ export default function SinglePlayerPage() {
</Alert>
</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}
store={store}
store={storeRef.current}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
@@ -541,64 +392,24 @@ export default function SinglePlayerPage() {
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
// Update snapshot service with editor reference
if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor);
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
}
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
presentationMode,
isEditorReady: true
});
} catch (error) {
logger.error('single-player-page', '❌ Error in onMount', error);
}
}}
/>}
</ErrorBoundary>
</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,
};
};
+1 -1
View File
@@ -36,7 +36,7 @@ function NotFound() {
gap: 3
}}
>
<ErrorOutline sx={{ fontSize: 60, color: theme.palette.error.main }} />
<ErrorOutlineIcon sx={{ fontSize: 60, color: theme.palette.error.main }} />
<Typography variant="h2" component="h1" gutterBottom>
404
</Typography>
+93 -1
View File
@@ -1,8 +1,10 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Chip,
Container,
Grid,
Paper,
@@ -10,17 +12,28 @@ import {
Typography
} from '@mui/material';
import { useAuth } from '../../contexts/AuthContext';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import { useUser } from '../../contexts/UserContext';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { user: authUser, bootstrapData } = useAuth();
const { profile, loading } = useUser();
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
const emailAddress = profile?.email || authUser?.email || '';
const userType = profile?.user_type || authUser?.user_type || '';
const activeInstitute = bootstrapData?.active_institute;
const activeMembership = bootstrapData?.memberships?.find(
(m) => m.institute_id === activeInstitute?.id && m.is_active
);
const schoolName = activeMembership?.institute?.name;
const onboarding = bootstrapData?.onboarding;
const calendarOk = bootstrapData?.calendar_status?.available && !bootstrapData?.calendar_status?.needs_setup;
const timetableOk = bootstrapData?.timetable_status?.available && !bootstrapData?.timetable_status?.needs_setup;
return (
<Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={6}>
@@ -72,6 +85,12 @@ const DashboardPage: React.FC = () => {
>
Open workspace
</Button>
<Button
variant="outlined"
onClick={() => navigate('/exam-marker')}
>
Exam Marker
</Button>
<Button
variant="outlined"
onClick={() => navigate('/calendar')}
@@ -88,6 +107,79 @@ const DashboardPage: React.FC = () => {
</Paper>
</Grid>
</Grid>
{/* Bootstrap Status Section */}
{bootstrapData && (
<Stack spacing={3}>
<Typography variant="h5" component="h2">
Your school
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Institute
</Typography>
<Stack spacing={1}>
<Typography variant="body1" fontWeight={600}>
{schoolName || 'No school assigned'}
</Typography>
{activeInstitute?.membership_role && (
<Chip
label={activeInstitute.membership_role.replace(/_/g, ' ')}
size="small"
color="primary"
variant="outlined"
/>
)}
<Typography variant="body2" color="text.secondary">
Status: {bootstrapData.school_status}
</Typography>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>
System status
</Typography>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{calendarOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
<Typography variant="body2">
Calendar: {calendarOk ? 'Ready' : 'Needs setup'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{timetableOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
<Typography variant="body2">
Timetable: {timetableOk ? 'Ready' : 'Needs setup'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{bootstrapData?.graph_status?.available ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
<Typography variant="body2">
Graph projection: {bootstrapData?.graph_status?.projection_state || 'unknown'}
</Typography>
</Box>
</Stack>
</Paper>
</Grid>
</Grid>
{onboarding && onboarding.next_step !== 'complete' && (
<Alert severity="info" sx={{ mt: 1 }}>
<Typography variant="body2" fontWeight={600}>
{onboarding.message}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
Next step: {onboarding.next_step.replace(/_/g, ' ')}
</Typography>
</Alert>
)}
</Stack>
)}
</Stack>
</Container>
);
+123
View File
@@ -0,0 +1,123 @@
import { logger } from '../../debugConfig';
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
export interface BootstrapProfile {
id: string;
email: string;
display_name: string;
user_type: string;
school_id: string | null;
}
export interface BootstrapMembership {
institute_id: string;
role: string;
status: string;
is_active: boolean;
institute: {
id: string;
name: string;
urn: string | null;
};
}
export interface BootstrapActiveInstitute {
id: string | null;
source: string;
membership_role: string | null;
}
export interface BootstrapPermissions {
platform_admin: boolean;
platform_super_admin: boolean;
can_create_school: boolean;
can_manage_school: boolean;
can_manage_calendar: boolean;
can_manage_timetable: boolean;
can_invite_staff: boolean;
can_manage_classes: boolean;
can_view_student_data: boolean;
can_use_canvas: boolean;
}
export interface BootstrapOnboarding {
next_step: string;
required: string[];
optional: string[];
message: string;
}
export interface BootstrapCalendarStatus {
available: boolean;
academic_year_count: number;
term_count: number;
current_academic_year_id: string | null;
needs_setup: boolean;
}
export interface BootstrapTimetableStatus {
available: boolean;
teacher_timetable_id: string | null;
slot_count: number;
needs_setup: boolean;
}
export interface BootstrapGraphStatus {
available: boolean;
user_db: string | null;
institute_db: string | null;
projection_state: string;
needs_rebuild: boolean;
last_checked_at: string | null;
error_code: string | null;
}
export interface BootstrapResponse {
profile: BootstrapProfile;
memberships: BootstrapMembership[];
active_institute: BootstrapActiveInstitute;
permissions: BootstrapPermissions;
school_status: string;
onboarding: BootstrapOnboarding;
calendar_status: BootstrapCalendarStatus;
timetable_status: BootstrapTimetableStatus;
graph_status: BootstrapGraphStatus;
}
let cachedBootstrap: BootstrapResponse | null = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 60_000; // 60 second cache
export async function fetchBootstrapData(accessToken: string): Promise<BootstrapResponse | null> {
if (!accessToken) return null;
// Return cached data if fresh
if (cachedBootstrap && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
return cachedBootstrap;
}
try {
const res = await fetch(`${apiBase}/me/bootstrap`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
logger.warn('bootstrap-service', `Bootstrap fetch failed: ${res.status}`);
return cachedBootstrap; // Return stale cache on error
}
const data: BootstrapResponse = await res.json();
cachedBootstrap = data;
cacheTimestamp = Date.now();
return data;
} catch (err) {
logger.error('bootstrap-service', 'Bootstrap fetch error', { err });
return cachedBootstrap; // Return stale cache on error
}
}
export function invalidateBootstrapCache(): void {
cachedBootstrap = null;
cacheTimestamp = 0;
}
+261
View File
@@ -0,0 +1,261 @@
/**
* 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 {
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate,
ExamTemplateDetail,
MarkingBatch,
MarkUpsertPayload,
Neo4jSyncResult,
PatchQuestionPayload,
SpecPoint,
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,
};
}
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,
};
}
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,
};
}
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)),
},
{ 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 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;
},
};
export default examRepository;
+25 -4
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import { supabase } from '../supabaseClient';
import {
Class,
ClassWithRole,
ClassWithRelations,
ClassFilters,
Timetable,
@@ -14,7 +15,7 @@ import {
} from '../types/timetable.types';
// API base URL
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ============================================================================
// Helper: Get auth headers
@@ -79,10 +80,30 @@ export const classService = {
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
},
async getMyClasses(): Promise<Class[]> {
async getMyClasses(): Promise<ClassWithRole[]> {
const headers = await getAuthHeaders();
const response = await axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers });
return response.data.classes;
const [teacherRes, studentRes] = await Promise.allSettled([
axios.get(`${API_BASE}/database/timetable/classes/me/teacher`, { headers }),
axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers }),
]);
const teacherClasses: ClassWithRole[] = teacherRes.status === 'fulfilled'
? (teacherRes.value.data.classes || []).map((c: Class) => ({
...c,
class_id: c.id,
role: 'teacher' as const,
status: 'active',
}))
: [];
const studentClasses: ClassWithRole[] = studentRes.status === 'fulfilled'
? (studentRes.value.data.classes || []).map((c: Class) => ({
...c,
class_id: c.id,
role: 'student' as const,
status: 'active',
}))
: [];
const seen = new Set(teacherClasses.map(c => c.id));
return [...teacherClasses, ...studentClasses.filter(c => !seen.has(c.id))];
},
async getMyTeachingClasses(): Promise<Class[]> {
+17 -11
View File
@@ -19,12 +19,17 @@ export class SharedStoreService {
private lastSaveTime: number = Date.now();
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
private config: AutoSaveConfig;
private isDirty = false;
private dirtyListener: (() => void) | null = null;
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
this.config = {
...DEFAULT_CONFIG,
...config
};
this.dirtyListener = store.listen(() => {
this.isDirty = true;
});
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
}
@@ -52,18 +57,16 @@ export class SharedStoreService {
}
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
const currentSnapshot = getSnapshot(this.store);
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (!this.isDirty) {
return;
}
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 {
this.stopAutoSave();
this.dirtyListener?.();
this.dirtyListener = null;
this.store.clear();
this.isDirty = false;
logger.debug('shared-store-service', '🧹 Store cleared');
}
}
+25 -6
View File
@@ -1,5 +1,5 @@
// 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 { SharedStoreService } from './sharedStoreService';
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
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 = {
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snap.document,
session: snap.session,
};
const targetStore = editor ? editor.store : store;
try {
if (editor) {
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
} else {
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
loadSnapshot(targetStore, snapshotCopy as any);
// Post-load validation: ensure instance.currentPageId is still valid
const instance = targetStore.get(TLINSTANCE_ID);
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');
} catch (err) {
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
if (isSchemaMigration) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', { error: msg });
} 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();
}
}
+4 -4
View File
@@ -15,6 +15,7 @@ export interface SyncConnectionOptions {
color: string;
roomId?: string;
baseUrl: string;
token?: string;
}
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
@@ -22,8 +23,8 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
userId,
displayName,
roomId = 'multiplayer',
baseUrl
baseUrl,
token
} = options;
// Ensure we have valid user info
@@ -72,8 +73,7 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
roomId: effectiveRoomId
});
const token = import.meta.env.VITE_TLSYNC_SECRET ?? ''
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
return {
uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`,
+13
View File
@@ -1,3 +1,16 @@
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
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))
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import {
Class,
ClassWithRole,
ClassWithRelations,
Timetable,
TimetableWithRelations,
@@ -21,7 +22,7 @@ interface TimetableState {
// Classes
classes: Class[];
currentClass: ClassWithRelations | null;
myClasses: Class[];
myClasses: ClassWithRole[];
myTeachingClasses: Class[];
classesLoading: boolean;
classesError: string | null;
+5 -5
View File
@@ -577,7 +577,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
set({ isExporting: true, exportError: null });
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, {
method: 'POST',
headers: {
@@ -627,7 +627,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
try {
const { _accessToken: token } = get();
if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
headers: { 'Authorization': `Bearer ${token}` },
});
@@ -643,7 +643,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
try {
const { _accessToken: token, _userId: userId } = get();
if (!token || !userId) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
method: 'POST',
headers: {
@@ -669,7 +669,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
try {
const { _accessToken: token } = get();
if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
@@ -709,7 +709,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
if (activeSession) {
try {
const { _accessToken: kwToken } = get();
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
method: 'POST',
headers: {
+233
View File
@@ -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; }
+268
View File
@@ -0,0 +1,268 @@
/**
* 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 interface ExamQuestion {
id: string;
template_id: string;
parent_id: string | null;
label: string;
order: number;
max_marks: number;
answer_type: string | null;
mcq_options: unknown | null;
mark_scheme: MarkScheme;
is_container: boolean;
spec_ref: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
}
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
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: 'manual' | 'ai';
confirmed: boolean;
confidence: number | null;
}
export interface ExamBoundary {
id: string;
template_id: string;
question_id: string | null;
label: string | null;
page_index: number;
y: number;
bounds: Record<string, number> | null;
source: 'manual' | 'ai';
confirmed: boolean;
}
export interface ExamTemplateDetail extends ExamTemplate {
questions: ExamQuestion[];
response_areas: ExamResponseArea[];
boundaries: ExamBoundary[];
}
export interface 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;
}>;
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;
}>;
boundaries: Array<{
id?: string;
question_id?: string | null;
label?: string | null;
page_index: number;
y: number;
bounds?: Record<string, number> | null;
source?: 'manual' | 'ai';
confirmed?: boolean;
}>;
}
export interface 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 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;
}
+9
View File
@@ -27,6 +27,15 @@ export interface Class {
created_at: string;
updated_at: string;
is_active: boolean;
code?: string;
institute_id?: string;
}
export interface ClassWithRole extends Class {
role: 'teacher' | 'student' | 'assistant';
status: string;
class_id?: string;
is_primary_teacher?: boolean;
}
export interface ClassTeacher {
+91
View File
@@ -0,0 +1,91 @@
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: [],
}
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.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 },
{ 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 },
],
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 },
{ 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 },
],
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 }],
})
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
})
it('round-trips boundary y as page-local with page_index while rendering at full page width', () => {
const pages = [
{ pageNumber: 1, x: 0, y: 0, w: 780, h: 1000 },
{ pageNumber: 2, x: 0, y: 1000, w: 820, h: 1200 },
]
const shapes = shapesFromTemplate({
...template,
page_count: 2,
boundaries: [
{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 1, y: 160, bounds: { x: 48, y: 160, w: 700, h: 8 }, source: 'manual', confirmed: true },
{ id: 'b2', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 end', page_index: 1, y: 720, bounds: null, source: 'manual', confirmed: true },
],
}, pages)
expect(shapes[0]).toMatchObject({ kind: 'boundary', x: 0, y: 1160, w: 820, h: 8 })
expect(shapes[1]).toMatchObject({ kind: 'boundary', x: 0, y: 1720, w: 820, h: 8 })
const payload = serializeCanvasShapes(template, shapes, pages)
expect(payload.boundaries.map((b) => ({ page_index: b.page_index, y: b.y }))).toEqual([
{ page_index: 1, y: 160 },
{ page_index: 1, y: 720 },
])
expect(payload.boundaries.every((b) => b.bounds.w === 820)).toBe(true)
})
})
+150
View File
@@ -0,0 +1,150 @@
import type { ExamTemplateDetail, 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 { 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
}
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 {
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
}
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: {} })
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: bounds(b), source: 'manual', confirmed: true })
}
}
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) })
})
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: 'manual', confirmed: true, confidence: null })
}
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
}
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 bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
}
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 ?? 'written', questionId: q.id })
}
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 ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
}
return shapes
}
+13 -53
View File
@@ -1,7 +1,6 @@
import React from 'react'
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
import { CCBaseShape } from './cc-types'
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
import { logger } from '../../../debugConfig'
export interface ToolbarItem {
@@ -40,44 +39,32 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
component(shape: T) {
const {
props: { w, h, isLocked },
props: { w, h, isLocked, headerColor },
} = shape
const toolbarItems = this.getToolbarItems(shape)
return (
<HTMLContainer
id={shape.id}
className="cc-shape-container"
style={{
width: toDomPrecision(w),
height: toDomPrecision(h),
backgroundColor: shape.props.headerColor,
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
overflow: 'hidden',
position: 'relative',
}}
'--shape-header-color': headerColor,
} as React.CSSProperties}
>
{/* Header */}
<div
style={{
backgroundColor: shape.props.headerColor,
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,
}}
className="cc-shape-header"
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
>
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
<span className="shape-title">{shape.props.title}</span>
<div className="cc-shape-toolbar">
{toolbarItems.map((item) => (
<button
key={item.id}
title={item.label}
style={{ opacity: item.isActive ? 1 : 0.7 }}
onClick={(e) => {
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
e.preventDefault()
@@ -89,46 +76,19 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
e.preventDefault()
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' }}>
{item.icon}
</div>
</button>
))}
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
{isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
</div>
</div>
{/* Content */}
<div
style={{
position: 'absolute',
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,
}}
<div
className="cc-shape-content"
style={{ backgroundColor: shape.props.backgroundColor }}
>
{this.renderContent(shape)}
</div>
@@ -5,6 +5,7 @@
import { Editor, TLStoreEventInfo } from '@tldraw/tldraw';
import { useTranscriptionStore } from '../../stores/transcriptionStore';
import { API_BASE } from '../../../../config/apiConfig';
export class CanvasEventLogger {
private editor: Editor | null = null;
@@ -221,7 +222,7 @@ export class CanvasEventLogger {
this.buffer = [];
try {
const response = await fetch('http://192.168.0.64:8000/transcribe/canvas-events', {
const response = await fetch(`${API_BASE}/transcribe/canvas-events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -17,10 +17,16 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCTimetableLessonNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
headerColor: '',
school_db_name: '',
school_period_id: '',
subject_class: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
}
}
@@ -29,34 +35,34 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
renderContent = (shape: CCTimetableLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
<NodeProperty
label="Subject Class"
value={shape.props.subject_class}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
@@ -65,4 +71,4 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
</div>
)
}
}
}
@@ -543,6 +543,8 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
start_time: '',
end_time: '',
period_code: '',
school_db_name: '',
school_period_id: '',
})
export const getDefaultCCPlannedLessonNodeProps = () => ({
@@ -22,7 +22,6 @@ import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShape
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
@@ -31,12 +30,11 @@ import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademic
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
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
export const NODE_SHAPE_TYPES = {
USER: CCUserNodeShapeUtil.type,
@@ -82,7 +80,7 @@ export const NODE_SHAPE_TYPES = {
export type NodeShapeType = typeof NODE_SHAPE_TYPES[keyof typeof NODE_SHAPE_TYPES];
// Define AllNodeShapes as a union type of all shape types
export type AllNodeShapes =
export type AllNodeShapes =
| CCUserNodeShape
| CCTeacherNodeShape
| CCStudentNodeShape
@@ -118,8 +116,7 @@ export type AllNodeShapes =
| CCTimetableLessonNodeShape
| CCPlannedLessonNodeShape
| CCDepartmentStructureNodeShape
| CCUserTeacherTimetableNodeShape
| CCTimetableLessonNodeShape;
| CCUserTeacherTimetableNodeShape;
// Export all shape utils in an object for easy access
export const ShapeUtils = {
@@ -159,17 +156,16 @@ export const ShapeUtils = {
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
} as const;
// Add a type guard to check if a shape is a valid node shape
export const isValidNodeShape = (shape: TLShape): shape is AllNodeShapes => {
return shape &&
typeof shape.type === 'string' &&
return shape &&
typeof shape.type === 'string' &&
Object.values(NODE_SHAPE_TYPES).includes(shape.type as NodeShapeType);
};
// Add a type guard to check if a type string is a valid node type
export const isValidNodeType = (type: string): type is NodeShapeType => {
return Object.values(NODE_SHAPE_TYPES).includes(type as NodeShapeType);
};
};
@@ -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 { ccShapeMigrations } from '../cc-migrations';
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
import { logger } from '../../../../debugConfig';
export interface TranscriptionSegment {
id: string
@@ -83,7 +84,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
height: '48px',
borderRadius: '50%',
border: 'none',
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
color: 'white',
fontSize: '24px',
cursor: 'pointer',
@@ -105,7 +106,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: isRecording ? '#f44336' : '#4CAF50'
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
}}>
{isRecording ? 'Recording...' : 'Ready'}
</div>
@@ -207,11 +208,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
}
private toggleRecording(shape: CCLiveTranscriptionShape) {
console.log('🎤 Toggle recording clicked');
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
const { id } = shape;
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
const newProps = !isRecording ? {
@@ -232,13 +233,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
});
const manager = TranscriptionManager.getManager(this.editor);
console.log('Got transcription manager');
logger.debug('cc-live-transcription', 'Got transcription manager');
if (!isRecording) {
console.log('Starting transcription...');
logger.debug('cc-live-transcription', 'Starting transcription...');
manager.startTranscription(id);
} else {
console.log('Stopping transcription...');
logger.debug('cc-live-transcription', 'Stopping transcription...');
manager.stopTranscription();
}
}
@@ -249,10 +250,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
isConfirmed: boolean,
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);
if (!shape) {
console.warn('❌ Shape not found for updating text:', id);
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
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();
// Set up callback for transcription updates
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
console.log('📝 Transcription update received:', { text, metadata });
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
if (!util) {
console.warn('❌ Shape util not found');
@@ -36,6 +36,10 @@ export class TranscriptionManager {
}
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)
const isStable = text === this.lastText;
if (isStable) {
@@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
overflowY: 'auto',
maxHeight: '100%',
minWidth: '200px',
backgroundColor: '#f5f5f5',
borderLeft: '1px solid #ddd',
backgroundColor: 'var(--cc-navy-50)',
borderLeft: '1px solid var(--cc-border)',
},
title: {
margin: '0 0 10px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333',
color: 'var(--cc-text-primary)',
},
line: {
padding: '5px',
marginBottom: '5px',
borderRadius: '4px',
fontSize: '14px',
color: '#333',
backgroundColor: '#fff',
border: '1px solid #eee',
color: 'var(--cc-text-primary)',
backgroundColor: 'var(--cc-bg-canvas)',
border: '1px solid var(--cc-border-strong)',
},
activeLine: {
backgroundColor: '#e3f2fd',
border: '1px solid #2196f3',
color: '#1565c0',
backgroundColor: 'var(--cc-navy-50)',
border: '1px solid var(--cc-action-primary)',
color: 'var(--cc-action-primary)',
fontWeight: 'bold',
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
transform: 'scale(1.02)',
transition: 'all 0.2s ease-in-out',
},
timestamp: {
color: '#666',
color: 'var(--cc-text-tertiary)',
marginRight: '8px',
fontWeight: 'bold',
},
loading: {
color: '#666',
color: 'var(--cc-text-muted)',
fontStyle: 'italic',
},
},
@@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
},
button: {
padding: '8px 12px',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
backgroundColor: 'var(--cc-navy-50)',
border: '1px solid var(--cc-border)',
borderRadius: '4px',
cursor: 'pointer',
color: '#333',
color: 'var(--cc-text-primary)',
fontSize: '14px',
'&:hover': {
backgroundColor: '#e0e0e0',
backgroundColor: 'var(--cc-border-strong)',
},
},
},
+976
View File
@@ -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);
}
+11 -1
View File
@@ -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('./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,18 +1,15 @@
import React, { useState } from 'react';
import { BasePanel } from './shared/BasePanel';
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
import { BaseContext, ViewContext } from '../../../../types/navigation';
interface CCPanelProps {
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onPinnedChange?: (pinned: boolean) => void;
}
export const CCPanel: React.FC<CCPanelProps> = ({
examMarkerProps,
export const CCPanel: React.FC<CCPanelProps> = ({
isExpanded,
isPinned,
onExpandedChange,
@@ -31,8 +28,7 @@ export const CCPanel: React.FC<CCPanelProps> = ({
};
return (
<BasePanel
examMarkerProps={examMarkerProps}
<BasePanel
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={handleExpandedChange}
@@ -1,41 +1,14 @@
import React, { useEffect, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import React, { useEffect, useRef, useMemo, useState } from 'react';
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 { CCSlidesPanel } from './CCSlidesPanel';
import { CCFilesPanel } from './CCFilesPanel';
import { CCCabinetsPanel } from './CCCabinetsPanel';
import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
import { CCSearchPanel } from './CCSearchPanel'
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
import { CCSearchPanel } from './CCSearchPanel';
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
import './panel.css';
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
@@ -56,16 +29,12 @@ export const PANEL_TYPES = {
{ id: 'graph', label: 'Graph', order: 60 },
{ id: 'search', label: 'Search', order: 70 },
],
examMarker: [
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
],
} as const;
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
interface BasePanelProps {
initialPanelType?: PanelType;
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
@@ -78,49 +47,95 @@ interface BasePanelProps {
onMenuOpenChange?: (open: boolean) => void;
}
const PanelTypeButton = styled(Button)(() => ({
textTransform: 'none',
padding: '6px 12px',
gap: '8px',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
border: '1px solid transparent',
transition: 'border-color 200ms ease',
justifyContent: 'space-between',
minWidth: '200px',
'&:hover': {
backgroundColor: 'var(--color-panel)',
borderColor: 'var(--color-text)',
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
color: 'inherit',
function getIconSvg(panelId: PanelType, size = '1.25rem') {
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 };
switch (panelId) {
case 'cabinets':
return (
<svg {...common}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
case 'transcription':
return (
<svg {...common}>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<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)(() => ({
gap: '8px',
padding: '8px 16px',
transition: 'background-color 200ms ease',
'&: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',
}
}
}));
function isDarkMode() {
if (typeof window === 'undefined') return false;
return document.documentElement.dataset.colorMode === 'dark' ||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'files',
examMarkerProps,
isExpanded: controlledIsExpanded,
isPinned: controlledIsPinned,
onExpandedChange,
@@ -128,47 +143,37 @@ export const BasePanel: React.FC<BasePanelProps> = ({
isMenuOpen = false,
onMenuOpenChange = () => {},
}) => {
const location = useLocation();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
return createTheme({
palette: {
mode,
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setPrefersDarkMode(isDarkMode());
mq.addEventListener?.('change', handler);
return () => mq.removeEventListener?.('change', handler);
}, []);
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
? 'dark'
: 'light';
const availablePanels = PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
const [internalIsPinned, setInternalIsPinned] = useState(true);
const isExamMarkerRoute = location.pathname === '/exam-marker';
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
isExamMarkerRoute ? 'exam-marker' : initialPanelType
);
// 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 isPinned = controlledIsPinned ?? internalIsPinned;
const handleExpandedChange = (expanded: boolean) => {
setInternalIsExpanded(expanded);
onExpandedChange?.(expanded);
};
const handlePinToggle = () => {
const newPinned = !isPinned;
setInternalIsPinned(newPinned);
@@ -178,92 +183,25 @@ export const BasePanel: React.FC<BasePanelProps> = ({
const panelRef = useRef<HTMLDivElement>(null);
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Don't close if pinned
if (isPinned) return;
// Check if click is outside panel
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 isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
if (isClickOutside && !isPanelElement) {
handleExpandedChange(false);
}
};
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [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 = () => {
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
}
switch (currentPanelType) {
case 'cabinets':
return <CCCabinetsPanel />;
@@ -288,22 +226,15 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}
};
// Handle menu button click
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
onMenuOpenChange(true);
};
// Handle menu close
const handleMenuClose = () => {
setMenuAnchorEl(null);
onMenuOpenChange(false);
const toggleMenu = (open: boolean) => {
setMenuOpen(open);
onMenuOpenChange(open);
};
return (
<>
{!isExpanded && (
<div
<div
className="panel-handle"
onClick={() => handleExpandedChange(true)}
onTouchEnd={(e) => {
@@ -316,7 +247,7 @@ export const BasePanel: React.FC<BasePanelProps> = ({
)}
{isExpanded && (
<div
<div
ref={panelRef}
className="panel-root"
style={{
@@ -327,55 +258,60 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}}
>
<div className="panel-header">
<ThemeProvider theme={theme}>
<PanelTypeButton
onClick={handleMenuClick}
endIcon={<ExpandMoreIcon />}
startIcon={getIconForPanel(currentPanelType)}
>
<button
type="button"
className="cc-btn cc-btn--secondary cc-panel-selector"
onClick={() => toggleMenu(!menuOpen)}
>
<span className="cc-btn-start-icon">
{getIconSvg(currentPanelType)}
</span>
<span className="cc-btn-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
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
PaperProps={{
elevation: 8,
sx: {
border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)',
}
}}
>
{menuOpen && (
<div className="cc-menu panel-type-menu" role="menu">
{[...availablePanels]
.sort((a, b) => a.order - b.order)
.map(type => (
<StyledMenuItem
key={type.id}
onClick={() => {
setCurrentPanelType(type.id as PanelType);
handleMenuClose();
}}
selected={currentPanelType === type.id}
>
<ListItemIcon>
{getIconForPanel(type.id as PanelType)}
</ListItemIcon>
<ListItemText
primary={type.label}
secondary={getDescriptionForPanel(type.id as PanelType)}
primaryTypographyProps={{
sx: { color: 'var(--color-text)' }
<button
key={type.id}
type="button"
role="menuitem"
className={`cc-menu-item ${type.id === currentPanelType ? 'cc-menu-item--active' : ''}`}
onClick={() => {
setCurrentPanelType(type.id as PanelType);
toggleMenu(false);
}}
secondaryTypographyProps={{
sx: { color: 'var(--color-text-secondary)' }
}}
/>
</StyledMenuItem>
))}
</Menu>
</ThemeProvider>
>
<span className="cc-menu-item-icon">
{getIconSvg(type.id as PanelType)}
</span>
<span className="cc-menu-item-text">
<span className="cc-menu-item-primary">{type.label}</span>
<span className="cc-menu-item-secondary">
{type.id === 'cabinets' && 'Manage file cabinets'}
{type.id === 'transcription' && 'Record and transcribe lessons'}
{type.id === 'cc-shapes' && 'Add shapes and elements to your canvas'}
{type.id === 'slides' && 'Manage presentation slides'}
{type.id === 'youtube' && 'Embed YouTube videos'}
{type.id === 'graph' && 'View and manage graph connections'}
{type.id === 'search' && 'Search through your content'}
{type.id === 'navigation' && 'Navigate through different contexts'}
{type.id === 'node-snapshot' && 'Manage node snapshots'}
</span>
</span>
</button>
))}
</div>
)}
<div className="panel-header-actions">
<TldrawUiButton
@@ -383,18 +319,38 @@ export const BasePanel: React.FC<BasePanelProps> = ({
onClick={handlePinToggle}
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>
</div>
</div>
<ThemeProvider theme={theme}>
<div className="panel-content">
{renderCurrentPanel()}
</div>
</ThemeProvider>
</div>
)}
</>
);
};
};
@@ -26,7 +26,7 @@ export const CCCabinetsPanel: React.FC = () => {
return createTheme({ palette: { mode, divider: 'var(--color-divider)' } });
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const API_BASE: string = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
const API_BASE: string = import.meta.env.VITE_API_BASE || '/api';
type RequestInitLite = { method?: string; body?: string | FormData | Blob | null; headers?: Record<string, string> } | undefined;
const apiFetch = useCallback(async (url: string, init?: RequestInitLite) => {

Some files were not shown because too many files have changed in this diff Show More