Compare commits

...
Author SHA1 Message Date
CC WorkerandClaude Opus 4.8 f72ea7189c FX-4: confirm/dismiss UI for AI ghost suggestions
The S5-7 review layer could surface unconfirmed AI shapes but had no way to
resolve them (confirmed was never set true), so the only "dismiss" was deletion
and the unconfirmed count never dropped. Add the resolution loop:

- A floating review-actions panel (shown while unconfirmed > 0): Accept selected,
  Reject (delete) selected, and Accept all AI on the page. Accept flips
  props.confirmed = true.
- Human edit = implicit confirm: nudging/resizing an AI ghost marks it confirmed
  (R3.3 — a teacher who adjusts a suggestion has reviewed it). Geometry-only
  trigger, so the confirmed-only update can't recurse.
- Confidence-driven opacity: unconfirmed ghosts fade by confidence instead of a
  constant 0.72; confirmed AI renders solid with its kind icon (like manual).
- Live review counts read straight off the canvas so the unconfirmed chip/panel
  update immediately on accept/reject (the old count reflected only saved state).

Persists through the existing PUT (confirmed already round-trips). No schema or
API change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:08:38 +00:00
CC WorkerandClaude Opus 4.8 7bd66fbaf0 fix(exam): stop placeholder guide shapes flashing before template loads
app-ci-deploy / test-build-deploy (push) Has been cancelled
seedGuide() ran in onMount via the 'else' branch while template was still null during the async
fetch, creating 5 example shapes (Q1 start/end, part, response, context) that flashed on screen
until the real template + PDF loaded. Only seed the guide for a genuinely-empty template after load.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-08 19:19:15 +00:00
kcar 9c3a5f97cc docs(admin): clarify exam-corpus reset warning
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-08 00:58:03 +01:00
CC WorkerandClaude Opus 4.8 2ccfb9ccd6 fix(dev): route Supabase same-origin via /__supabase proxy
app-ci-deploy / test-build-deploy (push) Has been cancelled
app-dev built with --mode production was baking the PROD Supabase URL (.env)
into the bundle, so browser auth went cross-origin to supa.classroomcopilot.ai
and was CORS-blocked (and hit the wrong user store). Mirror the /__ccapi fix:
- Dockerfile: nginx /__supabase/ -> dev Supabase .94:8000 (+WS upgrade for realtime)
- supabaseClient.ts: resolve a leading-slash VITE_SUPABASE_URL against
  window.location.origin so supabase-js gets an absolute same-origin URL
- docker-compose.dev.yml: bake VITE_SUPABASE_URL=/__supabase (like VITE_API_BASE)
Browser now talks only to the app host (Tailscale or LAN), no CORS, dev .94 store.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 21:20:57 +00:00
CC Worker ef13a124dd Merge S5-7 G6 review wiring
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/pages/exam/setup/ExamTemplateSetupPage.tsx
#	src/pages/exam/setup/examCanvasShapes.tsx
#	src/utils/exam-canvas/model.ts
2026-06-07 20:06:45 +00:00
CC Worker ffa0ad85ac Merge S4-11: marking flow + class results + same-origin API proxy fix
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:04:07 +00:00
kcarandClaude Opus 4.8 92f9dfef82 S5-7: basic G6 review wiring (dashed/translucent AI shapes, confidence, flags)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 20:56:57 +01:00
kcar 824031f2c0 feat(exam): add auto-map PDF canvas refresh
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:46:41 +01:00
kcar 7db852aaff [verified] route app-dev API through same-origin proxy 2026-06-07 20:43:20 +01:00
CC Worker afc0371dd9 Merge S5-6 layout/provenance types + repository (App)
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/utils/exam-canvas/model.ts
2026-06-07 19:22:24 +00:00
kcar 7326b9f3be [verified] fix app-dev api proxy and build env 2026-06-07 20:20:42 +01:00
kcar cd8ac38d39 Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results 2026-06-07 20:13:43 +01:00
kcar 469bcc0517 [verified] align app exam layout payloads 2026-06-07 20:05:49 +01:00
kcar e899af303d Make exam boundaries page-width lines
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:01:54 +01:00
kcar 7a01b3e8f6 Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results
# Conflicts:
#	src/AppRoutes.tsx
#	src/pages/exam/index.ts
#	src/services/exam/examRepository.ts
#	src/types/exam.types.ts
2026-06-07 19:58:38 +01:00
kcar bff91a4b17 feat(exam): S4-11 marking flow and class results 2026-06-07 19:50:20 +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
kcarandClaude Sonnet 4.6 61ef95a35e fix(nav): register Journal/Planner shapes, fix headerColor crash, enable academic week expansion
Bugs fixed:
- 'cc-teacher-timetable-node' missing from NODE_TYPE_THEMES caused
  Cannot read properties of undefined (reading 'headerColor') crash
  when clicking My Timetable
- 'cc-journal-node' and 'cc-planner-node' had no shape utils registered,
  causing 'No shape util found for type' error on Journal/Planner click
- Added null safety (?? fallback) to getNodeTheme to prevent future crashes
  from any other unmapped type
- Removed AcademicWeek from canExpand exclusion so weeks can be expanded
  to show individual academic days

Added:
- CCJournalNodeShapeUtil and CCPlannerNodeShapeUtil (stub shapes)
- CCJournalNodeProps and CCPlannerNodeProps types
- Journal/Planner added to CCNodeTypes, ccGraphShapeProps, NODE_TYPE_THEMES
- 'cc-department-structure-node' mapping added to NODE_TYPE_THEMES

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 10:55:40 +01:00
kcarandClaude Sonnet 4.6 0f5dbd12bf feat(phase-c): lesson plans library — browse, create, and edit lesson plans
Adds LessonPlansPage (card grid with search/filter, create dialog) and
LessonPlanDetailPage (structured editor with objectives, activities, Bloom
taxonomy tags, per-field AI suggest via  button, and auto-save).

Routes: /lesson-plans and /lesson-plans/:planId wired into AppRoutes.
Nav: Lesson Plans item added to Header menu under Timetable & Classes.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 03:59:44 +01:00
kcarandClaude Sonnet 4.6 6ac5ab7b5c feat(phase-b): student enrollment UI, teacher/student management pages, lesson views
- ClassDetailPage: full rewrite with MUI tabs (students, enrollment requests, teachers),
  add/remove students, approve/reject enrollment requests, AddStudentDialog
- StudentLessonsPage: new — student's weekly lesson view with week navigation
- TaughtLessonsPage: teacher's taught lesson week view
- SchoolSettingsPage, StaffManagerPage, StudentManagerPage: school admin management pages
- PlatformAdminPage: platform admin reset/seed controls
- Header: expanded nav menu (student lessons, school management, platform admin items)
- AppRoutes: routes for all new pages
- SchoolCalendarWizard, TeacherTimetableWizard: week_cycle support and improvements
- CCGraphNavPanel: updated navigation integration
- index.ts: export all new timetable pages

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-27 02:56:04 +01:00
kcarandClaude Sonnet 4.6 138dfb1531 chore: add .dockerignore to exclude node_modules from build context
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 02:03:17 +01:00
kcarandClaude Sonnet 4.6 9d78f06c97 feat(phase-b): school onboarding wizard + GAIS school search UI
- SchoolOnboardingWizard.tsx: new 3-step wizard (GAIS search → confirm →
  calendar setup) triggered from the nav panel school section when no school
  is linked. Calls GET /school/search for live school lookup and
  POST /school/register to create the institute record.
- CCGraphNavPanel.tsx: add showOnboard button for no_school state,
  onOnboardSchool context action, wire up SchoolOnboardingWizard state.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:56:37 +01:00
kcarandClaude Sonnet 4.6 b0b2a7f2c3 feat(phase-a+b): merge clean auth + Supabase navigation to master
Merges Phase A (Neo4j removed from startup) and Phase B (Supabase navigation,
school/timetable setup, graph nav panel) into master.

Phase A: NeoUserProvider removed, CCUser simplified, snapshotService null-safe
Phase B: navigationStore → Supabase whiteboard_rooms, snapshotService → Supabase Storage,
  CCGraphNavPanel, SchoolCalendarWizard, TeacherTimetableWizard, CIS auth fixes

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:25:41 +01:00
kcarandClaude Sonnet 4.6 83adcce951 feat(phase-b): school/timetable wizards, graph nav panel, UI updates
New components:
- CCGraphNavPanel: Supabase-driven navigation tree (school/timetable/calendar/classes sections),
  role-aware setup buttons, lazy child loading, academic/generic calendar toggle
- SchoolCalendarWizard: 3-step admin-only school setup (details → term dates → daily periods)
- TeacherTimetableWizard: period grid with existing slot pre-loading, edit-mode title

Updated:
- CCNodeSnapshotPanel: saves via Supabase storage path + accessToken
- BasePanel: nav panel tab wired to CCGraphNavPanel
- CCFilesPanelEnhanced: auth context fixes
- CCDocumentIntelligence suite: accessToken threading, Supabase storage integration

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:25:29 +01:00
kcarandClaude Sonnet 4.6 b0c7758135 feat(phase-b): Supabase navigation store, snapshot service, auth wiring
navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 01:25:15 +01:00
kcarandClaude Sonnet 4.6 3a65cf436b fix(panels): eliminate getSession() from transcription store and cabinets panel
Same GoTrueClient lock contention fix as CCFilesPanel:
- transcriptionStore: add _accessToken/_userId state + setAuthInfo() action;
  replace all 6 getSession() calls (startSession, flushCanvasEvents, loadSessions,
  loadKeywordWatches, addKeywordWatch, deleteKeywordWatch, checkSegmentForKeywords)
  with stored values — zero getSession() calls remain in the store
- CCTranscriptionPanel: destructure accessToken from useAuth; sync both values into
  store via setAuthInfo() on every auth change; gate loadSessions on authUser.id
- CCCabinetsPanel: same pattern as CCFilesPanel — useAuth for token, useCallback on
  apiFetch/loadCabinets, gate effect on authUser.id

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 15:13:37 +01:00
kcarandClaude Sonnet 4.6 6bd85671d2 fix(panels): bypass GoTrueClient lock — expose accessToken via AuthContext, gate panels on auth
Root cause: apiFetch called supabase.auth.getSession() on every API request, acquiring
the GoTrueClient internal lock. On refresh, concurrent mount of panels caused lock
contention — the loadCabinets/loadSessions fetch awaits hung, loading spinner never
cleared.

- AuthContext: add accessToken state, set/clear it alongside user on all auth events
- CCFilesPanel: apiFetch reads accessToken from AuthContext (no lock), loadCabinets
  effect gated on authUser.id (fires only after SIGNED_IN, session guaranteed valid)
- CCTranscriptionPanel: loadSessions effect gated on authUser.id for same reason

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 15:06:51 +01:00
kcarandClaude Sonnet 4.6 5284d30f84 fix(panels): resolve sidebar refresh bugs — getUser→getSession, files double-call, hardcoded IP
- transcriptionStore: replace all supabase.auth.getUser() with getSession() so session
  restoration on page refresh does not race against GoTrue network validation
- CCFilesPanel: remove selectedCabinet from loadCabinets useCallback deps; use
  initialSelectionDone ref to prevent double-call on first mount
- CCTranscriptionPanel: replace hardcoded LAN IP with VITE_API_URL env var

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 13:41:25 +00:00
kcarandClaude Sonnet 4.6 fb1795fd2b fix(phase-a): actually remove Neo provider JSX from App.tsx
Python heredoc string replacement missed the JSX wrapper lines due to
indentation mismatch. Rewrote App.tsx directly. Previous commit removed
the imports but left NeoUserProvider/NeoInstituteProvider in the JSX,
causing ReferenceError at runtime (Vite builds without tsc so it
compiled clean despite the undefined references).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 13:22:24 +00:00
kcarandClaude Sonnet 4.6 7b546c933e feat(phase-a): remove Neo4j from startup chain, clean auth flow
- Remove NeoUserProvider + NeoInstituteProvider from App.tsx startup chain
- Strip user_db_name/school_db_name from CCUser; add school_id (Phase B wires it to Supabase)
- Remove DatabaseNameService from AuthContext and UserContext
- Remove provisionUser() call from login path; API endpoint preserved for Phase B decision
- Simplify UserContext.resolveProfile: fast-path JWT metadata then background Supabase fetch
- Replace user.user_db_name reads in singlePlayerPage + snapshotService with null-safe guards
- Add useDeviceContext hook (desktop/tablet/phone/iwb, persists to localStorage)

App now loads to dashboard without any Neo4j dependency at startup.
Canvas opens to blank TLDraw state; Phase B rebuilds navigation on Supabase.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 13:06:39 +00:00
kcarandClaude Sonnet 4.6 4139eb8fd3 fix: audio pipeline — 16 kHz AudioContext, 4096-sample buffering, SERVER_READY handshake
Root causes of disconnection and slow transcription:
- AudioWorklet was firing every 128 native samples (~48 kHz), sending
  ~375 tiny WebSocket messages/sec. Server flooded with tiny frames
  during silence → keepalive ping timed out → connection dropped.
- JS resampling 48 kHz → 16 kHz added CPU overhead on every chunk.
- Audio started on ws.onopen before server sent SERVER_READY, so early
  frames were dropped.

Fixes:
- audioWorklet.js: accumulate 4096 samples before posting (256 ms/chunk
  at 16 kHz, ~4 messages/sec), transfer ArrayBuffer zero-copy.
- transcriptionService: AudioContext({ sampleRate: 16000 }) — browser
  handles native resampling, no JS resampling needed. Remove
  resampleTo16kHZ entirely.
- Wait for SERVER_READY message before calling setupAudioProcessing().
- Send 'END_OF_AUDIO' string on stop so server can finalise last segment.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-23 07:26:20 +00:00
kcarandClaude Sonnet 4.6 308889937c fix: transcription segment dedup, store finalisation, and panel colours
- transcriptionService: track finalizedSegmentCount so only newly-final
  segments are emitted per WS message (was re-processing full array each
  time, causing the live segment to freeze in the completed list)
- transcriptionStore: saveSegment isFinal branch now appends the passed
  text directly instead of currentSegment (currentSegment was stale
  relative to the incoming final)
- CCTranscriptionPanel: record button colour changed from var(--color-text)
  to explicit #2563eb so it is visible in dark mode; completed segment
  backgrounds changed from hardcoded #fff to var(--color-muted) so text
  is readable in both themes; keyword Add button gets same blue fix

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-23 07:08:53 +00:00
132 changed files with 28957 additions and 4903 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
*.md
.env.local
.env.*.local
.cursor*
*.log
+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')
});
});
+63 -4
View File
@@ -1,19 +1,51 @@
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 . .
# Run build with production mode
RUN npm run build -- --mode production
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
ARG VITE_API_BASE
ARG VITE_API_URL
ARG VITE_APP_NAME
ARG VITE_APP_HMR_URL
ARG VITE_DEV
ARG VITE_FRONTEND_SITE_URL
ARG VITE_SEARCH_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_SUPABASE_URL
ARG VITE_SUPER_ADMIN_EMAIL
ARG VITE_TLSYNC_URL
ARG VITE_WHISPERLIVE_URL
# Run build with production mode. Keep these as build-step environment values
# rather than final-image ENV entries; Vite still embeds the public client config
# into the static bundle, but nginx image metadata does not need them.
RUN VITE_API_BASE="${VITE_API_BASE}" \
VITE_API_URL="${VITE_API_URL}" \
VITE_APP_NAME="${VITE_APP_NAME}" \
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
VITE_DEV="${VITE_DEV}" \
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
npm run build -- --mode production
FROM nginx:alpine
# 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; \
@@ -23,6 +55,33 @@ RUN echo 'server { \
expires -1; \
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
} \
location = /health { \
proxy_pass http://192.168.0.64:18000/health; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location /__ccapi/ { \
proxy_pass http://192.168.0.64:18000/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location /__supabase/ { \
proxy_pass http://192.168.0.94:8000/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_http_version 1.1; \
} \
location /api/ { \
proxy_pass http://192.168.0.64:18000/api/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location / { \
try_files $uri $uri/ /index.html; \
expires 30d; \
+55
View File
@@ -0,0 +1,55 @@
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:
# app-dev is served by nginx on the app host; browser API calls must stay
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
# strips that prefix before forwarding, preserving mixed backend routes
# such as /api/exam, /me/bootstrap, and /database/timetable.
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
# leading-slash URL is resolved against window.location.origin in
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
VITE_API_BASE: /__ccapi
VITE_API_URL: /__ccapi
VITE_SUPABASE_URL: /__supabase
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
VITE_DEV: ${VITE_DEV:-false}
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
env_file:
- .env.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': {},
},
+17 -3
View File
@@ -1,12 +1,26 @@
class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
process(inputs) {
const input = inputs[0];
if (input.length > 0) {
const audioData = input[0];
this.port.postMessage(audioData);
const samples = input[0];
for (let i = 0; i < samples.length; i++) {
this._buffer[this._bufferIndex++] = samples[i];
if (this._bufferIndex >= 4096) {
// Transfer ownership (zero-copy) to main thread
this.port.postMessage(this._buffer.buffer, [this._buffer.buffer]);
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
}
}
return true;
}
}
registerProcessor('audio-processor', AudioProcessor);
registerProcessor('audio-processor', AudioProcessor);
+58 -22
View File
@@ -1,34 +1,70 @@
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 { NeoUserProvider } from './contexts/NeoUserContext';
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
import AppRoutes from './AppRoutes';
import { ErrorBoundary } from './components/ErrorBoundary';
import React from 'react';
// Wrap the entire app in a memo to prevent unnecessary re-renders
const App = React.memo(() => (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
<AuthProvider>
<UserProvider>
<NeoUserProvider>
<NeoInstituteProvider>
<TLDrawProvider>
/**
* 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 />
</TLDrawProvider>
</NeoInstituteProvider>
</NeoUserProvider>
</UserProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
));
</ThemeProvider>
</TLDrawProvider>
</UserProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
);
});
// Add display name for better debugging
App.displayName = import.meta.env.VITE_APP_NAME;
export default App;
export default App;
+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();
});
});
+67 -11
View File
@@ -7,11 +7,13 @@ 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';
import AdminDashboard from './pages/auth/adminPage';
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
import DevPage from './pages/tldraw/devPage';
import TeacherPlanner from './pages/react-flow/teacherPlanner';
@@ -29,15 +31,22 @@ import DashboardPage from './pages/user/dashboardPage';
// Timetable Module Pages
import {
TimetablePage,
TimetableListPage,
ClassesPage,
LessonPage,
TaughtLessonsPage,
MyClassesPage,
EnrollmentRequestsPage,
StaffManagerPage,
StudentManagerPage,
SchoolSettingsPage,
ClassDetailPage,
StudentLessonsPage,
LessonPlansPage,
LessonPlanDetailPage,
} 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) {
@@ -59,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', {
@@ -114,24 +150,44 @@ const AppRoutes: React.FC = () => {
<Route
path="/admin"
element={
user?.user_type === 'admin' ? <AdminDashboard /> : <NotFound />
!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 />} />
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
<Route path="/student-lessons" element={<StudentLessonsPage />} />
<Route path="/lesson-plans" element={<LessonPlansPage />} />
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
<Route path="/enrollment-requests" element={<EnrollmentRequestsPage />} />
<Route path="/lessons/:lessonId" element={<LessonPage />} />
<Route path="/my-lessons" element={<TaughtLessonsPage />} />
<Route path="/staff-manager" element={<StaffManagerPage />} />
<Route path="/student-manager" element={<StudentManagerPage />} />
<Route path="/school-settings" element={<SchoolSettingsPage />} />
{/* 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 />} />
@@ -142,10 +198,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 @@
+75 -412
View File
@@ -1,458 +1,121 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
IconButton,
Tooltip,
Box,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Button,
styled
import React, { useState } from 'react';
import {
IconButton, Tooltip, Box, Menu, MenuItem,
ListItemIcon, ListItemText, Chip, styled,
} from '@mui/material';
import {
import {
ArrowBack as ArrowBackIcon,
ArrowForward as ArrowForwardIcon,
History as HistoryIcon,
School as SchoolIcon,
Person as PersonIcon,
AccountCircle as AccountCircleIcon,
CalendarToday as CalendarIcon,
School as TeachingIcon,
Business as BusinessIcon,
AccountTree as DepartmentIcon,
Class as ClassIcon,
ExpandMore as ExpandMoreIcon
Home as HomeIcon,
CalendarToday,
DateRange,
Event,
WorkspacesOutlined,
} from '@mui/icons-material';
import { useNavigationStore } from '../../stores/navigationStore';
import { useNeoUser } from '../../contexts/NeoUserContext';
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
import {
BaseContext,
ViewContext
} from '../../types/navigation';
import { logger } from '../../debugConfig';
const NavigationRoot = styled(Box)`
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
height: 100%;
overflow: hidden;
`;
const NavigationControls = styled(Box)`
display: flex;
align-items: center;
gap: 4px;
`;
const ContextToggleContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.action.hover,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
gap: theme.spacing(0.5),
'& .button-label': {
'@media (max-width: 500px)': {
display: 'none'
}
function getNodeIcon(nodeType: string) {
switch (nodeType) {
case 'User': return <HomeIcon fontSize="small" />;
case 'CalendarYear': return <CalendarToday fontSize="small" />;
case 'CalendarMonth': return <DateRange fontSize="small" />;
case 'CalendarDay': return <Event fontSize="small" />;
default: return <WorkspacesOutlined fontSize="small" />;
}
}));
const ContextToggleButton = styled(Button, {
shouldForwardProp: (prop) => prop !== 'active'
})<{ active?: boolean }>(({ theme, active }) => ({
minWidth: 0,
padding: theme.spacing(0.5, 1.5),
borderRadius: theme.shape.borderRadius,
backgroundColor: active ? theme.palette.primary.main : 'transparent',
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
textTransform: 'none',
transition: theme.transitions.create(['background-color', 'color'], {
duration: theme.transitions.duration.shorter,
}),
'&:hover': {
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
},
'@media (max-width: 500px)': {
padding: theme.spacing(0.5),
}
}));
}
export const GraphNavigator: React.FC = () => {
const {
context,
switchContext,
goBack,
goForward,
isLoading
} = useNavigationStore();
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
const { context, goBack, goForward, isLoading } = useNavigationStore();
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const [availableWidth, setAvailableWidth] = useState<number>(0);
useEffect(() => {
const calculateAvailableSpace = () => {
if (!rootRef.current) return;
// Get the header element
const header = rootRef.current.closest('.MuiToolbar-root');
if (!header) return;
// Get the title and menu elements
const title = header.querySelector('.app-title');
const menu = header.querySelector('.menu-button');
if (!title || !menu) return;
// Calculate available width
const headerWidth = header.clientWidth;
const titleWidth = title.clientWidth;
const menuWidth = menu.clientWidth;
const padding = 48; // Increased buffer space
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
console.log('Available width:', newAvailableWidth); // Debug log
setAvailableWidth(newAvailableWidth);
};
// Set up ResizeObserver
const resizeObserver = new ResizeObserver(() => {
// Use requestAnimationFrame to debounce calculations
window.requestAnimationFrame(calculateAvailableSpace);
});
// Observe both the root element and the header
if (rootRef.current) {
const header = rootRef.current.closest('.MuiToolbar-root');
if (header) {
resizeObserver.observe(header);
resizeObserver.observe(rootRef.current);
}
}
// Initial calculation
calculateAvailableSpace();
return () => {
resizeObserver.disconnect();
};
}, []);
// Helper function to determine what should be visible
const getVisibility = () => {
// Adjusted thresholds and collapse order:
// 1. Navigation controls (back/forward/history) collapse first
// 2. Toggle labels collapse second
// 3. Context label collapses last
if (availableWidth < 300) {
return {
navigation: false,
contextLabel: true, // Keep context label visible longer
toggleLabels: false
};
} else if (availableWidth < 450) {
return {
navigation: false,
contextLabel: true, // Keep context label visible
toggleLabels: true
};
} else if (availableWidth < 600) {
return {
navigation: true,
contextLabel: true,
toggleLabels: true
};
}
return {
navigation: true,
contextLabel: true,
toggleLabels: true
};
};
const visibility = getVisibility();
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
setHistoryMenuAnchor(event.currentTarget);
};
const handleHistoryClose = () => {
setHistoryMenuAnchor(null);
};
const handleHistoryItemClick = (index: number) => {
const {currentIndex} = context.history;
const steps = index - currentIndex;
if (steps < 0) {
for (let i = 0; i < -steps; i++) {
goBack();
}
} else if (steps > 0) {
for (let i = 0; i < steps; i++) {
goForward();
}
}
handleHistoryClose();
};
const handleContextChange = useCallback(async (newContext: BaseContext) => {
try {
// Check if trying to access institute contexts without worker database
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
return;
}
// Check if trying to access profile contexts without user database
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
return;
}
logger.debug('navigation', '🔄 Changing main context', {
from: context.main,
to: newContext,
userDbName,
workerDbName
});
// Get default view for new context
const defaultView = getDefaultViewForContext(newContext);
// Use unified context switch with both base and extended contexts
await switchContext({
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
base: newContext,
extended: defaultView,
skipBaseContextLoad: false
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to change context:', error);
}
}, [context.main, switchContext, userDbName, workerDbName]);
// Helper function to get default view for a context
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
switch (context) {
case 'calendar':
return 'overview';
case 'teaching':
return 'overview';
case 'school':
return 'overview';
case 'department':
return 'overview';
case 'class':
return 'overview';
default:
return 'overview';
}
};
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
setContextMenuAnchor(event.currentTarget);
};
const handleContextSelect = useCallback(async (context: BaseContext) => {
setContextMenuAnchor(null);
try {
// Use unified context switch with both base and extended contexts
const contextDef = NAVIGATION_CONTEXTS[context];
const defaultExtended = contextDef?.views[0]?.id;
await switchContext({
base: context,
extended: defaultExtended
}, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to select context:', error);
}
}, [switchContext, userDbName, workerDbName]);
const getContextItems = useCallback(() => {
if (context.main === 'profile') {
return [
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
];
} else {
return [
{ id: 'school', label: 'School', icon: BusinessIcon },
{ id: 'department', label: 'Department', icon: DepartmentIcon },
{ id: 'class', label: 'Class', icon: ClassIcon },
];
}
}, [context.main]);
const getContextIcon = useCallback((contextType: string) => {
switch (contextType) {
case 'profile':
return <AccountCircleIcon />;
case 'calendar':
return <CalendarIcon />;
case 'teaching':
return <TeachingIcon />;
case 'school':
return <BusinessIcon />;
case 'department':
return <DepartmentIcon />;
case 'class':
return <ClassIcon />;
default:
return <AccountCircleIcon />;
}
}, []);
const isDisabled = !isNeoUserInitialized || isLoading;
const { history } = context;
const canGoBack = history.currentIndex > 0;
const canGoForward = history.currentIndex < history.nodes.length - 1;
const currentNode = context.node;
const handleHistoryClick = (e: React.MouseEvent<HTMLElement>) => setHistoryMenuAnchor(e.currentTarget);
const handleHistoryClose = () => setHistoryMenuAnchor(null);
const handleHistoryItemClick = (index: number) => {
const delta = index - history.currentIndex;
if (delta < 0) for (let i = 0; i < -delta; i++) goBack();
else if (delta > 0) for (let i = 0; i < delta; i++) goForward();
handleHistoryClose();
};
return (
<NavigationRoot ref={rootRef}>
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
<Tooltip title="Back">
<span>
<IconButton
onClick={goBack}
disabled={!canGoBack || isDisabled}
size="small"
>
<ArrowBackIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<NavigationRoot>
<Tooltip title="Back">
<span>
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
<ArrowBackIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="History">
<span>
<IconButton
onClick={handleHistoryClick}
disabled={!history.nodes.length || isDisabled}
size="small"
>
<HistoryIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="History">
<span>
<IconButton
onClick={handleHistoryClick}
disabled={!history.nodes.length}
size="small"
>
<HistoryIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Forward">
<span>
<IconButton
onClick={goForward}
disabled={!canGoForward || isDisabled}
size="small"
>
<ArrowForwardIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</NavigationControls>
<Tooltip title="Forward">
<span>
<IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
<ArrowForwardIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
{currentNode && (
<Chip
size="small"
icon={getNodeIcon(currentNode.type)}
label={currentNode.label || currentNode.type}
variant="outlined"
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
/>
)}
{/* History Menu */}
<Menu
anchorEl={historyMenuAnchor}
open={Boolean(historyMenuAnchor)}
onClose={handleHistoryClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
{history.nodes.map((node, index) => (
<MenuItem
key={`${node.id}-${index}`}
onClick={() => handleHistoryItemClick(index)}
selected={index === history.currentIndex}
dense
>
<ListItemIcon>
{getContextIcon(node.type)}
<ListItemIcon sx={{ minWidth: 32 }}>
{getNodeIcon(node.type)}
</ListItemIcon>
<ListItemText
<ListItemText
primary={node.label || node.id}
secondary={node.type}
primaryTypographyProps={{ fontSize: '0.8rem' }}
secondaryTypographyProps={{ fontSize: '0.7rem' }}
/>
</MenuItem>
))}
</Menu>
<ContextToggleContainer>
<ContextToggleButton
active={context.main === 'profile'}
onClick={() => handleContextChange('profile' as BaseContext)}
startIcon={<PersonIcon />}
disabled={isDisabled || !userDbName}
>
{visibility.toggleLabels && <span className="button-label">Profile</span>}
</ContextToggleButton>
<ContextToggleButton
active={context.main === 'institute'}
onClick={() => handleContextChange('school' as BaseContext)}
startIcon={<SchoolIcon />}
disabled={isDisabled || !workerDbName}
>
{visibility.toggleLabels && <span className="button-label">Institute</span>}
</ContextToggleButton>
</ContextToggleContainer>
<Box>
<Tooltip title={context.base}>
<span>
<Button
onClick={handleContextMenu}
disabled={isDisabled}
sx={{
minWidth: 0,
p: 0.5,
color: 'text.primary',
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
{getContextIcon(context.base)}
{visibility.contextLabel && (
<Box sx={{ ml: 1 }}>
{context.base}
</Box>
)}
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
</Button>
</span>
</Tooltip>
</Box>
<Menu
anchorEl={contextMenuAnchor}
open={Boolean(contextMenuAnchor)}
onClose={() => setContextMenuAnchor(null)}
>
{getContextItems().map(item => (
<MenuItem
key={item.id}
onClick={() => handleContextSelect(item.id as BaseContext)}
disabled={isDisabled}
>
<ListItemIcon>
<item.icon />
</ListItemIcon>
<ListItemText primary={item.label} />
</MenuItem>
))}
</Menu>
</NavigationRoot>
);
};
};
+225
View File
@@ -0,0 +1,225 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, IconButton, CircularProgress, Tooltip, Collapse,
} from '@mui/material';
import {
ChevronLeft, ChevronRight,
ExpandMore, ChevronRight as ChevronRightIcon,
Home as HomeIcon, CalendarToday, DateRange, Event,
} from '@mui/icons-material';
import { useNavigationStore } from '../../stores/navigationStore';
import { useAuth } from '../../contexts/AuthContext';
import { NeoGraphNode } from '../../types/navigation';
import { logger } from '../../debugConfig';
interface TreeNode extends NeoGraphNode {
has_children?: boolean;
children?: TreeNode[];
}
const NODE_ICONS: Record<string, React.ElementType> = {
User: HomeIcon,
CalendarYear: CalendarToday,
CalendarMonth: DateRange,
CalendarWeek: DateRange,
CalendarDay: Event,
};
const SIDEBAR_WIDTH = 220;
interface TreeItemProps {
node: TreeNode;
depth: number;
onSelect: (node: TreeNode) => void;
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
activeRoomId?: string;
}
function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
const [loading, setLoading] = useState(false);
const Icon = NODE_ICONS[node.node_type] || HomeIcon;
const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay';
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!expanded && children.length === 0 && canExpand) {
setLoading(true);
try {
const loaded = await onExpand(node);
setChildren(loaded);
} finally {
setLoading(false);
}
}
setExpanded(v => !v);
};
return (
<Box>
<Box
onClick={() => onSelect(node)}
sx={{
display: 'flex', alignItems: 'center',
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4,
cursor: 'pointer', borderRadius: 1, mx: 0.5,
fontSize: '0.78rem', minHeight: 28,
bgcolor: 'transparent',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{canExpand && (
loading
? <CircularProgress size={10} />
: (
<IconButton
size="small" sx={{ p: 0, color: 'text.secondary' }}
onClick={handleToggle}
>
{expanded
? <ExpandMore sx={{ fontSize: 14 }} />
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
</IconButton>
)
)}
</Box>
<Icon sx={{ fontSize: 14, mr: 0.75, flexShrink: 0, color: 'text.secondary' }} />
<Box sx={{
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
flexGrow: 1, color: 'text.primary',
}}>
{node.label}
</Box>
</Box>
{canExpand && (
<Collapse in={expanded} timeout="auto">
{children.map(child => (
<TreeItem
key={child.neo4j_node_id}
node={child}
depth={depth + 1}
onSelect={onSelect}
onExpand={onExpand}
activeRoomId={activeRoomId}
/>
))}
</Collapse>
)}
</Box>
);
}
interface GraphSidebarProps {
open: boolean;
onToggle: () => void;
}
export function GraphSidebar({ open, onToggle }: GraphSidebarProps) {
const { accessToken } = useAuth();
const { navigateToNeoNode, context } = useNavigationStore();
const [tree, setTree] = useState<TreeNode | null>(null);
const [loading, setLoading] = useState(false);
const apiBase = import.meta.env.VITE_API_BASE as string;
const fetchTree = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
try {
const res = await fetch(`${apiBase}/graph/tree`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`);
const data = await res.json();
setTree(data.tree);
} catch (err) {
logger.error('graph-sidebar', 'Failed to load graph tree', err);
} finally {
setLoading(false);
}
}, [accessToken, apiBase]);
useEffect(() => {
if (open && !tree && accessToken) fetchTree();
}, [open, tree, accessToken, fetchTree]);
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
if (!accessToken) return [];
const params = new URLSearchParams({
neo4j_node_id: node.neo4j_node_id,
neo4j_db_name: node.neo4j_db_name,
node_type: node.node_type,
});
try {
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return [];
const data = await res.json();
return data.children || [];
} catch {
return [];
}
}, [accessToken, apiBase]);
return (
<Box sx={{ position: 'relative', height: '100%', display: 'flex', flexShrink: 0 }}>
<Box
sx={{
width: open ? SIDEBAR_WIDTH : 0,
overflow: 'hidden',
transition: 'width 0.2s ease',
bgcolor: 'background.paper',
borderRight: 1,
borderColor: 'divider',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
<Box sx={{ overflowY: 'auto', flexGrow: 1, pt: 1 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
<CircularProgress size={20} />
</Box>
) : tree ? (
<TreeItem
node={tree}
depth={0}
onSelect={n => navigateToNeoNode(n)}
onExpand={handleExpand}
activeRoomId={context.node?.id}
/>
) : null}
</Box>
</Box>
<Tooltip title={open ? 'Collapse sidebar' : 'Expand sidebar'} placement="right">
<IconButton
onClick={onToggle}
size="small"
sx={{
position: 'absolute',
right: -14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
width: 22,
height: 44,
borderRadius: '0 4px 4px 0',
'&:hover': { bgcolor: 'action.hover' },
}}
>
{open
? <ChevronLeft sx={{ fontSize: 14 }} />
: <ChevronRight sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
</Box>
);
}
+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/";
+89 -43
View File
@@ -4,35 +4,46 @@ import { Session, User } from '@supabase/supabase-js';
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { storageService, StorageKeys } from '../services/auth/localStorageService';
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
export interface AuthContextType {
user: CCUser | null;
user_role: string | null;
accessToken: string | null;
loading: boolean;
error: Error | null;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
clearError: () => void;
bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
user_role: null,
accessToken: null,
loading: true,
error: null,
signIn: async () => {},
signOut: async () => {},
clearError: () => {}
clearError: () => {},
bootstrapData: null,
isAuthResolving: false
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // true until INITIAL_SESSION fires
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 persistSession = useCallback((session: Session | null) => {
if (session) {
@@ -52,20 +63,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
const userType = (metadata.user_type || 'email_teacher').trim();
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(userType || 'standard', supabaseUser.id);
const schoolDbName = storedSchoolDb || '';
const resolvedUser: CCUser = {
id: supabaseUser.id,
email: supabaseUser.email,
user_type: userType,
username: baseUsername,
display_name: baseDisplayName,
user_db_name: userDbName,
school_db_name: schoolDbName,
school_id: null,
created_at: supabaseUser.created_at,
updated_at: supabaseUser.updated_at
};
@@ -74,53 +78,90 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { user: resolvedUser, role: resolvedRole };
}, []);
const loadBootstrap = useCallback(async (token: string | null | undefined) => {
if (!token) {
setBootstrapData(null);
return null;
}
const bootstrap = await fetchBootstrapData(token);
setBootstrapData(bootstrap);
return bootstrap;
}, []);
useEffect(() => {
// Canonical Supabase auth pattern: rely solely on onAuthStateChange.
// INITIAL_SESSION fires immediately with the current session state,
// eliminating the race condition between loadInitialSession + onAuthStateChange.
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
switch (event) {
case 'INITIAL_SESSION':
case 'SIGNED_IN':
case 'TOKEN_REFRESHED': {
persistSession(session ?? null);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
setUser(resolvedUser);
setUserRole(role);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
setUserRole(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
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);
await loadBootstrap(session.access_token);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
setUser(null);
setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
// Always clear loading after the first auth event resolves
setLoading(false);
break;
}
case 'SIGNED_OUT': {
persistSession(null);
} else {
setUser(null);
setUserRole(null);
setLoading(false);
break;
setAccessToken(null);
}
default:
break;
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);
setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
setUser(null);
setUserRole(null);
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]);
}, [buildUserFromSupabase, persistSession, loadBootstrap]);
const signIn = async (email: string, password: string) => {
try {
@@ -140,6 +181,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
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 });
@@ -173,11 +216,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
value={{
user,
user_role,
accessToken,
loading,
error,
signIn,
signOut,
clearError
clearError,
bootstrapData,
isAuthResolving
}}
>
{children}
+28 -32
View File
@@ -3,7 +3,6 @@ import { useAuth } from './AuthContext';
import { useUser } from './UserContext';
import { logger } from '../debugConfig';
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { CalendarStructure, WorkerStructure } from '../types/navigation';
import { useNavigationStore } from '../stores/navigationStore';
@@ -131,7 +130,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
});
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const { user, accessToken } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser();
const navigationStore = useNavigationStore();
@@ -215,12 +214,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
setIsLoading(true);
setError(null);
// Set database names
const userDb = profile.user_db_name || (user?.email ?
DatabaseNameService.getStoredUserDatabase() || null : null);
if (!userDb) {
throw new Error('No user database name available');
// Inject auth into navigation store so Supabase queries work
if (user?.id && accessToken) {
navigationStore.setAuthInfo(accessToken, user.id);
}
// Initialize user node in profile context
@@ -236,7 +232,7 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
main: 'profile',
base: 'profile',
extended: 'overview'
}, userDb, profile.school_db_name),
}, null, null),
switchTimeout
]);
@@ -271,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Continue without user node - this is not critical for basic functionality
}
// Set final state
setUserDbName(userDb);
setWorkerDbName(profile.school_db_name);
// Set final state — userDbName signals auth availability for UI guards
setUserDbName(user?.id || null);
setWorkerDbName(null);
setIsInitialized(true);
setIsLoading(false);
initializationRef.current.isComplete = true;
@@ -294,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Calendar Navigation Functions
const navigateToDay = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'day'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -334,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToWeek = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'week'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -374,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToMonth = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'month'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -414,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToYear = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'calendar',
extended: 'year'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -455,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Worker Navigation Functions
const navigateToTimetable = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'timetable'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -492,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToJournal = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'journal'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -529,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToPlanner = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'planner'
}, userDbName, workerDbName);
}, null, null);
const node = navigationStore.context.node;
if (node?.data) {
@@ -566,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToClass = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'classes'
}, userDbName, workerDbName);
await navigationStore.navigate(id, userDbName);
}, null, null);
await navigationStore.navigate(id, '');
const node = navigationStore.context.node;
if (node?.data) {
@@ -604,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const navigateToLesson = async (id: string) => {
if (!userDbName) return;
if (!user?.id) return;
setIsLoading(true);
try {
await navigationStore.switchContext({
base: 'teaching',
extended: 'lessons'
}, userDbName, workerDbName);
await navigationStore.navigate(id, userDbName);
}, null, null);
await navigationStore.navigate(id, '');
const node = navigationStore.context.node;
if (node?.data) {
+8 -95
View File
@@ -4,8 +4,6 @@ import { supabase } from '../supabaseClient';
import { logger } from '../debugConfig';
import { CCUser, CCUserMetadata } from '../services/auth/authService';
import { UserPreferences } from '../services/auth/profileService';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { provisionUser } from '../services/provisioningService';
import { storageService, StorageKeys } from '../services/auth/localStorageService';
export interface UserContextType {
@@ -112,33 +110,23 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
let profileRow: Record<string, unknown> | null = null;
// Fast-path: build profile from auth metadata + localStorage immediately.
// This clears the spinner before any network call so the page renders on refresh
// without waiting for the Supabase profiles query (~200ms).
// Fast-path: build profile from auth metadata immediately (no spinner on refresh).
const fastMetadata = userInfo.user_metadata as CCUserMetadata;
const fastStoredUserDb = DatabaseNameService.getStoredUserDatabase();
const fastStoredSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const fastUserDb = fastStoredUserDb || DatabaseNameService.getUserPrivateDB(fastMetadata?.user_type || '', userInfo.id);
const fastProfile: CCUser = {
id: userInfo.id,
email: userInfo.email,
user_type: fastMetadata?.user_type || '',
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
display_name: String(fastMetadata?.display_name || ''),
user_db_name: String(fastUserDb || ''),
school_db_name: String(fastStoredSchoolDb || ''),
school_id: null,
created_at: userInfo.created_at,
updated_at: userInfo.updated_at
};
DatabaseNameService.rememberDatabaseNames({
userDbName: fastProfile.user_db_name,
schoolDbName: fastProfile.school_db_name
});
if (mountedRef.current && !isInitialized) {
setProfile(fastProfile);
setLoading(false);
setIsInitialized(true);
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata, no spinner');
logger.debug('user-context', '⚡ Fast-path: profile initialized from auth metadata');
}
// Background: query Supabase profiles for authoritative data (user_db_name, school_db_name, display_name).
@@ -172,80 +160,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
hasSchoolDb: !!profileRow?.school_db_name
});
const metadata = userInfo.user_metadata as CCUserMetadata;
logger.debug('user-context', '🔧 Step 7: Processing user metadata...', {
hasMetadata: !!metadata,
userType: metadata?.user_type
});
let userDbName = profileRow?.user_db_name ?? null;
let schoolDbName = profileRow?.school_db_name ?? null;
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
// Start provisioning in background (non-blocking)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const provisioningPromise = provisionUser(userInfo.id, authSession?.access_token ?? null)
.then(provisioned => {
if (provisioned) {
logger.debug('user-context', '✅ Provisioning completed in background', {
userDbName: provisioned.user_db_name,
workerDbName: provisioned.worker_db_name
});
// Update localStorage with provisioned values
DatabaseNameService.rememberDatabaseNames({
userDbName: provisioned.user_db_name,
schoolDbName: provisioned.worker_db_name || ''
});
}
})
.catch(provisionError => {
logger.warn('user-context', '⚠️ Background provisioning failed', {
userId: userInfo?.id,
provisionError: provisionError instanceof Error ? provisionError.message : String(provisionError)
});
});
if (!userDbName && storedUserDb) {
userDbName = storedUserDb;
}
if (!schoolDbName && storedSchoolDb) {
schoolDbName = storedSchoolDb;
}
logger.debug('user-context', '️ Database name resolution', {
userDbName,
schoolDbName
});
if (!userDbName) {
userDbName = DatabaseNameService.getUserPrivateDB(metadata.user_type || '', userInfo.id);
}
if (!schoolDbName) {
schoolDbName = '';
}
DatabaseNameService.rememberDatabaseNames({
userDbName: String(userDbName || ''),
schoolDbName: String(schoolDbName || '')
});
logger.debug('user-context', '🔧 Creating user profile object...', {
userId: userInfo.id,
userDbName,
schoolDbName,
userType: metadata.user_type
});
const userProfile: CCUser = {
id: userInfo.id,
email: userInfo.email,
user_type: metadata.user_type || '',
username: metadata.username || '',
display_name: String(metadata.display_name || ''),
user_db_name: String(userDbName || ''),
school_db_name: String(schoolDbName || ''),
school_id: (profileRow?.school_id as string) ?? null,
created_at: userInfo.created_at,
updated_at: userInfo.updated_at
};
@@ -268,9 +191,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
logger.debug('user-context', '✅ User profile loaded', {
userId: userProfile.id,
userType: userProfile.user_type,
username: userProfile.username,
userDbName: userProfile.user_db_name,
schoolDbName: userProfile.school_db_name
username: userProfile.username
});
setError(null);
} catch (error) {
@@ -295,22 +216,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
user_type: metadata?.user_type || 'email_teacher',
username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
display_name: metadata?.display_name || userInfo.email?.split('@')[0] || 'User',
user_db_name: DatabaseNameService.getUserPrivateDB(metadata?.user_type || 'email_teacher', userInfo.id),
school_db_name: '',
school_id: null,
created_at: userInfo.created_at,
updated_at: userInfo.updated_at
};
DatabaseNameService.rememberDatabaseNames({
userDbName: fallbackProfile.user_db_name,
schoolDbName: fallbackProfile.school_db_name
});
setProfile(fallbackProfile);
logger.debug('user-context', '✅ Fallback profile created', {
userId: fallbackProfile.id,
userType: fallbackProfile.user_type,
userDbName: fallbackProfile.user_db_name
userType: fallbackProfile.user_type
});
} else {
setProfile(null);
+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 {
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
export type DeviceType = 'desktop' | 'tablet' | 'phone' | 'iwb';
function detectDeviceType(): DeviceType {
const width = window.innerWidth;
const touchPoints = navigator.maxTouchPoints ?? 0;
const hasTouch = touchPoints > 0 || 'ontouchstart' in window;
if (width >= 1280 && !hasTouch) return 'desktop';
if (width >= 768 && hasTouch) return 'tablet';
if (width < 768) return 'phone';
return 'desktop';
}
const STORAGE_KEY = 'cc_device_type';
export function useDeviceContext() {
const [deviceType, setDeviceTypeState] = useState<DeviceType>(() => {
const stored = localStorage.getItem(STORAGE_KEY) as DeviceType | null;
if (stored && ['desktop', 'tablet', 'phone', 'iwb'].includes(stored)) return stored;
return detectDeviceType();
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, deviceType);
}, [deviceType]);
const setDeviceType = (type: DeviceType) => {
setDeviceTypeState(type);
};
const isTouch = deviceType === 'tablet' || deviceType === 'phone';
const isMobileLayout = deviceType === 'phone';
return { deviceType, setDeviceType, isTouch, isMobileLayout };
}
+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;
}
+75 -115
View File
@@ -7,6 +7,7 @@ import {
Typography,
IconButton,
Box,
Chip,
useTheme,
Menu,
MenuItem,
@@ -19,9 +20,6 @@ import Login from '@mui/icons-material/Login';
import Logout from '@mui/icons-material/Logout';
import Teacher from '@mui/icons-material/School';
import Student from '@mui/icons-material/Person';
import TLDrawDev from '@mui/icons-material/Dashboard';
import DevTools from '@mui/icons-material/Build';
import Multiplayer from '@mui/icons-material/Groups';
import Calendar from '@mui/icons-material/CalendarToday';
import TeacherPlanner from '@mui/icons-material/Assignment';
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn';
@@ -33,32 +31,32 @@ import Schedule from '@mui/icons-material/Schedule';
import Class from '@mui/icons-material/Class';
import Book from '@mui/icons-material/Book';
import Enrollment from '@mui/icons-material/HowToReg';
import Lessons from '@mui/icons-material/EventNote';
import LessonPlans from '@mui/icons-material/LibraryBooks';
import People from '@mui/icons-material/People';
import SchoolSettings from '@mui/icons-material/Tune';
import { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator';
const Header: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user, signOut } = useAuth();
const { user, signOut, bootstrapData } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
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';
// Update authentication state whenever user changes
useEffect(() => {
const newAuthState = !!user;
setIsAuthenticated(newAuthState);
logger.debug('user-context', '🔄 User state changed in header', {
hasUser: newAuthState,
userId: user?.id,
userEmail: user?.email,
userState: newAuthState ? 'logged-in' : 'logged-out',
isAdmin
});
}, [user, isAdmin]);
setIsAuthenticated(!!user);
}, [user]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -129,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"
@@ -192,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,
@@ -206,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 />
@@ -235,84 +224,59 @@ const Header: React.FC = () => {
secondary="Manage your classes"
/>
</MenuItem>,
<MenuItem key="enrollment-requests" onClick={() => handleNavigation('/enrollment-requests')}>
<MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
<ListItemIcon>
<Enrollment />
<Lessons />
</ListItemIcon>
<ListItemText
primary="Enrollment Requests"
secondary="Review pending enrollments"
<ListItemText
primary="My Lessons"
secondary="Weekly lesson view"
/>
</MenuItem>,
<Divider key="timetable-divider" />,
<MenuItem key="lesson-plans" onClick={() => handleNavigation('/lesson-plans')}>
<ListItemIcon>
<LessonPlans />
</ListItemIcon>
<ListItemText
primary="Lesson Plans"
secondary="Plan and manage lessons"
/>
</MenuItem>,
<Divider key="work-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" />,
// School Admin Section
...(isSchoolAdmin ? [
<Typography
key="school-admin-header"
variant="subtitle2"
sx={{
px: 2, py: 1,
color: theme.palette.primary.main,
fontWeight: 600,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem',
}}
>
School Admin
</Typography>,
<MenuItem key="school-settings" onClick={() => handleNavigation('/school-settings')}>
<ListItemIcon><SchoolSettings /></ListItemIcon>
<ListItemText primary="School Settings" secondary="Calendar, overview" />
</MenuItem>,
<MenuItem key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
<ListItemIcon><People /></ListItemIcon>
<ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
</MenuItem>,
<MenuItem key="student-manager" onClick={() => handleNavigation('/student-manager')}>
<ListItemIcon><Teacher /></ListItemIcon>
<ListItemText primary="Student Manager" secondary="Invite & manage students" />
</MenuItem>,
<Divider key="school-admin-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>,
// Admin Section
...(isAdmin ? [
// Platform Admin Section
...(isPlatformAdmin ? [
<Divider key="admin-divider" />,
<Typography
key="admin-header"
@@ -330,15 +294,11 @@ const Header: React.FC = () => {
Administration
</Typography>,
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
<ListItemIcon>
<Admin />
</ListItemIcon>
<ListItemText primary="Admin Dashboard" />
<ListItemIcon><Admin /></ListItemIcon>
<ListItemText primary="Platform Admin" secondary="Schools & system stats" />
</MenuItem>
] : []),
// Authentication Section
<Divider key="auth-divider" />,
<MenuItem key="signout" onClick={handleSignOut}>
<ListItemIcon>
<Logout />
+178
View File
@@ -0,0 +1,178 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Table, TableHead, TableBody, TableRow, TableCell,
Card, CardContent, Grid, IconButton, Tooltip,
} from '@mui/material';
import { Refresh, School, People, EventNote, HourglassEmpty } from '@mui/icons-material';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
interface SchoolEntry {
id: string;
name: string;
urn: string | null;
website: string | null;
status: string;
created_at: string;
neo4j_uuid_string: string | null;
staff_count: number;
student_count: number;
has_calendar: boolean;
pending_invitations: number;
}
interface Stats {
schools: number;
profiles: number;
taught_lessons: number;
pending_invitations: number;
}
function StatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ElementType }) {
return (
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
</Box>
</CardContent>
</Card>
);
}
const PlatformAdminPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [schools, setSchools] = useState<SchoolEntry[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadData = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const [schoolsRes, statsRes] = await Promise.all([
fetch(`${API_BASE}/admin/schools`, { headers }).then(r => r.json()),
fetch(`${API_BASE}/admin/stats`, { headers }).then(r => r.json()),
]);
if (schoolsRes.status === 'ok') setSchools(schoolsRes.schools || []);
else setError(schoolsRes.detail || 'Failed to load schools');
if (statsRes.status === 'ok') setStats(statsRes);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadData(); }, [loadData]);
return (
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Platform Admin</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>Classroom Copilot system overview</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
</Tooltip>
<Button size="small" variant="outlined" onClick={() => navigate('/dashboard')}> Dashboard</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{stats && (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}><StatCard label="Schools" value={stats.schools} icon={School} /></Grid>
<Grid item xs={6} sm={3}><StatCard label="Profiles" value={stats.profiles} icon={People} /></Grid>
<Grid item xs={6} sm={3}><StatCard label="Taught Lessons" value={stats.taught_lessons} icon={EventNote} /></Grid>
<Grid item xs={6} sm={3}><StatCard label="Pending Invites" value={stats.pending_invitations} icon={HourglassEmpty} /></Grid>
</Grid>
)}
<Alert severity="warning" sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
<Typography variant="body2">
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
layouts, questions, boundaries, response areas, marking batches, student submissions, and
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
schools, users, or timetable data.
</Typography>
</Alert>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : schools.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No schools registered.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>School</TableCell>
<TableCell>URN</TableCell>
<TableCell>Staff</TableCell>
<TableCell>Students</TableCell>
<TableCell>Calendar</TableCell>
<TableCell>Invites</TableCell>
<TableCell>Neo4j</TableCell>
<TableCell>Registered</TableCell>
</TableRow>
</TableHead>
<TableBody>
{schools.map(s => (
<TableRow key={s.id}>
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>{s.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
{s.urn || '—'}
</TableCell>
<TableCell>{s.staff_count}</TableCell>
<TableCell>{s.student_count}</TableCell>
<TableCell>
<Chip
label={s.has_calendar ? 'Yes' : 'No'}
size="small"
color={s.has_calendar ? 'success' : 'default'}
sx={{ fontSize: '0.65rem', height: 18 }}
/>
</TableCell>
<TableCell>
{s.pending_invitations > 0
? <Chip label={s.pending_invitations} size="small" color="warning" sx={{ fontSize: '0.65rem', height: 18 }} />
: <Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>}
</TableCell>
<TableCell>
<Chip
label={s.neo4j_uuid_string ? 'Provisioned' : 'Pending'}
size="small"
color={s.neo4j_uuid_string ? 'success' : 'warning'}
sx={{ fontSize: '0.65rem', height: 18 }}
/>
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(s.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
);
};
export default PlatformAdminPage;
+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,566 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import CheckIcon from '@mui/icons-material/Check'
import CloseIcon from '@mui/icons-material/Close'
import DoneAllIcon from '@mui/icons-material/DoneAll'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository'
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
const TOOLS = [
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const },
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const },
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const },
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const },
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const },
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const },
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
]
const PAGE_START_X = 0
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
let y = 0
return pages.map((page) => {
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
y += page.height
return geometry
})
}
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
editor.setCameraOptions({
constraints: {
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
padding: { x: 64, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
isLocked: false,
})
}
function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
return { conflict: false, message: detail ?? err.message }
}
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
}
function reviewSummary(template: ExamTemplateDetail | null) {
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
return rows.reduce((acc, row) => {
if (row.source === 'ai') acc.ai += 1
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
return acc
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
}
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
// (the template-derived reviewSummary only reflects the last *saved* state).
function reviewFromShapes(editor: Editor | null) {
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
if (!editor) return acc
for (const shape of editor.getCurrentPageShapes()) {
if (!shapeTypeToKind(shape.type)) continue
const p = (shape as ExamCanvasTLShape).props
if (p.source === 'ai') acc.ai += 1
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
}
return acc
}
function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
}
function domainIdForShape(shape: ExamCanvasTLShape): string {
const fromProps = shape.props.domainId
if (isUuid(fromProps)) return fromProps
const fromShapeId = stripShapePrefix(shape.id)
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
}
function ensureDomainIds(editor: Editor) {
const updates = editor.getCurrentPageShapes()
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
.filter((shape) => !isUuid(shape.props.domainId))
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
if (updates.length) editor.updateShapes(updates)
}
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
const kind = shapeTypeToKind(shape.type)
if (!kind) return null
const s = shape as ExamCanvasTLShape
return {
id: domainIdForShape(s),
kind,
x: Number(s.x ?? 0),
y: Number(s.y ?? 0),
w: Number(s.props.w ?? 1),
h: Number(s.props.h ?? 1),
label: s.props.label,
maxMarks: s.props.maxMarks,
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
contextType: s.props.contextType,
questionId: s.props.questionId ?? null,
source: s.props.source ?? 'manual',
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
derivation: s.props.derivation ?? null,
}
}
function bringDomainShapesToFront(editor: Editor) {
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
}
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!models.length) return
editor.createShapes(models.map((m) => ({
id: createShapeId(m.id),
type: SHAPE_TYPES[m.kind],
x: m.x,
y: m.y,
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
})))
}
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!pages.length) return
const geometries = pageGeometryFromImages(pages)
editor.createShapes(geometries.map((geometry) => {
const page = pages[geometry.pageNumber - 1]
return {
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
type: PDF_PAGE_SHAPE_TYPE,
x: geometry.x,
y: geometry.y,
isLocked: true,
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
} as any
}))
// z-order is enforced by the caller via bringDomainShapesToFront
}
function seedGuide(editor: Editor) {
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
if (current.length) return
editor.createShapes([
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
])
}
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
}
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
if (!status) return 'Auto-map running'
if (status.status === 'queued') return 'Auto-map queued'
if (status.status === 'running') return 'Auto-map running'
if (status.status === 'completed') return 'Auto-map complete'
if (status.status === 'failed') return 'Auto-map failed'
return `Auto-map ${status.status}`
}
const ExamTemplateSetupInner: React.FC = () => {
const { templateId } = useParams<{ templateId: string }>()
const navigate = useNavigate()
const theme = useTheme()
const editorRef = useRef<Editor | null>(null)
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
const [autoMapBusy, setAutoMapBusy] = useState(false)
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
const autoMapPollRef = useRef<number | null>(null)
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
setTemplate(detail)
const editor = editorRef.current
if (editor) {
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
loadShapes(editor, shapes)
if (!shapes.length) seedGuide(editor)
bringDomainShapesToFront(editor)
}
setDirty(false)
}, [])
const acceptShapes = useCallback((shapes: TLShape[]) => {
const editor = editorRef.current
if (!editor) return
const updates = shapes
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
}, [refreshReview])
const selectedDomainShapes = useCallback((): TLShape[] => {
const editor = editorRef.current
if (!editor) return []
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
}, [])
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
const rejectSelected = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const ids = selectedDomainShapes().map((s) => s.id)
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
}, [refreshReview, selectedDomainShapes])
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
// saved-template summary only for the brief moment before shapes have mounted).
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
const load = useCallback(async () => {
if (!templateId) return
setLoading(true); setError(null); setConflict(null)
try {
const detail = await examRepository.getTemplate(templateId)
setTemplate(detail)
let pages: PdfPageImage[] = []
setPdfStatus('loading')
setPdfError(null)
try {
const bytes = await examRepository.getTemplateSourcePdf(templateId)
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
const newPage = partialPages[partialPages.length - 1]
const allGeometries = pageGeometryFromImages(partialPages)
pageGeometriesRef.current = allGeometries
const ed = editorRef.current
if (ed) {
const geometry = allGeometries[partialPages.length - 1]
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
bringDomainShapesToFront(ed)
}
applyDocViewConstraints(ed, partialPages)
}
setPdfStatus('ready')
})
setPdfStatus(pages.length ? 'ready' : 'missing')
} catch (pdfErr) {
const pdfMsg = apiMessage(pdfErr).message
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
setPdfError(pdfMsg)
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
}
const geometries = pageGeometryFromImages(pages)
pageGeometriesRef.current = geometries
const editor = editorRef.current
if (editor) {
syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail, geometries))
bringDomainShapesToFront(editor)
applyDocViewConstraints(editor, pages)
editor.resetZoom()
}
setDirty(false)
} catch (e) {
const msg = apiMessage(e).message
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
setError(msg)
} finally {
setLoading(false)
}
}, [templateId])
useEffect(() => { void load() }, [load])
useEffect(() => () => {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
}, [])
const pollAutoMapStatus = useCallback(async (jobId: string) => {
if (!templateId) return
try {
const status = await examRepository.getAutoMapStatus(templateId, jobId)
setAutoMapStatus(status)
if (status.status === 'completed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
const detail = status.template ?? await examRepository.getTemplate(templateId)
applyTemplateToCanvas(detail)
return
}
if (status.status === 'failed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
return
}
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
} catch (e) {
const msg = apiMessage(e).message
setAutoMapBusy(false)
setError(msg)
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
}
}, [applyTemplateToCanvas, templateId])
const autoMapFromPdf = useCallback(async () => {
if (!templateId || autoMapBusy) return
let queued = false
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
try {
const result = await examRepository.autoMapTemplate(templateId)
if (isAutoMapAccepted(result)) {
queued = true
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
await pollAutoMapStatus(result.job_id)
return
}
setAutoMapStatus(null)
applyTemplateToCanvas(result)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
} finally {
if (!queued) setAutoMapBusy(false)
}
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
const save = useCallback(async () => {
const editor = editorRef.current
if (!editor || !templateId || !template) return
setSaving(true); setError(null); setConflict(null)
try {
ensureDomainIds(editor)
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved)
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
setDirty(false)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
} finally {
setSaving(false)
}
}, [template, templateId])
const layoutSummary = useMemo(() => {
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
}, [template?.layout])
const toolButtons = useMemo(() => TOOLS.map((tool) => (
<Tooltip title={tool.tip} key={tool.id} placement="right">
<Button
size="small"
variant={activeTool === tool.id ? 'contained' : 'outlined'}
color={tool.color}
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
onClick={() => {
const editor = editorRef.current
if (!editor) return
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
setActiveTool(tool.id)
}}
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
>
{tool.label}
</Button>
</Tooltip>
)), [activeTool])
return (
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
{/* Top bar — single compact line */}
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
<Tooltip title="Back to exam marker">
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem />
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper>
{/* Body row */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left tool sidebar */}
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
<Stack spacing={1}>{toolButtons}</Stack>
</Paper>
{/* Canvas area */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
<Tldraw
shapeUtils={examCanvasShapeUtils as any}
tools={examCanvasTools as any}
hideUi
inferDarkMode={theme.palette.mode === 'dark'}
autoFocus
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
// update below can't recurse.
editor.store.listen((entry) => {
try {
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
for (const rec of Object.values(entry.changes.updated)) {
const [from, to] = rec as [any, any]
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
}
if (updates.length) editor.updateShapes(updates as any)
} catch { /* non-fatal: implicit-confirm is best-effort */ }
}, { scope: 'document', source: 'user' })
applyDocViewConstraints(editor, [])
editor.resetZoom()
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
// (Previously `else seedGuide` fired on mount while `template` was still null during
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
if (template) {
const s = shapesFromTemplate(template, pageGeometriesRef.current)
loadShapes(editor, s)
if (!s.length) seedGuide(editor)
}
bringDomainShapesToFront(editor)
}}
/>
</Box>
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
{review.unconfirmed > 0 && (
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
</Tooltip>
<Tooltip title="Reject (delete) the selected shapes">
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
</Tooltip>
<Tooltip title="Accept every AI suggestion on the page">
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
</Tooltip>
</Paper>
)}
{/* Guide toggle */}
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
</IconButton>
</Tooltip>
{/* Guide panel — collapsible */}
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
</Typography>
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
</Alert>
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
const p = canvasShapePalette[kind]
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
})}
</Stack>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
</Typography>
</Paper>
</Collapse>
{/* Conflict alert */}
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
</Box>
</Box>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
</Box>
)
}
const ExamTemplateSetupPage: React.FC = () => (
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
<ExamTemplateSetupInner />
</ErrorBoundary>
)
export default ExamTemplateSetupPage
+263
View File
@@ -0,0 +1,263 @@
import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
import type { TLHandle } from '@tldraw/tldraw'
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
import type { ExamTemplateSource } from '../../../types/exam.types'
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
export const SHAPE_TYPES = {
boundary: 'exam-boundary',
part: 'exam-part',
response: 'exam-region-response',
context: 'exam-region-context',
question_number: 'exam-region-question-number',
mark_area: 'exam-region-mark-area',
reference: 'exam-region-reference',
furniture: 'exam-region-furniture',
} as const
export type ExamPdfPageTLShape = TLBaseBoxShape & {
type: typeof PDF_PAGE_SHAPE_TYPE
props: { w: number; h: number; src: string; pageNumber: number }
}
export type ExamCanvasTLShape = TLBaseBoxShape & {
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
props: {
w: number
h: number
label: string
kind: ExamCanvasShapeKind
maxMarks?: number
responseForm?: string
contextType?: string
questionId?: string | null
domainId?: string
source?: 'manual' | 'ai'
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string
}
}
type CanvasPaletteEntry = {
stroke: string
fill: string
darkStroke: string
darkFill: string
dash?: string
label: string
icon: string
role: string
}
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
`
function confidenceLabel(confidence: number | null | undefined) {
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
}
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
function ghostOpacity(confidence: number | null | undefined) {
if (typeof confidence !== 'number') return 0.72
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
}
function reviewFlags(shape: ExamCanvasTLShape): string[] {
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
}
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
const bits = [base]
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
const confidence = confidenceLabel(shape.props.confidence)
if (confidence) bits.push(`confidence ${confidence}`)
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
const flags = reviewFlags(shape)
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
return bits.join(' • ')
}
function renderBoundaryLine(shape: ExamCanvasTLShape) {
const p = canvasShapePalette.boundary
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
const isAi = shape.props.source === 'ai'
const isGhost = isAi && shape.props.confirmed === false
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
</svg>
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
</HTMLContainer>
)
}
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
const isBoundary = kind === 'boundary'
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
if (isBoundary) return renderBoundaryLine(shape)
const isAi = shape.props.source === 'ai'
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
const title = provenanceTitle(shape, `${p.label}: ${p.role}`)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<div
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
style={{
'--exam-light-stroke': p.stroke,
'--exam-light-fill': p.fill,
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
} as React.CSSProperties}
aria-label={title}
title={title}
>
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{!confidence && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
</div>
</HTMLContainer>
)
}
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = canvasShapePalette[kind]
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
}
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) }
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
static override type = PDF_PAGE_SHAPE_TYPE
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
override canEdit() { return false }
override component(shape: ExamPdfPageTLShape) {
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
</HTMLContainer>
)
}
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
}
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
static override type = SHAPE_TYPES.boundary
static override props = sharedProps
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
override canEdit() { return false }
override canResize() { return false }
override canBind() { return false }
override hideResizeHandles() { return true }
override hideRotateHandle() { return true }
override hideSelectionBoundsBg() { return true }
private pageSpanForY(y: number) {
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
if (!best) return page
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
return pageDy < bestDy ? page : best
}, null)
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
}
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
}
override getGeometry(shape: ExamCanvasTLShape) {
const y = shape.props.h / 2
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
}
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
}
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
return this.normalize({ ...current, x: initial.x })
}
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
}
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
}
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
export function isPdfPageShape(type: string): boolean {
return type === PDF_PAGE_SHAPE_TYPE
}
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
}
+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
}
+428 -311
View File
@@ -1,324 +1,441 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, MenuBook, People } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
import { useUser } from '../../contexts/UserContext';
import Modal from '../../components/common/Modal';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
Dialog, DialogTitle, DialogContent, DialogActions, TextField,
Autocomplete, IconButton, Tooltip, Tabs, Tab, Avatar,
} from '@mui/material';
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_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Profile { id: string; full_name: string; display_name?: string; email: string; }
interface ClassTeacher { teacher_id: string; is_primary: boolean; can_edit: boolean; profile: Profile; }
interface ClassStudent { student_id: string; status: string; enrolled_at: string; profile: Profile; }
interface EnrollmentRequest { id: string; student_id: string; status: string; created_at: string; profile: Profile; }
interface ClassDetail {
id: string;
name: string;
class_code?: string;
subject?: string;
year_group?: string;
description?: string;
is_active: boolean;
teachers: ClassTeacher[];
students: ClassStudent[];
enrollment_requests: EnrollmentRequest[];
student_count: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function initials(name: string) {
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
}
// ─── Add Student Dialog ───────────────────────────────────────────────────────
interface AddStudentDialogProps {
open: boolean;
onClose: () => void;
onAdd: (studentId: string) => Promise<void>;
accessToken: string;
existingIds: Set<string>;
}
function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: AddStudentDialogProps) {
const [allStudents, setAllStudents] = useState<Profile[]>([]);
const [selected, setSelected] = useState<Profile | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
fetch(`${API_BASE}/classes/school/students`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then(r => r.json())
.then(d => setAllStudents((d.students || []).filter((s: Profile) => !existingIds.has(s.id))))
.finally(() => setLoading(false));
}, [open, accessToken, existingIds]);
const handleAdd = async () => {
if (!selected) return;
setSaving(true);
await onAdd(selected.id);
setSaving(false);
setSelected(null);
onClose();
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Add Student to Class</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={28} />
</Box>
) : (
<Autocomplete
options={allStudents}
getOptionLabel={o => `${o.full_name} (${o.email})`}
value={selected}
onChange={(_, v) => setSelected(v)}
renderInput={params => (
<TextField {...params} label="Search students" size="small" autoFocus />
)}
sx={{ mt: 1 }}
/>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button
onClick={handleAdd}
variant="contained"
disabled={!selected || saving}
startIcon={saving ? <CircularProgress size={16} /> : <PersonAdd />}
>
Add
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const ClassDetailPage: React.FC = () => {
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { profile } = useUser();
const {
currentClass,
timetables,
enrolledStudents,
classTeachers,
classDetailLoading,
classDetailError,
fetchClassDetail,
deleteClass,
clearCurrentClass,
} = useTimetableStore();
const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate();
const { accessToken, user, bootstrapData } = useAuth();
const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables');
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [cls, setCls] = useState<ClassDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState(0);
const [isAdmin, setIsAdmin] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (classId) {
fetchClassDetail(classId);
}
return () => {
clearCurrentClass();
const load = useCallback(async () => {
if (!accessToken || !classId) return;
setLoading(true);
setError(null);
try {
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
if (clsRes.id) {
setCls({
...clsRes,
class_code: clsRes.class_code || clsRes.code,
year_group: clsRes.year_group || clsRes.school_year,
teachers: clsRes.teachers || [],
students: clsRes.students || [],
enrollment_requests: clsRes.enrollment_requests || [],
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
});
}
else setError(clsRes.detail || 'Class not found');
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, bootstrapData]);
useEffect(() => { load(); }, [load]);
const apiPost = async (path: string, body?: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
return r.json();
};
}, [classId, fetchClassDetail, clearCurrentClass]);
const handleDeleteClass = async () => {
if (!classId) return;
await deleteClass(classId);
navigate('/timetable/classes');
};
const apiDelete = async (path: string) => {
await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
};
const isOwner = currentClass?.created_by === profile?.id;
const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary);
const apiPatch = async (path: string, body: object) => {
const r = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const handleAddStudent = async (studentId: string) => {
setActionError(null);
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
if (error || !cls) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error || 'Class not found'}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
Back to Classes
</Button>
</Box>
);
}
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
const pendingCount = cls.enrollment_requests.length;
if (classDetailLoading) {
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 (classDetailError || !currentClass) {
return (
<div className="container mx-auto px-4 py-6">
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Class</h2>
<p className="text-red-600">{classDetailError || 'Class not found'}</p>
<Link to="/timetable/classes" className="text-blue-600 hover:underline mt-4 inline-block">
Back to Classes
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Header */}
<div className="mb-6">
<Link
to="/timetable/classes"
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowBack size={18} />
Back to Classes
</Link>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{currentClass.name}</h1>
<span className="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
{currentClass.subject}
</span>
</div>
<p className="text-gray-500">
{currentClass.school_year} {currentClass.academic_term}
</p>
</div>
{(isOwner || isTeacher) && (
<div className="flex items-center gap-2">
<Link
to={`/timetable/classes/${classId}/edit`}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<Edit size={18} />
Edit
</Link>
<button
onClick={() => setShowDeleteModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
>
<Delete size={18} />
Delete
</button>
</div>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<CalendarToday className="text-blue-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Timetables</p>
<p className="text-xl font-semibold text-gray-900">{currentClass.timetable_count}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 rounded-lg">
<People className="text-green-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Students</p>
<p className="text-xl font-semibold text-gray-900">{currentClass.student_count}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 rounded-lg">
<MenuBook className="text-purple-600" size={20} />
</div>
<div>
<p className="text-sm text-gray-500">Teachers</p>
<p className="text-xl font-semibold text-gray-900">{classTeachers.length}</p>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-6">
<button
onClick={() => setActiveTab('timetables')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'timetables'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Timetables
</button>
<button
onClick={() => setActiveTab('students')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'students'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Students ({enrolledStudents.length})
</button>
<button
onClick={() => setActiveTab('teachers')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'teachers'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Teachers ({classTeachers.length})
</button>
</div>
</div>
{/* Tab Content */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{activeTab === 'timetables' && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Timetables</h2>
{(isOwner || isTeacher) && (
<Link
to={`/timetable/classes/${classId}/timetables/new`}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Add size={18} />
Add Timetable
</Link>
)}
</div>
{timetables.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<AccessTime size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No timetables yet</p>
<p>Create a timetable to start scheduling lessons</p>
</div>
) : (
<div className="space-y-3">
{timetables.map((timetable) => (
<Link
key={timetable.id}
to={`/timetable/timetables/${timetable.id}`}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<div>
<h3 className="font-medium text-gray-900">{timetable.name}</h3>
<p className="text-sm text-gray-500">
{timetable.lesson_count} lessons
{timetable.is_recurring && ' • Recurring'}
</p>
</div>
<ArrowBack className="rotate-180 text-gray-400" size={18} />
</Link>
))}
</div>
)}
</div>
)}
{activeTab === 'students' && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Enrolled Students</h2>
{enrolledStudents.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<People size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No students enrolled</p>
<p>Students can request enrollment or be added by teachers</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{enrolledStudents.map((student) => (
<div
key={student.user_id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
>
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-medium">
{student.full_name.charAt(0)}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{student.full_name}</p>
<p className="text-sm text-gray-500">{student.email}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'teachers' && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Teachers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{classTeachers.map((teacher) => (
<div
key={teacher.teacher_id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg"
>
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-medium">
{teacher.full_name.charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">{teacher.full_name}</p>
<p className="text-sm text-gray-500">{teacher.email}</p>
</div>
{teacher.is_primary && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
Primary
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Delete Modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Delete Class"
>
<div className="p-6">
<p className="text-gray-600 mb-6">
Are you sure you want to delete "{currentClass.name}"? This action cannot be undone and will remove all timetables, lessons, and whiteboards associated with this class.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteModal(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
{/* Header */}
<Button
size="small"
startIcon={<ArrowBack />}
onClick={() => navigate('/classes')}
sx={{ mb: 2 }}
>
Cancel
</button>
<button
onClick={handleDeleteClass}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Delete Class
</button>
</div>
</div>
</Modal>
</div>
);
Back to Classes
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
{cls.class_code && <Chip label={cls.class_code} size="small" />}
{cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
{cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
<Chip
label={cls.is_active ? 'Active' : 'Inactive'}
size="small"
color={cls.is_active ? 'success' : 'default'}
/>
</Box>
{cls.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{cls.description}
</Typography>
)}
</Box>
<School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
</Box>
{actionError && (
<Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
{actionError}
</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})`} />
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
<Tab label={`Teachers (${cls.teachers.length})`} />
</Tabs>
{/* Students tab */}
{tab === 0 && (
<Box>
{isAdmin && (
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
size="small"
startIcon={<PersonAdd />}
onClick={() => setAddOpen(true)}
>
Add Student
</Button>
</Box>
)}
{cls.students.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No students enrolled yet
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.students.map(s => (
<Box
key={s.student_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
{initials(s.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{s.profile?.full_name || s.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{s.profile?.email}
</Typography>
</Box>
{isAdmin && (
<Tooltip title="Remove student">
<IconButton
size="small"
color="error"
onClick={() => handleRemoveStudent(s.student_id)}
>
<PersonRemove fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Enrollment requests tab */}
{tab === 1 && (
<Box>
{cls.enrollment_requests.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No pending enrollment requests
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.enrollment_requests.map(req => (
<Box
key={req.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'warning.light',
borderRadius: 1, bgcolor: 'warning.50',
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
{initials(req.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{req.profile?.full_name || req.student_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{req.profile?.email} · requested{' '}
{new Date(req.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
{isAdmin && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Approve">
<IconButton
size="small"
color="success"
onClick={() => handleEnrollmentResponse(req.id, 'approve')}
>
<CheckCircle fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Reject">
<IconButton
size="small"
color="error"
onClick={() => handleEnrollmentResponse(req.id, 'reject')}
>
<Cancel fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{/* Teachers tab */}
{tab === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.teachers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
No teachers assigned
</Typography>
) : (
cls.teachers.map(t => (
<Box
key={t.teacher_id}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
>
<Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
{initials(t.profile?.full_name || '?')}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{t.profile?.full_name || t.teacher_id}
</Typography>
<Typography variant="caption" color="text.secondary">
{t.profile?.email}
</Typography>
</Box>
{t.is_primary && (
<Chip label="Primary" size="small" color="primary" />
)}
</Box>
))
)}
</Box>
)}
<AddStudentDialog
open={addOpen}
onClose={() => setAddOpen(false)}
onAdd={handleAddStudent}
accessToken={accessToken || ''}
existingIds={existingStudentIds}
/>
</Box>
);
};
export default ClassDetailPage;
@@ -0,0 +1,531 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip, TextField,
IconButton, Tooltip, Divider, Paper, Select, MenuItem, FormControl,
InputLabel, Switch, FormControlLabel, Dialog, DialogTitle,
DialogContent, DialogActions,
} from '@mui/material';
import {
ArrowBack, Add, Delete, AutoAwesome, DragIndicator, Save,
LibraryBooks, LinkOff,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Objective {
id: string;
text: string;
bloom_level?: string;
order: number;
}
interface Activity {
id: string;
section: string;
title: string;
description: string;
duration_minutes?: number;
order: number;
}
interface LessonPlan {
id: string;
title: string;
subject: string | null;
year_group: string | null;
duration_minutes: number | null;
context_notes: string | null;
objectives: Objective[];
activities: Activity[];
is_public: boolean;
creator_id: string;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const BLOOM_LEVELS = ['remember', 'understand', 'apply', 'analyse', 'evaluate', 'create'];
const SECTION_SUGGESTIONS = ['Starter', 'Main', 'Plenary', 'Extension', 'Assessment', 'Discussion'];
const BLOOM_COLORS: Record<string, string> = {
remember: '#90caf9', understand: '#a5d6a7', apply: '#fff176',
analyse: '#ffcc80', evaluate: '#ef9a9a', create: '#ce93d8',
};
// ─── AI Suggest button ────────────────────────────────────────────────────────
function SuggestButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<Tooltip title="AI suggest">
<span>
<IconButton size="small" onClick={onClick} disabled={loading} color="primary" sx={{ p: 0.5 }}>
{loading ? <CircularProgress size={16} /> : <AutoAwesome fontSize="small" />}
</IconButton>
</span>
</Tooltip>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const LessonPlanDetailPage: React.FC = () => {
const { planId } = useParams<{ planId: string }>();
const navigate = useNavigate();
const { accessToken } = useAuth();
const [plan, setPlan] = useState<LessonPlan | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [suggestingField, setSuggestingField] = useState<string | null>(null);
// Local editable state (mirrors plan)
const [title, setTitle] = useState('');
const [subject, setSubject] = useState('');
const [yearGroup, setYearGroup] = useState('');
const [duration, setDuration] = useState('');
const [contextNotes, setContextNotes] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [objectives, setObjectives] = useState<Objective[]>([]);
const [activities, setActivities] = useState<Activity[]>([]);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async () => {
if (!accessToken || !planId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/lessons/plans/${planId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
if (data.id) {
setPlan(data);
setTitle(data.title || '');
setSubject(data.subject || '');
setYearGroup(data.year_group || '');
setDuration(data.duration_minutes?.toString() || '');
setContextNotes(data.context_notes || '');
setIsPublic(data.is_public || false);
setObjectives(data.objectives || []);
setActivities(data.activities || []);
} else {
setError(data.detail || 'Plan not found');
}
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken, planId]);
useEffect(() => { load(); }, [load]);
// ── Save ──────────────────────────────────────────────────────────────────
const save = useCallback(async (
overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }>
) => {
if (!accessToken || !planId) return;
setSaving(true);
setSaved(false);
try {
await fetch(`${API_BASE}/lessons/plans/${planId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
subject: subject || null,
year_group: yearGroup || null,
duration_minutes: duration ? parseInt(duration, 10) : null,
context_notes: contextNotes || null,
is_public: isPublic,
objectives,
activities,
...overrides,
}),
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, isPublic, objectives, activities]);
const scheduleAutoSave = useCallback(() => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(() => save(), 1500);
}, [save]);
// ── Objectives ────────────────────────────────────────────────────────────
const addObjective = () => {
const next: Objective = { id: crypto.randomUUID(), text: '', bloom_level: undefined, order: objectives.length };
setObjectives(prev => [...prev, next]);
};
const updateObjective = (id: string, patch: Partial<Objective>) => {
setObjectives(prev => prev.map(o => o.id === id ? { ...o, ...patch } : o));
scheduleAutoSave();
};
const removeObjective = (id: string) => {
setObjectives(prev => prev.filter(o => o.id !== id).map((o, i) => ({ ...o, order: i })));
scheduleAutoSave();
};
// ── Activities ────────────────────────────────────────────────────────────
const addActivity = () => {
const next: Activity = {
id: crypto.randomUUID(), section: 'Main', title: '', description: '', order: activities.length,
};
setActivities(prev => [...prev, next]);
};
const updateActivity = (id: string, patch: Partial<Activity>) => {
setActivities(prev => prev.map(a => a.id === id ? { ...a, ...patch } : a));
scheduleAutoSave();
};
const removeActivity = (id: string) => {
setActivities(prev => prev.filter(a => a.id !== id).map((a, i) => ({ ...a, order: i })));
scheduleAutoSave();
};
// ── AI Suggest ────────────────────────────────────────────────────────────
const suggest = async (field: string, targetId?: string) => {
if (!accessToken || !planId) return;
const key = targetId ? `${field}_${targetId}` : field;
setSuggestingField(key);
try {
const body: Record<string, any> = {
field,
context: contextNotes,
objective_texts: objectives.map(o => o.text).filter(Boolean),
};
if (field === 'activity_description' && targetId) {
const act = activities.find(a => a.id === targetId);
if (act) body.activity_section = act.section;
}
const res = await fetch(`${API_BASE}/lessons/plans/${planId}/suggest`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.suggestion) {
if (field === 'objectives') {
addObjective();
setObjectives(prev => {
const updated = [...prev];
updated[updated.length - 1] = { ...updated[updated.length - 1], text: data.suggestion };
return updated;
});
} else if (field === 'activity_description' && targetId) {
updateActivity(targetId, { description: data.suggestion });
} else if (field === 'title') {
setTitle(data.suggestion);
scheduleAutoSave();
}
}
} catch (e: any) {
setError('AI suggest failed: ' + e.message);
} finally {
setSuggestingField(null);
}
};
// ── Render ────────────────────────────────────────────────────────────────
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
);
}
if (error && !plan) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
Back to Plans
</Button>
</Box>
);
}
return (
<Box sx={{ p: 3, maxWidth: 860, mx: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Button size="small" startIcon={<ArrowBack />} onClick={() => navigate('/lesson-plans')}>
Lesson Plans
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{saved && <Typography variant="caption" color="success.main">Saved</Typography>}
<Button
variant="contained"
size="small"
startIcon={saving ? <CircularProgress size={14} /> : <Save />}
onClick={() => save()}
disabled={saving}
>
Save
</Button>
</Box>
</Box>
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>
)}
{/* Title row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
<TextField
value={title}
onChange={e => { setTitle(e.target.value); scheduleAutoSave(); }}
variant="standard"
fullWidth
inputProps={{ style: { fontSize: '1.5rem', fontWeight: 700 } }}
placeholder="Lesson title…"
/>
<SuggestButton
onClick={() => suggest('title')}
loading={suggestingField === 'title'}
/>
</Box>
{/* Metadata row */}
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 3, alignItems: 'center' }}>
<TextField
label="Subject"
value={subject}
onChange={e => { setSubject(e.target.value); scheduleAutoSave(); }}
size="small"
sx={{ width: 200 }}
/>
<TextField
label="Year group"
value={yearGroup}
onChange={e => { setYearGroup(e.target.value); scheduleAutoSave(); }}
size="small"
sx={{ width: 120 }}
/>
<TextField
label="Duration (min)"
value={duration}
onChange={e => { setDuration(e.target.value); scheduleAutoSave(); }}
size="small"
type="number"
inputProps={{ min: 1, max: 300 }}
sx={{ width: 140 }}
/>
<FormControlLabel
control={
<Switch
size="small"
checked={isPublic}
onChange={e => { setIsPublic(e.target.checked); scheduleAutoSave(); }}
/>
}
label={<Typography variant="caption">Public</Typography>}
sx={{ ml: 0.5 }}
/>
</Box>
{/* Context notes */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 0.75 }}>
Context / notes
</Typography>
<TextField
value={contextNotes}
onChange={e => { setContextNotes(e.target.value); scheduleAutoSave(); }}
multiline
minRows={2}
fullWidth
size="small"
placeholder="Class context, prior knowledge, SEN considerations, links to curriculum…"
/>
</Box>
<Divider sx={{ mb: 3 }} />
{/* Objectives */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
<Typography variant="subtitle1" fontWeight={700}>
Learning objectives
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<SuggestButton
onClick={() => suggest('objectives')}
loading={suggestingField === 'objectives'}
/>
<Tooltip title="Add objective">
<IconButton size="small" onClick={addObjective}>
<Add fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{objectives.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
No objectives yet add one or use to suggest.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{objectives.map((obj, idx) => (
<Paper key={obj.id} variant="outlined" sx={{ p: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<DragIndicator sx={{ color: 'text.disabled', mt: 0.5, flexShrink: 0 }} fontSize="small" />
<TextField
value={obj.text}
onChange={e => updateObjective(obj.id, { text: e.target.value })}
fullWidth
size="small"
multiline
placeholder={`Objective ${idx + 1}`}
variant="standard"
/>
<FormControl size="small" sx={{ minWidth: 120, flexShrink: 0 }}>
<Select
value={obj.bloom_level || ''}
onChange={e => updateObjective(obj.id, { bloom_level: e.target.value || undefined })}
displayEmpty
variant="standard"
renderValue={v => v ? (
<Chip
label={v as string}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[v as string] || 'grey.200' }}
/>
) : <Typography variant="caption" color="text.disabled">Bloom level</Typography>}
>
<MenuItem value=""><em>None</em></MenuItem>
{BLOOM_LEVELS.map(l => (
<MenuItem key={l} value={l}>
<Chip
label={l}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: BLOOM_COLORS[l] || 'grey.200' }}
/>
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => removeObjective(obj.id)} sx={{ flexShrink: 0, p: 0.25 }}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Paper>
))}
</Box>
)}
</Box>
<Divider sx={{ mb: 3 }} />
{/* Activities */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
<Typography variant="subtitle1" fontWeight={700}>
Activities
</Typography>
<Tooltip title="Add activity">
<IconButton size="small" onClick={addActivity}>
<Add fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{activities.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1.5, textAlign: 'center' }}>
No activities yet add one to structure your lesson.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{activities.map((act, idx) => (
<Paper key={act.id} variant="outlined" sx={{ p: 1.5 }}>
{/* Activity header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<DragIndicator sx={{ color: 'text.disabled', flexShrink: 0 }} fontSize="small" />
{/* Section selector */}
<TextField
value={act.section}
onChange={e => updateActivity(act.id, { section: e.target.value })}
size="small"
label="Section"
sx={{ width: 140, flexShrink: 0 }}
select={false}
inputProps={{ list: `section-suggestions-${act.id}` }}
placeholder="Starter / Main…"
/>
<datalist id={`section-suggestions-${act.id}`}>
{SECTION_SUGGESTIONS.map(s => <option key={s} value={s} />)}
</datalist>
<TextField
value={act.title}
onChange={e => updateActivity(act.id, { title: e.target.value })}
size="small"
label="Activity title"
fullWidth
placeholder={`Activity ${idx + 1}`}
/>
<TextField
value={act.duration_minutes?.toString() || ''}
onChange={e => updateActivity(act.id, { duration_minutes: e.target.value ? parseInt(e.target.value, 10) : undefined })}
size="small"
label="Min"
type="number"
inputProps={{ min: 1 }}
sx={{ width: 72, flexShrink: 0 }}
/>
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => removeActivity(act.id)} sx={{ flexShrink: 0, p: 0.25 }}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Description */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, pl: 3.5 }}>
<TextField
value={act.description}
onChange={e => updateActivity(act.id, { description: e.target.value })}
size="small"
multiline
minRows={2}
fullWidth
placeholder="Describe what the teacher and pupils do…"
/>
<SuggestButton
onClick={() => suggest('activity_description', act.id)}
loading={suggestingField === `activity_description_${act.id}`}
/>
</Box>
</Paper>
))}
</Box>
)}
</Box>
</Box>
);
};
export default LessonPlanDetailPage;
+328
View File
@@ -0,0 +1,328 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, CircularProgress, Alert, Chip, Card,
CardContent, CardActions, IconButton, Tooltip, TextField,
InputAdornment, MenuItem, Select, FormControl, InputLabel,
Dialog, DialogTitle, DialogContent, DialogActions,
} from '@mui/material';
import { Add, Search, LibraryBooks, Edit, Delete, FilterList } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface PlanSummary {
id: string;
title: string;
subject: string | null;
year_group: string | null;
duration_minutes: number | null;
objectives: { id: string; text: string; bloom_level?: string }[];
activities: { id: string; section: string; title: string }[];
is_public: boolean;
created_at: string;
updated_at: string;
creator_name?: string;
}
// ─── Create plan dialog ───────────────────────────────────────────────────────
interface CreateDialogProps {
open: boolean;
onClose: () => void;
onCreate: (data: { title: string; subject?: string; year_group?: string }) => Promise<void>;
}
function CreatePlanDialog({ open, onClose, onCreate }: CreateDialogProps) {
const [title, setTitle] = useState('');
const [subject, setSubject] = useState('');
const [yearGroup, setYearGroup] = useState('');
const [saving, setSaving] = useState(false);
const reset = () => { setTitle(''); setSubject(''); setYearGroup(''); };
const handleCreate = async () => {
if (!title.trim()) return;
setSaving(true);
await onCreate({ title: title.trim(), subject: subject || undefined, year_group: yearGroup || undefined });
setSaving(false);
reset();
onClose();
};
const handleClose = () => { reset(); onClose(); };
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>New Lesson Plan</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 0.5 }}>
<TextField
label="Title"
value={title}
onChange={e => setTitle(e.target.value)}
size="small"
fullWidth
autoFocus
required
placeholder="e.g. Introduction to Photosynthesis"
/>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<TextField
label="Subject"
value={subject}
onChange={e => setSubject(e.target.value)}
size="small"
fullWidth
placeholder="e.g. Biology"
/>
<TextField
label="Year group"
value={yearGroup}
onChange={e => setYearGroup(e.target.value)}
size="small"
sx={{ width: 140 }}
placeholder="e.g. 9"
/>
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} disabled={saving}>Cancel</Button>
<Button
onClick={handleCreate}
variant="contained"
disabled={!title.trim() || saving}
startIcon={saving ? <CircularProgress size={16} /> : <Add />}
>
Create
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Plan card ────────────────────────────────────────────────────────────────
function PlanCard({ plan, onOpen, onDelete }: {
plan: PlanSummary;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
}) {
const objCount = plan.objectives?.length ?? 0;
const actCount = plan.activities?.length ?? 0;
return (
<Card
variant="outlined"
sx={{
display: 'flex', flexDirection: 'column', cursor: 'pointer',
'&:hover': { borderColor: 'primary.main', boxShadow: 1 },
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onClick={() => onOpen(plan.id)}
>
<CardContent sx={{ flex: 1, pb: 1 }}>
<Typography variant="body1" fontWeight={700} sx={{ mb: 0.5, lineHeight: 1.3 }}>
{plan.title}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mb: 1 }}>
{plan.subject && (
<Chip label={plan.subject} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
{plan.year_group && (
<Chip label={`Y${plan.year_group}`} size="small" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
{plan.duration_minutes && (
<Chip label={`${plan.duration_minutes}min`} size="small" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
{plan.is_public && (
<Chip label="Public" size="small" color="info" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
</Box>
<Typography variant="caption" color="text.secondary">
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
</Typography>
</CardContent>
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
<Tooltip title="Edit plan">
<IconButton size="small" onClick={() => onOpen(plan.id)}>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete plan">
<IconButton size="small" color="error" onClick={() => onDelete(plan.id)}>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</CardActions>
</Card>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const LessonPlansPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [plans, setPlans] = useState<PlanSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState('');
const [filterSubject, setFilterSubject] = useState('');
const load = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/lessons/plans`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
if (Array.isArray(data)) setPlans(data);
else setError(data.detail || 'Failed to load plans');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { load(); }, [load]);
const handleCreate = async (body: { title: string; subject?: string; year_group?: string }) => {
const res = await fetch(`${API_BASE}/lessons/plans`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.id) {
navigate(`/lesson-plans/${data.id}`);
} else {
load();
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Delete this lesson plan?')) return;
await fetch(`${API_BASE}/lessons/plans/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
setPlans(prev => prev.filter(p => p.id !== id));
};
const subjects = Array.from(new Set(plans.map(p => p.subject).filter(Boolean) as string[])).sort();
const filtered = plans.filter(p => {
const matchSearch = !search || p.title.toLowerCase().includes(search.toLowerCase())
|| (p.subject || '').toLowerCase().includes(search.toLowerCase());
const matchSubject = !filterSubject || p.subject === filterSubject;
return matchSearch && matchSubject;
});
return (
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<LibraryBooks sx={{ color: 'primary.main', fontSize: 32 }} />
<Box>
<Typography variant="h5" fontWeight={700}>Lesson Plans</Typography>
<Typography variant="caption" color="text.secondary">
{plans.length} plan{plans.length !== 1 ? 's' : ''}
</Typography>
</Box>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateOpen(true)}
>
New Plan
</Button>
</Box>
{/* Filters */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center' }}>
<TextField
size="small"
placeholder="Search plans…"
value={search}
onChange={e => setSearch(e.target.value)}
InputProps={{
startAdornment: <InputAdornment position="start"><Search fontSize="small" /></InputAdornment>,
}}
sx={{ width: 280 }}
/>
{subjects.length > 0 && (
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Subject</InputLabel>
<Select
label="Subject"
value={filterSubject}
onChange={e => setFilterSubject(e.target.value)}
>
<MenuItem value="">All subjects</MenuItem>
{subjects.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</Select>
</FormControl>
)}
{(search || filterSubject) && (
<Button
size="small"
startIcon={<FilterList />}
onClick={() => { setSearch(''); setFilterSubject(''); }}
>
Clear
</Button>
)}
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : filtered.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8, color: 'text.secondary' }}>
<LibraryBooks sx={{ fontSize: 56, opacity: 0.2, mb: 1 }} />
<Typography variant="body1">
{plans.length === 0 ? 'No lesson plans yet.' : 'No plans match your filters.'}
</Typography>
{plans.length === 0 && (
<Button sx={{ mt: 2 }} variant="outlined" startIcon={<Add />} onClick={() => setCreateOpen(true)}>
Create your first plan
</Button>
)}
</Box>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2 }}>
{filtered.map(plan => (
<PlanCard
key={plan.id}
plan={plan}
onOpen={id => navigate(`/lesson-plans/${id}`)}
onDelete={handleDelete}
/>
))}
</Box>
)}
<CreatePlanDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreate={handleCreate}
/>
</Box>
);
};
export default LessonPlansPage;
+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
+322
View File
@@ -0,0 +1,322 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
Table, TableHead, TableBody, TableRow, TableCell,
Select, MenuItem, FormControl, Tooltip, Divider,
Accordion, AccordionSummary, AccordionDetails, Grid,
Card, CardContent, IconButton,
} from '@mui/material';
import {
ExpandMore, People, School, MenuBook, EventNote,
HourglassEmpty, Refresh, Edit,
} from '@mui/icons-material';
import { useNavigate } from 'react-router';
import { useAuth } from '../../contexts/AuthContext';
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'> = {
Academic: 'primary',
Holiday: 'warning',
Staff: 'success',
OffTimetable: 'default',
};
interface Term {
id: string;
term_name: string;
term_number: number;
start_date: string;
end_date: string;
academic_days: number;
total_days: number;
is_current: boolean;
}
interface CalendarDay {
id: string;
date: string;
day_of_week: string;
day_type: string;
week_cycle: string;
week_number: number | null;
academic_day_number: number | null;
excluded_period_codes: string[];
}
interface Overview {
user_role: string;
counts: {
staff: number;
students: number;
classes: number;
pending_invitations: number;
};
terms: Term[];
has_calendar: boolean;
}
// ─── Stat card ────────────────────────────────────────────────────────────────
function StatCard({ label, value, icon: Icon, onClick }: {
label: string; value: number | string; icon: React.ElementType; onClick?: () => void;
}) {
return (
<Card
sx={{ cursor: onClick ? 'pointer' : 'default', '&:hover': onClick ? { bgcolor: 'action.hover' } : {} }}
onClick={onClick}
>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Icon sx={{ color: 'primary.main', fontSize: 28 }} />
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, lineHeight: 1 }}>{value}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
</Box>
</CardContent>
</Card>
);
}
// ─── Calendar days table ──────────────────────────────────────────────────────
function CalendarDaysTable({ termId, headers, isAdmin }: {
termId: string; headers: Record<string, string>; isAdmin: boolean;
}) {
const [days, setDays] = useState<CalendarDay[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch(`${API_BASE}/school/calendar/days?term_id=${termId}`, { headers })
.then(r => r.json())
.then(data => setDays(data.days || []))
.catch(() => {})
.finally(() => setLoading(false));
}, [termId]);
const handleTypeChange = async (dayId: string, newType: string) => {
setSaving(dayId);
try {
await fetch(`${API_BASE}/school/calendar/days/${dayId}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ day_type: newType }),
});
setDays(prev => prev.map(d => d.id === dayId ? { ...d, day_type: newType } : d));
} catch { }
setSaving(null);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>;
return (
<Box sx={{ overflowX: 'auto' }}>
<Table size="small" sx={{ minWidth: 500 }}>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Day</TableCell>
<TableCell>Week</TableCell>
<TableCell>Cycle</TableCell>
<TableCell>Type</TableCell>
</TableRow>
</TableHead>
<TableBody>
{days.map(day => (
<TableRow key={day.id} sx={{ opacity: day.day_type !== 'Academic' ? 0.7 : 1 }}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.78rem' }}>{day.date}</TableCell>
<TableCell sx={{ fontSize: '0.78rem' }}>{day.day_of_week.slice(0, 3)}</TableCell>
<TableCell sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{day.week_number ?? '—'}</TableCell>
<TableCell>
{day.week_cycle
? <Chip label={`W${day.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
: <Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>}
</TableCell>
<TableCell>
{isAdmin ? (
<FormControl size="small" sx={{ minWidth: 110 }}>
<Select
value={day.day_type}
onChange={e => handleTypeChange(day.id, e.target.value)}
disabled={saving === day.id}
sx={{ fontSize: '0.75rem', height: 24 }}
>
{DAY_TYPE_OPTIONS.map(opt => (
<MenuItem key={opt} value={opt} sx={{ fontSize: '0.78rem' }}>{opt}</MenuItem>
))}
</Select>
</FormControl>
) : (
<Chip
label={day.day_type}
size="small"
color={DAY_TYPE_COLORS[day.day_type] ?? 'default'}
sx={{ fontSize: '0.65rem', height: 18 }}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const SchoolSettingsPage: React.FC = () => {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [overview, setOverview] = useState<Overview | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedTerm, setExpandedTerm] = useState<string | false>(false);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadOverview = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/school/overview`, { headers });
const data = await res.json();
if (data.status === 'ok') setOverview(data);
else setError(data.detail || 'Failed to load school overview');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadOverview(); }, [loadOverview]);
const isAdmin = overview?.user_role === 'school_admin' || overview?.user_role === 'department_head';
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}><CircularProgress /></Box>;
return (
<Box sx={{ p: 3, maxWidth: 960, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>School Settings</Typography>
{overview && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{overview.user_role}
</Typography>
)}
</Box>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadOverview}><Refresh fontSize="small" /></IconButton>
</Tooltip>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{overview && (
<>
{/* Stat cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}>
<StatCard label="Staff" value={overview.counts.staff} icon={People} onClick={() => navigate('/staff-manager')} />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard label="Students" value={overview.counts.students} icon={School} onClick={() => navigate('/student-manager')} />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard label="Classes" value={overview.counts.classes} icon={MenuBook} onClick={() => navigate('/classes')} />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard label="Pending invites" value={overview.counts.pending_invitations} icon={HourglassEmpty} />
</Grid>
</Grid>
<Divider sx={{ mb: 3 }} />
{/* Calendar */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>Academic Calendar</Typography>
{!overview.has_calendar && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
No calendar configured
</Typography>
)}
</Box>
{overview.terms.length === 0 ? (
<Alert severity="info">
No academic calendar set up yet. Use the School Calendar Wizard in the navigation panel to set one up.
</Alert>
) : (
overview.terms.map(term => (
<Accordion
key={term.id}
expanded={expandedTerm === term.id}
onChange={(_, open) => setExpandedTerm(open ? term.id : false)}
sx={{ mb: 0.5 }}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 80 }}>
{term.term_name}
</Typography>
{term.is_current && (
<Chip label="Current" size="small" color="primary" sx={{ fontSize: '0.65rem', height: 18 }} />
)}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{term.start_date} {term.end_date}
</Typography>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
<Chip label={`${term.academic_days} teaching days`} size="small" sx={{ fontSize: '0.65rem', height: 18 }} />
{term.total_days > term.academic_days && (
<Chip
label={`${term.total_days - term.academic_days} non-teaching`}
size="small"
color="warning"
sx={{ fontSize: '0.65rem', height: 18 }}
/>
)}
</Box>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<CalendarDaysTable termId={term.id} headers={headers} isAdmin={isAdmin} />
{isAdmin && (
<Typography variant="caption" sx={{ display: 'block', px: 2, py: 1, color: 'text.secondary' }}>
Changing a day type automatically creates or removes its periods.
</Typography>
)}
</AccordionDetails>
</Accordion>
))
)}
<Divider sx={{ my: 3 }} />
{/* Quick links */}
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Manage</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Button variant="outlined" size="small" startIcon={<People />} onClick={() => navigate('/staff-manager')}>
Staff Manager
</Button>
<Button variant="outlined" size="small" startIcon={<School />} onClick={() => navigate('/student-manager')}>
Student Manager
</Button>
<Button variant="outlined" size="small" startIcon={<MenuBook />} onClick={() => navigate('/classes')}>
Classes
</Button>
<Button variant="outlined" size="small" startIcon={<EventNote />} onClick={() => navigate('/my-lessons')}>
My Lessons
</Button>
</Box>
</>
)}
</Box>
);
};
export default SchoolSettingsPage;
+280
View File
@@ -0,0 +1,280 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
TextField, Select, MenuItem, FormControl, InputLabel,
Table, TableHead, TableBody, TableRow, TableCell,
IconButton, Tooltip, Divider,
} from '@mui/material';
import {
PersonAdd, Cancel, Send, Refresh,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StaffMember {
profile_id: string;
email: string | null;
username: string | null;
display_name: string | null;
role: string;
joined_at: string;
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
created_at: string;
expires_at: string;
}
const ROLE_COLORS: Record<string, 'default' | 'primary' | 'success' | 'warning'> = {
school_admin: 'primary',
department_head: 'warning',
teacher: 'success',
};
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
pending: 'warning',
accepted: 'success',
expired: 'error',
cancelled: 'default',
};
// ─── Component ───────────────────────────────────────────────────────────────
const StaffManagerPage: React.FC = () => {
const { accessToken } = useAuth();
const [staff, setStaff] = useState<StaffMember[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Invite form
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState('teacher');
const [inviting, setInviting] = useState(false);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadData = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const [staffRes, invRes] = await Promise.all([
fetch(`${API_BASE}/users/staff`, { headers }),
fetch(`${API_BASE}/users/invitations?role=teacher&status=pending`, { headers })
.then(r => r.json())
.catch(() => ({ invitations: [] })),
].map((p, i) => i === 0 ? (p as Promise<Response>).then(r => r.json()) : p));
if (staffRes.status === 'ok') setStaff(staffRes.staff || []);
else setError(staffRes.detail || 'Failed to load staff');
setInvitations(invRes.invitations || []);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadData(); }, [loadData]);
const handleInvite = async () => {
if (!inviteEmail.trim() || !accessToken) return;
setInviting(true);
setError(null);
setSuccessMsg(null);
try {
const res = await fetch(`${API_BASE}/users/invite`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inviteEmail.trim().toLowerCase(), role: inviteRole }),
});
const data = await res.json();
if (data.status === 'ok' || data.status === 'already_pending') {
setSuccessMsg(
data.status === 'already_pending'
? `Invitation already pending for ${inviteEmail}`
: `Invitation sent to ${inviteEmail}`
);
setInviteEmail('');
loadData();
} else {
setError(data.detail || data.message || 'Invite failed');
}
} catch (e: any) {
setError(e.message);
} finally {
setInviting(false);
}
};
const handleCancel = async (id: string, email: string) => {
if (!accessToken) return;
try {
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
setSuccessMsg(`Cancelled invitation for ${email}`);
loadData();
} catch (e: any) {
setError(e.message);
}
};
const handleResend = async (id: string, email: string) => {
if (!accessToken) return;
try {
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
const data = await res.json();
if (data.status === 'ok') {
setSuccessMsg(`Re-sent invitation to ${email}`);
} else {
setError(data.detail || 'Resend failed');
}
} catch (e: any) {
setError(e.message);
}
};
return (
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Staff Manager</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Invite teachers, department heads, and school admins
</Typography>
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
{/* Invite form */}
<Box sx={{ display: 'flex', gap: 1, mt: 3, alignItems: 'flex-end' }}>
<TextField
label="Email address"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleInvite()}
size="small"
sx={{ flex: 1 }}
placeholder="[email protected]"
disabled={inviting}
/>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Role</InputLabel>
<Select label="Role" value={inviteRole} onChange={e => setInviteRole(e.target.value)} disabled={inviting}>
<MenuItem value="teacher">Teacher</MenuItem>
<MenuItem value="department_head">Department Head</MenuItem>
<MenuItem value="school_admin">School Admin</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
onClick={handleInvite}
disabled={inviting || !inviteEmail.trim()}
sx={{ whiteSpace: 'nowrap' }}
>
Send Invite
</Button>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}>
<Refresh fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Pending invitations */}
{invitations.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Pending Invitations ({invitations.length})</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>Status</TableCell>
<TableCell>Sent</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invitations.map(inv => (
<TableRow key={inv.id}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
<TableCell>
<Chip label={inv.role} size="small" color={ROLE_COLORS[inv.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell>
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell align="right">
<Tooltip title="Resend">
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
<Send fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Cancel">
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
<Cancel fontSize="inherit" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
<Divider sx={{ my: 3 }} />
{/* Active staff */}
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Active Staff ({loading ? '…' : staff.length})
</Typography>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : staff.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No staff members yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>Joined</TableCell>
</TableRow>
</TableHead>
<TableBody>
{staff.map(s => (
<TableRow key={s.profile_id}>
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
{s.display_name || s.username || '—'}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
{s.email || '—'}
</TableCell>
<TableCell>
<Chip label={s.role} size="small" color={ROLE_COLORS[s.role] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
);
};
export default StaffManagerPage;
+208
View File
@@ -0,0 +1,208 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Typography, CircularProgress, Alert, Chip, IconButton, Tooltip,
} from '@mui/material';
import { ChevronLeft, ChevronRight, Today } from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Lesson {
id: string;
date: string;
period_code: string;
period_name: string;
start_time: string | null;
end_time: string | null;
class_name: string | null;
subject: string | null;
year_group: string | null;
week_cycle: string;
day_of_week: string;
status: string;
teacher_name: string | null;
}
interface DayEntry {
date: string;
day_of_week: string;
is_today: boolean;
lessons: Lesson[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toMonday(d: Date): string {
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
const m = new Date(d);
m.setDate(d.getDate() + diff);
return m.toISOString().slice(0, 10);
}
function addWeeks(iso: string, n: number): string {
const d = new Date(iso + 'T00:00:00');
d.setDate(d.getDate() + n * 7);
return d.toISOString().slice(0, 10);
}
function fmtDate(iso: string): string {
return new Date(iso + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
planned: 'default',
in_progress: 'primary',
completed: 'success',
cancelled: 'error',
substituted: 'warning',
};
// ─── Lesson card ──────────────────────────────────────────────────────────────
function LessonCard({ lesson }: { lesson: Lesson }) {
return (
<Box
sx={{
p: 1.25, mb: 0.75,
border: '1px solid', borderColor: 'divider', borderRadius: 1,
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
display: 'flex', flexDirection: 'column', gap: 0.5,
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code}
</Typography>
{lesson.subject && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.start_time ? `${lesson.start_time}${lesson.end_time}` : lesson.period_name}
</Typography>
{lesson.week_cycle && (
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
)}
<Chip
label={lesson.status}
size="small"
color={STATUS_COLORS[lesson.status] ?? 'default'}
sx={{ height: 16, fontSize: '0.65rem' }}
/>
</Box>
{lesson.teacher_name && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
{lesson.teacher_name}
</Typography>
)}
</Box>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
const StudentLessonsPage: React.FC = () => {
const { accessToken } = useAuth();
const [weekStart, setWeekStart] = useState<string>(toMonday(new Date()));
const [days, setDays] = useState<DayEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (ws: string) => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE}/timetable/student/lessons?week_start=${ws}&weeks=1`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const data = await res.json();
if (data.status === 'ok') setDays(data.days);
else setError(data.message || 'Failed to load lessons');
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { load(weekStart); }, [weekStart, load]);
const totalLessons = days.reduce((s, d) => s + d.lessons.length, 0);
const weekLabel = days.length > 0
? `${fmtDate(days[0].date)} ${fmtDate(days[days.length - 1].date)}`
: '';
return (
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="h5" fontWeight={700}>My Lessons</Typography>
<Typography variant="caption" color="text.secondary">
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<Tooltip title="This week">
<IconButton size="small" onClick={() => setWeekStart(toMonday(new Date()))}>
<Today fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, -1))}>
<ChevronLeft />
</IconButton>
<IconButton size="small" onClick={() => setWeekStart(ws => addWeeks(ws, 1))}>
<ChevronRight />
</IconButton>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
{days.map(day => (
<Box key={day.date}>
<Box
sx={{
p: 1, mb: 0.75, borderRadius: 1, textAlign: 'center',
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
color: day.is_today ? 'primary.contrastText' : 'text.primary',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
{day.day_of_week.slice(0, 3)}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
{fmtDate(day.date)}
</Typography>
</Box>
{day.lessons.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
No lessons
</Typography>
) : (
day.lessons.map(lesson => (
<LessonCard key={lesson.id} lesson={lesson} />
))
)}
</Box>
))}
</Box>
)}
</Box>
);
};
export default StudentLessonsPage;
+380
View File
@@ -0,0 +1,380 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
Box, Typography, Button, CircularProgress, Alert, Chip,
TextField, Table, TableHead, TableBody, TableRow, TableCell,
IconButton, Tooltip, Divider, Tabs, Tab,
} from '@mui/material';
import {
PersonAdd, Cancel, Send, Refresh, UploadFile,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
interface Student {
profile_id: string;
email: string | null;
username: string | null;
display_name: string | null;
role: string;
joined_at: string;
}
interface Invitation {
id: string;
email: string;
role: string;
status: string;
created_at: string;
expires_at: string;
metadata: Record<string, any>;
}
const STATUS_COLORS: Record<string, 'default' | 'warning' | 'success' | 'error'> = {
pending: 'warning',
accepted: 'success',
expired: 'error',
cancelled: 'default',
};
const StudentManagerPage: React.FC = () => {
const { accessToken } = useAuth();
const [tab, setTab] = useState(0);
const [students, setStudents] = useState<Student[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
// Single invite
const [inviteEmail, setInviteEmail] = useState('');
const [inviting, setInviting] = useState(false);
// CSV import
const fileRef = useRef<HTMLInputElement>(null);
const [csvEmails, setCsvEmails] = useState<string[]>([]);
const [csvPreview, setCsvPreview] = useState(false);
const [csvProgress, setCsvProgress] = useState(0);
const [csvTotal, setCsvTotal] = useState(0);
const [bulkRunning, setBulkRunning] = useState(false);
const headers = { Authorization: `Bearer ${accessToken}` };
const loadData = useCallback(async () => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const [studRes, invRes] = await Promise.all([
fetch(`${API_BASE}/users/students`, { headers }).then(r => r.json()),
fetch(`${API_BASE}/users/invitations?role=student`, { headers }).then(r => r.json()),
]);
if (studRes.status === 'ok') setStudents(studRes.students || []);
else setError(studRes.detail || 'Failed to load students');
setInvitations(invRes.invitations || []);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadData(); }, [loadData]);
const sendInvite = async (email: string): Promise<'ok' | 'already_pending' | 'error'> => {
try {
const res = await fetch(`${API_BASE}/users/invite`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim().toLowerCase(), role: 'student' }),
});
const data = await res.json();
if (data.status === 'ok') return 'ok';
if (data.status === 'already_pending') return 'already_pending';
return 'error';
} catch {
return 'error';
}
};
const handleInvite = async () => {
if (!inviteEmail.trim() || !accessToken) return;
setInviting(true);
setError(null);
setSuccessMsg(null);
const result = await sendInvite(inviteEmail);
if (result === 'ok') {
setSuccessMsg(`Invitation sent to ${inviteEmail}`);
setInviteEmail('');
loadData();
} else if (result === 'already_pending') {
setSuccessMsg(`Invitation already pending for ${inviteEmail}`);
setInviteEmail('');
} else {
setError(`Failed to invite ${inviteEmail}`);
}
setInviting(false);
};
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const text = ev.target?.result as string;
const emails = text
.split(/[\n,;]+/)
.map(s => s.trim().toLowerCase())
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
setCsvEmails([...new Set(emails)]);
setCsvPreview(true);
};
reader.readAsText(file);
// Reset file input so same file can be re-selected
e.target.value = '';
};
const handleBulkInvite = async () => {
if (csvEmails.length === 0) return;
setBulkRunning(true);
setCsvTotal(csvEmails.length);
setCsvProgress(0);
let sent = 0;
let failed = 0;
for (const email of csvEmails) {
const result = await sendInvite(email);
if (result === 'ok' || result === 'already_pending') sent++;
else failed++;
setCsvProgress(prev => prev + 1);
}
setBulkRunning(false);
setCsvPreview(false);
setCsvEmails([]);
setSuccessMsg(`Bulk invite: ${sent} sent${failed > 0 ? `, ${failed} failed` : ''}`);
loadData();
};
const handleCancel = async (id: string, email: string) => {
if (!accessToken) return;
await fetch(`${API_BASE}/users/invitations/${id}`, { method: 'DELETE', headers });
setSuccessMsg(`Cancelled invitation for ${email}`);
loadData();
};
const handleResend = async (id: string, email: string) => {
if (!accessToken) return;
const res = await fetch(`${API_BASE}/users/invitations/${id}/resend`, { method: 'POST', headers });
const data = await res.json();
if (data.status === 'ok') setSuccessMsg(`Re-sent invitation to ${email}`);
else setError(data.detail || 'Resend failed');
};
const pendingInvitations = invitations.filter(i => i.status === 'pending');
const otherInvitations = invitations.filter(i => i.status !== 'pending');
return (
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Student Manager</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Invite students individually or via CSV import
</Typography>
{error && <Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{successMsg && <Alert severity="success" sx={{ mt: 2 }} onClose={() => setSuccessMsg(null)}>{successMsg}</Alert>}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mt: 2, mb: 2 }}>
<Tab label="Invite" />
<Tab label={`Invitations (${invitations.length})`} />
<Tab label={`Students (${students.length})`} />
</Tabs>
{/* ── Tab 0: Invite ─────────────────────────────────────────── */}
{tab === 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Single invite</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end', mb: 3 }}>
<TextField
label="Email address"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleInvite()}
size="small"
sx={{ flex: 1 }}
placeholder="[email protected]"
disabled={inviting}
/>
<Button
variant="contained"
startIcon={inviting ? <CircularProgress size={14} /> : <PersonAdd />}
onClick={handleInvite}
disabled={inviting || !inviteEmail.trim()}
>
Send Invite
</Button>
</Box>
<Divider sx={{ mb: 3 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>Bulk CSV import</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 1.5 }}>
Upload a .csv or .txt file with one email per line (or comma/semicolon separated). Duplicate and invalid addresses are filtered automatically.
</Typography>
<input
ref={fileRef}
type="file"
accept=".csv,.txt"
style={{ display: 'none' }}
onChange={handleCsvFile}
/>
<Button
variant="outlined"
startIcon={<UploadFile />}
onClick={() => fileRef.current?.click()}
disabled={bulkRunning}
>
Choose File
</Button>
{csvPreview && csvEmails.length > 0 && (
<Box sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{csvEmails.length} valid email{csvEmails.length !== 1 ? 's' : ''} found
</Typography>
<Box sx={{ maxHeight: 120, overflowY: 'auto', mt: 0.5 }}>
{csvEmails.slice(0, 20).map(e => (
<Typography key={e} variant="caption" sx={{ display: 'block', fontFamily: 'monospace', fontSize: '0.75rem', color: 'text.secondary' }}>
{e}
</Typography>
))}
{csvEmails.length > 20 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
and {csvEmails.length - 20} more
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 1.5 }}>
<Button
variant="contained"
size="small"
onClick={handleBulkInvite}
disabled={bulkRunning}
startIcon={bulkRunning ? <CircularProgress size={14} /> : <Send />}
>
{bulkRunning ? `Sending… ${csvProgress}/${csvTotal}` : `Send ${csvEmails.length} Invites`}
</Button>
<Button size="small" onClick={() => { setCsvEmails([]); setCsvPreview(false); }}>
Cancel
</Button>
</Box>
</Box>
)}
</Box>
)}
{/* ── Tab 1: Invitations ────────────────────────────────────── */}
{tab === 1 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">All Invitations</Typography>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
</Tooltip>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : invitations.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No invitations yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Email</TableCell>
<TableCell>Status</TableCell>
<TableCell>Sent</TableCell>
<TableCell>Expires</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invitations.map(inv => (
<TableRow key={inv.id}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{inv.email}</TableCell>
<TableCell>
<Chip label={inv.status} size="small" color={STATUS_COLORS[inv.status] ?? 'default'} sx={{ fontSize: '0.7rem' }} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(inv.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(inv.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell align="right">
{inv.status === 'pending' && (
<>
<Tooltip title="Resend">
<IconButton size="small" onClick={() => handleResend(inv.id, inv.email)}>
<Send fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Cancel">
<IconButton size="small" onClick={() => handleCancel(inv.id, inv.email)}>
<Cancel fontSize="inherit" />
</IconButton>
</Tooltip>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
)}
{/* ── Tab 2: Students ───────────────────────────────────────── */}
{tab === 2 && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">Enrolled Students ({students.length})</Typography>
<Tooltip title="Refresh">
<IconButton size="small" onClick={loadData} disabled={loading}><Refresh fontSize="small" /></IconButton>
</Tooltip>
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : students.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>No enrolled students yet.</Typography>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Joined</TableCell>
</TableRow>
</TableHead>
<TableBody>
{students.map(s => (
<TableRow key={s.profile_id}>
<TableCell sx={{ fontWeight: 500, fontSize: '0.85rem' }}>
{s.display_name || s.username || '—'}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'text.secondary' }}>
{s.email || '—'}
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: '0.75rem' }}>
{new Date(s.joined_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
)}
</Box>
);
};
export default StudentManagerPage;
+512
View File
@@ -0,0 +1,512 @@
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, List, ListItemButton, ListItemText,
InputAdornment,
} from '@mui/material';
import {
ChevronLeft, ChevronRight, Today, EditNote, LibraryAdd, Search, OpenInNew,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Lesson {
id: string;
date: string;
period_code: string;
period_name: string;
start_time: string | null;
end_time: string | null;
class_name: string | null;
subject: string | null;
year_group: string | null;
week_cycle: string;
day_of_week: string;
status: string;
lesson_plan: Record<string, any>;
notes: string | null;
whiteboard_room_id: string | null;
}
interface DayEntry {
date: string;
day_of_week: string;
is_today: boolean;
lessons: Lesson[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toMonday(d: Date): string {
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diff);
return monday.toISOString().slice(0, 10);
}
function addWeeks(isoDate: string, n: number): string {
const d = new Date(isoDate + 'T00:00:00');
d.setDate(d.getDate() + n * 7);
return d.toISOString().slice(0, 10);
}
function formatDate(isoDate: string): string {
return new Date(isoDate + 'T00:00:00').toLocaleDateString('en-GB', {
day: 'numeric', month: 'short',
});
}
const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error' | 'warning'> = {
planned: 'default',
in_progress: 'primary',
completed: 'success',
cancelled: 'error',
substituted: 'warning',
};
// ─── Lesson edit dialog ───────────────────────────────────────────────────────
interface EditDialogProps {
lesson: Lesson | null;
onClose: () => void;
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
}
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
const [notes, setNotes] = useState('');
const [status, setStatus] = useState('planned');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (lesson) {
setNotes(lesson.notes || '');
setStatus(lesson.status || 'planned');
}
}, [lesson]);
const handleSave = async () => {
if (!lesson) return;
setSaving(true);
await onSave(lesson.id, { notes, status });
setSaving(false);
onClose();
};
if (!lesson) return null;
return (
<Dialog open={!!lesson} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ pb: 1 }}>
<Box>
<Typography variant="h6">
{lesson.class_name || lesson.period_code}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{lesson.date} · {lesson.period_name}
{lesson.start_time && ` · ${lesson.start_time}${lesson.end_time}`}
{lesson.week_cycle && ` · Week ${lesson.week_cycle}`}
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<FormControl size="small" fullWidth>
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={e => setStatus(e.target.value)}>
<MenuItem value="planned">Planned</MenuItem>
<MenuItem value="in_progress">In Progress</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
<MenuItem value="cancelled">Cancelled</MenuItem>
<MenuItem value="substituted">Substituted</MenuItem>
</Select>
</FormControl>
<TextField
label="Notes"
value={notes}
onChange={e => setNotes(e.target.value)}
multiline
minRows={3}
fullWidth
size="small"
placeholder="Lesson notes, objectives, reminders…"
/>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSave} variant="contained" disabled={saving}>
{saving ? <CircularProgress size={18} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
);
}
// ─── Lesson card ──────────────────────────────────────────────────────────────
interface LessonCardProps {
lesson: Lesson;
onEdit: (l: Lesson) => void;
onAssignPlan: (l: Lesson) => void;
}
function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
return (
<Box
sx={{
p: 1.25,
mb: 0.75,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: lesson.status === 'completed' ? 'action.hover' : 'background.paper',
opacity: lesson.status === 'cancelled' ? 0.5 : 1,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="caption" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{lesson.class_name || lesson.period_code}
</Typography>
<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' }}>
{lesson.subject}{lesson.year_group ? ` · Y${lesson.year_group}` : ''}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{lesson.start_time ? `${lesson.start_time}${lesson.end_time}` : lesson.period_name}
</Typography>
{lesson.week_cycle && (
<Chip label={`W${lesson.week_cycle}`} size="small" sx={{ height: 16, fontSize: '0.65rem' }} />
)}
<Chip
label={lesson.status}
size="small"
color={STATUS_COLORS[lesson.status] ?? 'default'}
sx={{ height: 16, fontSize: '0.65rem' }}
/>
</Box>
{lesson.notes && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
</Typography>
)}
</Box>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
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);
const [error, setError] = useState<string | null>(null);
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;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE}/timetable/lessons?week_start=${ws}&weeks=1`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const data = await res.json();
if (data.status === 'ok') {
setDays(data.days);
} else {
setError(data.message || 'Failed to load lessons');
}
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken]);
useEffect(() => { loadLessons(weekStart); }, [weekStart, loadLessons]);
const goThisWeek = () => setWeekStart(toMonday(new Date()));
const prevWeek = () => setWeekStart(ws => addWeeks(ws, -1));
const nextWeek = () => setWeekStart(ws => addWeeks(ws, 1));
const handleMaterialize = async () => {
if (!accessToken) return;
setMaterializing(true);
setMaterializeResult(null);
try {
const res = await fetch(`${API_BASE}/timetable/materialize`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
if (data.status === 'ok') {
setMaterializeResult(`Created ${data.lessons_upserted} lessons, ${data.whiteboard_rooms_created} rooms`);
loadLessons(weekStart);
} else {
setMaterializeResult(`Error: ${data.message}`);
}
} catch (e: any) {
setMaterializeResult(`Error: ${e.message}`);
} finally {
setMaterializing(false);
}
};
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
if (!accessToken) return;
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
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);
const weekLabel = days.length > 0
? `${formatDate(days[0].date)} ${formatDate(days[days.length - 1].date)}`
: '';
return (
<Box sx={{ p: 3, maxWidth: 1000, mx: 'auto' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>My Lessons</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{weekLabel} · {totalLessons} lesson{totalLessons !== 1 ? 's' : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button
size="small"
variant="outlined"
onClick={handleMaterialize}
disabled={materializing}
sx={{ whiteSpace: 'nowrap' }}
>
{materializing ? <CircularProgress size={14} sx={{ mr: 1 }} /> : null}
Generate Lessons
</Button>
<Tooltip title="Go to this week">
<IconButton size="small" onClick={goThisWeek}><Today fontSize="small" /></IconButton>
</Tooltip>
<IconButton size="small" onClick={prevWeek}><ChevronLeft /></IconButton>
<IconButton size="small" onClick={nextWeek}><ChevronRight /></IconButton>
</Box>
</Box>
{materializeResult && (
<Alert
severity={materializeResult.startsWith('Error') ? 'error' : 'success'}
onClose={() => setMaterializeResult(null)}
sx={{ mb: 2 }}
>
{materializeResult}
</Alert>
)}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{/* Week grid */}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1.5 }}>
{days.map(day => (
<Box key={day.date}>
{/* Day header */}
<Box
sx={{
p: 1,
mb: 0.75,
borderRadius: 1,
bgcolor: day.is_today ? 'primary.main' : 'action.hover',
color: day.is_today ? 'primary.contrastText' : 'text.primary',
textAlign: 'center',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block' }}>
{day.day_of_week.slice(0, 3)}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', opacity: 0.85 }}>
{formatDate(day.date)}
</Typography>
</Box>
{/* Lesson cards */}
{day.lessons.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem', px: 0.5 }}>
No lessons
</Typography>
) : (
day.lessons.map(lesson => (
<LessonCard
key={lesson.id}
lesson={lesson}
onEdit={setEditingLesson}
onAssignPlan={openAssignDialog}
/>
))
)}
</Box>
))}
</Box>
)}
<LessonEditDialog
lesson={editingLesson}
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>
);
};
export default TaughtLessonsPage;
+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;
+9 -8
View File
@@ -1,19 +1,20 @@
/**
* Timetable Module - Page Exports
*
* This barrel file exports all pages related to the timetable/classroom management system.
*
* @module pages/timetable
*/
// Main pages
export { default as TimetablePage } from './TimetablePage';
export { default as TimetableListPage } from './TimetableListPage';
export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage';
// Class management pages
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
export { default as MyClassesPage } from './MyClassesPage';
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage';
export { default as StaffManagerPage } from './StaffManagerPage';
export { default as StudentManagerPage } from './StudentManagerPage';
export { default as SchoolSettingsPage } from './SchoolSettingsPage';
export { default as ClassDetailPage } from './ClassDetailPage';
export { default as StudentLessonsPage } from './StudentLessonsPage';
// Re-export types if needed
export type { ClassView, DaySchedule, WeekSchedule } from './TimetablePage';
export { default as LessonPlansPage } from './LessonPlansPage';
export { default as LessonPlanDetailPage } from './LessonPlanDetailPage';
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select';
import { supabase } from '../../../supabaseClient';
import { useAuth } from '../../../contexts/AuthContext';
type Manifest = {
bucket: string;
@@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{
currentPage?: number;
combinedBundles?: Array<{ id: string }>;
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
const { accessToken } = useAuth();
const [manifest, setManifest] = useState<Manifest | null>(null);
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
const [mode, setMode] = useState<Mode>('markdown_full');
@@ -49,13 +50,13 @@ 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 = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
return `${API_BASE}/database/files/proxy_signed?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(relPath)}&token=${encodeURIComponent(token)}`;
}, [API_BASE]);
}, [API_BASE, accessToken]);
const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
if (!s || typeof s !== 'string') return s || '';
@@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{
setManifest(null);
if (combinedBundles && combinedBundles.length > 0) {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const ms: Manifest[] = [];
for (const b of combinedBundles) {
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
@@ -239,7 +240,7 @@ export const CCBundleViewer: React.FC<{
}
if (!bundleId) return;
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(bundleId)}/manifest`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text());
const rawManifest: Manifest = await res.json();
@@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{
let textParts: string[] = [];
let jsonParts: string[] = [];
for (const m of combinedManifests) {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
let rel: string | undefined;
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full;
else if (mode === 'html_full') rel = m.html_full || m.markdown_full || m.text_full || m.json_full;
@@ -348,7 +349,7 @@ export const CCBundleViewer: React.FC<{
relPath = rec?.path;
}
if (!relPath) { setContent(''); setLoading(false); return; }
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const url = await proxyUrl(bucket, relPath);
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text());
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, IconButton } from '@mui/material';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import { supabase } from '../../../supabaseClient';
import { useAuth } from '../../../contexts/AuthContext';
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
@@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{
hideToolbar?: boolean;
sectionRange?: { start: number; end: number };
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
const { accessToken } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
@@ -181,10 +182,10 @@ 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 ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (mRes.ok) {
const m: PageImagesManifest = await mRes.json();
@@ -198,8 +199,8 @@ 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`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
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());
const artefacts: Artefact[] = await artefactsRes.json();
@@ -216,7 +217,7 @@ export const CCDoclingViewer: React.FC<{
// Download artefact JSON via backend (service-role) to avoid RLS issues
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (!jsonRes.ok) throw new Error(await jsonRes.text());
const doc: DoclingJson = await jsonRes.json();
@@ -238,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;
@@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{
setPageObjectUrl(cached);
return;
}
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok && manifest) {
// Fallback to thumbnail if the full image is not accessible yet
@@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{
export default CCDoclingViewer;
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
const { accessToken } = useAuth();
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -376,7 +378,7 @@ const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
let revoked: string | null = null;
const load = async () => {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
@@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select';
import { CCDoclingViewer } from './CCDoclingViewer.tsx';
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
import CCBundleViewer from './CCBundleViewer.tsx';
import { supabase } from '../../../supabaseClient';
import { useAuth } from '../../../contexts/AuthContext';
type CanonicalDoclingConfig = {
pipeline: 'standard' | 'vlm' | 'asr';
@@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => {
const { fileId } = useParams<{ fileId: string }>();
const validFileId = useMemo(() => fileId || '', [fileId]);
const { accessToken } = useAuth();
const [page, setPage] = useState<number>(1);
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
const [profile, setProfile] = useState<Profile>('default');
@@ -147,8 +148,8 @@ 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 token = (await supabase.auth.getSession()).data.session?.access_token || '';
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;
const arts: Artefact[] = await res.json();
@@ -222,17 +223,17 @@ 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 ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (!artsRes.ok) return;
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (!outlineArt) return;
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (!jsonRes.ok) return;
const doc = await jsonRes.json();
@@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => {
const splitArt = arts.find(a => a.type === 'split_map_json');
if (splitArt) {
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (smRes.ok) {
const sm = await smRes.json();
@@ -485,8 +486,8 @@ 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 token = (await supabase.auth.getSession()).data.session?.access_token || '';
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const token = accessToken || '';
const body: CanonicalDoclingRequest = {
use_split_map: selectedSectionId === 'full' ? autoSplit : false,
config: {
@@ -11,7 +11,8 @@ import Schedule from '@mui/icons-material/Schedule';
import Visibility from '@mui/icons-material/Visibility';
import Psychology from '@mui/icons-material/Psychology';
import Overview from '@mui/icons-material/Home';
import { supabase } from '../../../supabaseClient';
import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth';
import { useAuth } from '../../../contexts/AuthContext';
// Types
type PageImagesManifest = {
@@ -66,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
fileId, selectedPage, onSelectPage, currentSection
}) => {
// State
const { accessToken } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
@@ -82,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',
[]
);
@@ -94,7 +96,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
setError(null);
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
// Load page images manifest
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
@@ -198,7 +200,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
try {
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`;
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!response.ok) return undefined;
@@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import { supabase } from '../../../supabaseClient';
import { useAuth } from '../../../contexts/AuthContext';
type PageImagesManifest = {
version: number;
@@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{
selectedPage: number;
onSelectPage: (p: number) => void;
}> = ({ fileId, selectedPage, onSelectPage }) => {
const { accessToken } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
@@ -64,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 () => {
@@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{
setError(null);
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (!mRes.ok) throw new Error(await mRes.text());
const m: PageImagesManifest = await mRes.json();
@@ -82,14 +83,14 @@ export const CCFileDetailPanel: React.FC<{
// Try to load outline structure artefact (for grouping only)
try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (artsRes.ok) {
const arts: Array<{ id: string; type: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (outlineArt) {
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
headers: { 'Authorization': `Bearer ${accessToken || ''}` }
});
if (jsonRes.ok) {
const outJson = await jsonRes.json();
@@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{
const pg = manifest.page_images[idx];
if (!pg) return undefined;
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`;
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) return undefined;
const blob = await resp.blob();
@@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{
<IconButton size="small" onClick={async () => {
try {
setShowAdmin(true);
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
const token = accessToken || '';
const res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
const data = await res.json();
setAdminData(data);
@@ -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} />;
}
+113 -330
View File
@@ -7,15 +7,14 @@ 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';
import { useUser } from '../../contexts/UserContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
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';
@@ -46,6 +43,8 @@ interface LoadingState {
export default function SinglePlayerPage() {
// Context hooks with initialization states
const { profile: user, loading: userLoading } = useUser();
const { accessToken } = useAuth();
const { context, setAuthInfo, switchContext } = useNavigationStore();
const {
tldrawPreferences,
initializePreferences,
@@ -55,21 +54,18 @@ export default function SinglePlayerPage() {
const routerNavigate = useNavigate();
const location = useLocation();
// Navigation store
const { context } = useNavigationStore();
// Refs
const editorRef = useRef<Editor | null>(null);
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,237 +81,107 @@ 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 () => {
storeRef.current = store;
setStoreReady(true);
setCanvasPhase('ready');
return () => {
unsubscribe?.();
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
snapshotServiceRef.current?.clearCurrentNode();
snapshotServiceRef.current = null;
setStoreReady(false);
setCanvasPhase('idle');
store.dispose();
storeRef.current = null;
};
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
useEffect(() => {
if (!user || !storeRef.current) return;
const store = storeRef.current;
let cancelled = false;
const loadNodeSnapshot = async () => {
if (!context.node?.node_storage_path) {
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.clearCurrentNode();
setCanvasPhase('ready');
return;
}
setCanvasPhase('snapshot-loading');
try {
setLoadingState({ status: 'loading', error: '' });
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
});
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
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: user.user_db_name,
node: context.node,
node_storage_path: nodeStoragePath,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath,
user.user_db_name,
newStore,
setLoadingState,
undefined, // sharedStore
editorRef.current || undefined // editor
);
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');
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');
}
// 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 && context.node && 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
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
}
});
// 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;
} catch (e) {
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
}
};
initializeStoreAndSnapshot();
}, [user, context.node]);
loadNodeSnapshot();
return () => { cancelled = true; };
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
// Handle initial node placement
// Handle navigation changes — save previous snapshot, load next one.
// No automatic node shape placement: the canvas shows only persisted state.
// Inject auth and trigger initial context when token is ready
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?.id && accessToken) {
setAuthInfo(accessToken, user.id);
if (!context.node) {
switchContext({ main: 'profile', base: 'profile' }, null, null);
}
// Debug: Log the actual node structure
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
node: context.node,
nodeKeys: Object.keys(context.node),
hasId: !!context.node.id,
hasStoragePath: !!context.node.node_storage_path,
hasData: !!context.node.data,
dataKeys: context.node.data ? Object.keys(context.node.data) : null
});
// Validate that the node has required properties
const nodeStoragePath = getNodeStoragePath(context.node);
if (!context.node.id || !nodeStoragePath) {
logger.error('single-player-page', '❌ Node missing required properties', {
nodeId: context.node.id,
hasStoragePath: !!nodeStoragePath,
node: context.node
});
setLoadingState({
status: 'error',
error: 'Node is missing required information'
});
return;
}
try {
setLoadingState({ status: 'loading', error: '' });
// Center the node
const nodeData = await loadNodeData(context.node);
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
setIsInitialLoad(false);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to place initial node', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to place initial node'
});
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
// Handle navigation changes
useEffect(() => {
const handleNodeChange = async () => {
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
return;
}
// We can safely assert these types because we've checked for null above
const editor = editorRef.current as Editor;
const snapshotService = snapshotServiceRef.current;
const currentNode = context.node;
try {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
node_storage_path: currentNode.node_storage_path,
isInitialLoad
});
// Get the previous node from navigation history
const previousNode = context.history.currentIndex > 0
? context.history.nodes[context.history.currentIndex - 1]
: null;
// Handle navigation in snapshot service
await snapshotService.handleNavigationStart(previousNode, currentNode);
// Center the node on canvas
const nodeData = await loadNodeData(currentNode);
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
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, isInitialLoad]);
}
}, [user?.id, accessToken]);
// Initialize preferences when user is available
useEffect(() => {
@@ -462,12 +328,9 @@ export default function SinglePlayerPage() {
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
{(loadingState.status === 'loading' || !storeReady) && (
<div style={{
position: 'absolute',
top: 0,
@@ -495,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}
@@ -523,110 +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> => {
// Validate the node parameter
if (!node) {
throw new Error('Node parameter is required');
}
if (!node.id) {
throw new Error('Node must have an ID');
}
const nodeStoragePath = getNodeStoragePath(node);
if (!nodeStoragePath) {
throw new Error(`Node ${node.id} is missing node_storage_path`);
}
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: node.id,
nodeType: node.type,
nodeLabel: node.label,
nodeStoragePath: nodeStoragePath
});
try {
// 1. Always fetch fresh data
// Create a temporary node object with the correct structure for the service
const normalizedNode = {
...node,
node_storage_path: nodeStoragePath
};
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: String(fetchedData.node_data.title || node.label || ''),
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
};
} catch (error) {
logger.error('single-player-page', '❌ Error in loadNodeData', {
nodeId: node.id,
nodeType: node.type,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
};
+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>
);
+3 -20
View File
@@ -3,7 +3,6 @@ import { TLUserPreferences } from '@tldraw/tldraw';
import { supabase } from '../../supabaseClient';
import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig';
import { DatabaseNameService } from '../graph/databaseNameService';
export interface CCUser {
id: string;
@@ -11,8 +10,7 @@ export interface CCUser {
user_type: string;
username: string;
display_name: string;
user_db_name: string;
school_db_name: string;
school_id?: string | null;
created_at?: string;
updated_at?: string;
}
@@ -44,28 +42,13 @@ export function convertToCCUser(user: User, metadata: CCUserMetadata): CCUser {
// Default to student if no user type specified
const userType = metadata.user_type || 'student';
const storedUserDb = DatabaseNameService.getStoredUserDatabase();
const storedSchoolDb = DatabaseNameService.getStoredSchoolDatabase();
const userDbName = storedUserDb || DatabaseNameService.getUserPrivateDB(
userType,
user.id
);
const schoolDbName = metadata.school_db_name || metadata.worker_db_name || storedSchoolDb || '';
DatabaseNameService.rememberDatabaseNames({
userDbName,
schoolDbName
});
return {
id: user.id,
email: user.email,
user_type: userType,
username: username,
username,
display_name: displayName,
user_db_name: userDbName,
school_db_name: schoolDbName,
school_id: null,
created_at: user.created_at,
updated_at: user.updated_at,
};
-14
View File
@@ -6,7 +6,6 @@ import { RegistrationResponse } from '../../services/auth/authService';
import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig';
import { provisionUser } from '../provisioningService';
import { DatabaseNameService } from '../graph/databaseNameService';
const REGISTRATION_SERVICE = 'registration-service';
@@ -87,14 +86,6 @@ export class RegistrationService {
try {
const provisioned = await provisionUser(ccUser.id, provisioningToken);
if (provisioned) {
ccUser.user_db_name = provisioned.user_db_name;
if (provisioned.worker_db_name) {
ccUser.school_db_name = provisioned.worker_db_name;
}
DatabaseNameService.rememberDatabaseNames({
userDbName: ccUser.user_db_name,
schoolDbName: ccUser.school_db_name
});
logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
userId: ccUser.id,
userDbName: provisioned.user_db_name,
@@ -110,11 +101,6 @@ export class RegistrationService {
});
}
DatabaseNameService.rememberDatabaseNames({
userDbName: ccUser.user_db_name,
schoolDbName: ccUser.school_db_name
});
return {
user: ccUser,
accessToken: authData.session?.access_token || null,
+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;
}
+303
View File
@@ -0,0 +1,303 @@
/**
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
*
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
*/
import axios from 'axios';
import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient';
import type {
AutoMapJobStatus,
AutoMapResponse,
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate,
ExamTemplateDetail,
ExamTemplateLayout,
MarkingBatch,
MarkUpsertPayload,
Neo4jSyncResult,
PatchQuestionPayload,
SpecPoint,
TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`;
async function authHeaders(): Promise<Record<string, string>> {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No authentication token available');
}
return { Authorization: `Bearer ${session.access_token}` };
}
function newUuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
const v = c === 'x' ? r : (r % 4) + 8;
return v.toString(16);
});
}
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
return {
id: idMap?.get(q.id) ?? q.id,
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
label: q.label,
order: q.order,
max_marks: q.max_marks,
answer_type: q.answer_type,
mcq_options: q.mcq_options,
mark_scheme: q.mark_scheme ?? {},
is_container: q.is_container,
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
source: q.source ?? 'manual',
confirmed: q.confirmed ?? true,
confidence: q.confidence ?? null,
derivation: q.derivation ?? null,
};
}
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : r.id,
question_id: idMap?.get(r.question_id) ?? r.question_id,
page: r.page,
bounds: r.bounds,
kind: r.kind,
response_form: r.response_form,
context_type: r.context_type ?? null,
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
mark_subtype: r.mark_subtype ?? null,
derivation: r.derivation ?? null,
};
}
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : b.id,
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
label: b.label,
page_index: b.page_index,
y: b.y,
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
confidence: b.confidence ?? null,
derivation: b.derivation ?? null,
};
}
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
return {
id: duplicate ? newUuid() : layout.id,
page_index: layout.page_index,
role: layout.role ?? null,
margin_left: layout.margin_left ?? null,
margin_right: layout.margin_right ?? null,
margin_top: layout.margin_top ?? null,
margin_bottom: layout.margin_bottom ?? null,
margins_enabled: layout.margins_enabled ?? true,
source: layout.source ?? 'manual',
confirmed: layout.confirmed ?? true,
confidence: layout.confidence ?? null,
derivation: layout.derivation ?? null,
meta: layout.meta ?? {},
};
}
async function replaceTemplate(
templateId: string,
detail: ExamTemplateDetail,
meta?: UpdateTemplateMetaPayload,
duplicateIds = false,
): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const idMap = new Map<string, string>();
if (duplicateIds) {
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
}
const res = await axios.put<ExamTemplateDetail>(
`${EXAM_BASE}/templates/${templateId}`,
{
meta,
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
},
{ headers },
);
return res.data;
}
export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders();
const res = await axios.get<{ templates: ExamTemplate[] }>(
`${EXAM_BASE}/templates`,
{ headers, params: { include_archived: includeArchived } },
);
return res.data.templates ?? [];
},
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
return res.data;
},
async autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
const headers = await authHeaders();
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
return res.data;
},
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
const headers = await authHeaders();
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
return res.data;
},
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
headers,
responseType: 'arraybuffer',
});
return res.data;
},
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data;
},
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
return res.data;
},
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
const detail = await this.getTemplate(templateId);
const created = await this.createTemplate({
title,
subject: detail.subject ?? undefined,
exam_id: detail.exam_id ?? undefined,
exam_code: detail.exam_code ?? undefined,
source_file_id: detail.source_file_id ?? undefined,
page_count: detail.page_count ?? undefined,
institute_id: detail.institute_id,
});
try {
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
} catch (error) {
try {
await this.archiveTemplate(created.id);
} catch (archiveError) {
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
templateId: created.id,
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
});
}
throw error;
}
},
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
return res.data;
},
async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
},
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
const headers = await authHeaders();
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
return res.data;
},
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
const headers = await authHeaders();
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
{ headers, params: search ? { q: search } : undefined },
);
if (Array.isArray(res.data)) return res.data;
return res.data.points ?? [];
},
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
const headers = await authHeaders();
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
return res.data;
},
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
const headers = await authHeaders();
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
return res.data;
},
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
const headers = await authHeaders();
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
headers,
params: {
include_archived: params.includeArchived ?? false,
template_id: params.templateId,
},
});
return res.data.batches ?? [];
},
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
return res.data;
},
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
return res.data;
},
async getBatchCsv(batchId: string): Promise<string> {
const headers = await authHeaders();
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
return res.data;
},
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
const headers = await authHeaders();
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
return res.data;
},
};
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');
}
}
+160 -328
View File
@@ -1,27 +1,52 @@
// External imports
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService';
import { StorageKeys, storageService } from '../auth/localStorageService';
import { NavigationNode } from '../../types/navigation';
export interface LoadingState {
status: 'loading' | 'ready' | 'error';
error: string;
}
const EMPTY_NODE: NavigationNode = {
id: '',
node_storage_path: '',
type: '',
label: ''
};
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
const BUCKET = 'cc.users';
async function storageGet(path: string, accessToken: string): Promise<unknown | null> {
const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
apikey: SUPABASE_ANON_KEY,
},
});
if (res.status === 404 || res.status === 400) return null;
if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`);
return res.json();
}
async function storagePut(path: string, accessToken: string, data: unknown): Promise<void> {
const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`;
const headers = {
Authorization: `Bearer ${accessToken}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
};
const body = JSON.stringify(data);
// PUT replaces an existing object; POST creates a new one.
// Avoids x-upsert custom header which self-hosted Supabase CORS may block.
let res = await fetch(url, { method: 'PUT', headers, body });
if (!res.ok && (res.status === 404 || res.status === 400)) {
res = await fetch(url, { method: 'POST', headers, body });
}
if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`);
}
export class NavigationSnapshotService {
private store: TLStore;
private editor: Editor | null = null;
private currentNodePath: string | null = null;
private _accessToken: string | null = null;
private isAutoSaveEnabled = true;
private isSaving = false;
private isLoading = false;
@@ -33,24 +58,21 @@ export class NavigationSnapshotService {
this.editor = editor || null;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id,
hasEditor: !!editor
hasEditor: !!editor,
});
}
setEditor(editor: Editor): void {
this.editor = editor;
logger.debug('snapshot-service', '🔄 Editor reference updated', {
editorId: editor.store.id
});
}
private static replaceBackslashes(input: string | undefined): string {
return input ? input.replace(/\\/g, '/') : '';
setAccessToken(token: string): void {
this._accessToken = token;
}
static async loadNodeSnapshotFromDatabase(
nodePath: string,
dbName: string,
accessToken: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService,
@@ -58,248 +80,121 @@ export class NavigationSnapshotService {
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
logger.info('snapshot-service', '📂 Loading file from path', {
path: nodePath,
db_name: dbName
});
const snapshot = await storageGet(nodePath, accessToken);
const response = await axios.get(
'/database/tldraw_supabase/get_tldraw_node_file', {
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
if (!snapshot) {
logger.debug('snapshot-service', '️ No snapshot found at path — clearing canvas', { nodePath });
// Clear all shapes so the canvas is blank for this new node
if (editor) {
const shapeIds = [...editor.getCurrentPageShapeIds()];
if (shapeIds.length > 0) {
editor.deleteShapes(shapeIds);
}
}
);
const snapshot = response.data;
logger.debug('snapshot-service', '🔍 Snapshot data received', {
hasSnapshot: !!snapshot,
hasDocument: !!snapshot?.document,
hasSession: !!snapshot?.session,
hasSchemaVersion: !!snapshot?.schemaVersion,
schemaVersion: snapshot?.schemaVersion,
snapshotKeys: snapshot ? Object.keys(snapshot) : []
});
if (snapshot && snapshot.document && snapshot.session) {
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else {
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', {
hasStore: !!store,
snapshotType: typeof snapshot,
snapshotKeys: Object.keys(snapshot),
snapshotSchemaVersion: snapshot?.schemaVersion,
snapshotDocument: !!snapshot?.document,
snapshotSession: !!snapshot?.session
});
// Create a defensive copy to ensure the snapshot doesn't get modified
const snapshotCopy = {
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion,
document: snapshot.document,
session: snapshot.session
};
logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', {
copySchemaVersion: snapshotCopy.schemaVersion,
copyDocument: !!snapshotCopy.document,
copySession: !!snapshotCopy.session,
storeType: typeof store,
storeIsNull: store === null,
storeIsUndefined: store === undefined,
storeKeys: store ? Object.keys(store) : 'N/A'
});
// Debug: Log the snapshot schema sequences
if (snapshotCopy.document?.schema?.sequences) {
logger.debug('snapshot-service', '🔍 Snapshot schema sequences:', snapshotCopy.document.schema.sequences);
const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences in snapshot:', customSequences);
}
// Debug: Log the store schema sequences
if (store?.schema) {
const storeSequences = store.schema.serialize().sequences;
logger.debug('snapshot-service', '🔍 Store schema sequences:', storeSequences);
const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences in store:', storeCustomSequences);
}
// Add try-catch around the loadSnapshot call to get more specific error info
try {
// Ensure store is properly initialized before loading snapshot
if (!store) {
throw new Error('Store is null or undefined');
}
// Validate snapshot structure before loading
if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) {
throw new Error('Invalid snapshot structure');
}
// Check for schema migrations and handle them properly
logger.debug('snapshot-service', '🔄 Checking for schema migrations', {
storeId: store.id,
storeType: typeof store,
storeConstructor: store.constructor.name,
snapshotSchemaVersion: snapshotCopy.schemaVersion,
snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}),
snapshotSessionKeys: Object.keys(snapshotCopy.session || {})
});
try {
// Try to load the snapshot directly first
logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly');
if (editor) {
loadSnapshot(editor.store, snapshotCopy);
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} else {
// Fallback: use global loadSnapshot if no editor available
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot');
loadSnapshot(store, snapshotCopy);
logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot');
}
} catch (migrationError) {
// Check if this is a schema migration error that we can safely ignore
const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError);
const isSchemaMigrationError = errorMessage.includes('migration') ||
errorMessage.includes('schema') ||
errorMessage.includes('Incompatible');
if (isSchemaMigrationError) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', {
error: errorMessage
});
// Continue with empty store - this is expected for some snapshots
} else {
logger.warn('snapshot-service', '⚠️ Unexpected load error', {
error: errorMessage
});
}
}
logger.debug('snapshot-service', '✅ loadSnapshot call succeeded');
setLoadingState({ status: 'ready', error: '' });
} catch (loadError) {
logger.error('snapshot-service', '❌ loadSnapshot call failed', {
error: loadError instanceof Error ? loadError.message : String(loadError),
storeType: typeof store,
storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function',
snapshotType: typeof snapshotCopy,
snapshotKeys: Object.keys(snapshotCopy)
});
throw loadError;
}
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
}
} else {
logger.error('snapshot-service', '❌ Invalid snapshot format');
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
setLoadingState({ status: 'ready', error: '' });
return;
}
const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
if (!snap.document || !snap.session) {
logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
setLoadingState({ status: 'ready', error: '' });
return;
}
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
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 {
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) {
const msg = err instanceof Error ? err.message : String(err);
const isSchemaMigration = /migration|schema|Incompatible/i.test(msg);
if (isSchemaMigration) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', { error: msg });
} else {
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
targetStore.clear();
}
}
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
logger.error('snapshot-service', '❌ Failed to load snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load file'
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot',
});
}
}
static async saveNodeSnapshotToDatabase(
nodePath: string,
dbName: string,
accessToken: string,
store: TLStore
): Promise<void> {
try {
logger.info('snapshot-service', '💾 Saving snapshot to database', {
path: nodePath,
db_name: dbName
});
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
const snapshot = getSnapshot(store);
// Debug: Log what we're saving
logger.debug('snapshot-service', '🔍 Snapshot being saved:', {
hasSnapshot: !!snapshot,
snapshotKeys: Object.keys(snapshot || {}),
schemaVersion: snapshot?.schemaVersion,
hasDocument: !!snapshot?.document,
hasSession: !!snapshot?.session
});
// Debug: Log the schema sequences in the snapshot being saved
if (snapshot?.document?.schema?.sequences) {
logger.debug('snapshot-service', '🔍 Schema sequences being saved:', snapshot.document.schema.sequences);
const customSequences = Object.keys(snapshot.document.schema.sequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences being saved:', customSequences);
}
const response = await axios.post(
'/database/tldraw_supabase/set_tldraw_node_file',
snapshot,
{
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
}
}
);
if (response.data.status === 'success') {
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} else {
throw new Error('Failed to save snapshot');
}
await storagePut(nodePath, accessToken, snapshot);
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
logger.error('snapshot-service', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
if (!this.currentNodePath || this.currentNodePath !== nodePath) {
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
currentPath: this.currentNodePath,
savePath: nodePath
});
if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
if (!this._accessToken) {
logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
return;
}
try {
this.isSaving = true;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = user.user_db_name;
logger.debug('snapshot-service', '💾 Saving snapshot', {
nodePath,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', {
nodePath,
storeId: this.store.id
});
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath
nodePath,
});
throw error;
} finally {
@@ -307,137 +202,77 @@ export class NavigationSnapshotService {
}
}
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> {
if (!this._accessToken) {
logger.debug('snapshot-service', '⚠️ No access token — snapshot load skipped');
return;
}
try {
this.isLoading = true;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = user.user_db_name;
logger.debug('snapshot-service', '📥 Loading snapshot', {
nodePath: node.node_storage_path,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.node_storage_path,
dbName,
this._accessToken,
this.store,
(state: LoadingState) => {
if (state.status === 'ready') {
this.currentNodePath = node.node_storage_path;
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
nodePath: node.node_storage_path,
currentNodePath: this.currentNodePath
});
} else if (state.status === 'error') {
logger.error('snapshot-service', '❌ Error in load callback', {
error: state.error,
nodePath: node.node_storage_path
});
}
},
undefined, // sharedStore
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
undefined,
this.editor || undefined
);
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath: node.node_storage_path
});
throw error;
} finally {
this.isLoading = false;
}
}
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
if (!toNode) {
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
return;
}
// Clear any pending debounce
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Debounce the navigation operation
async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
if (!toNode) return;
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
return new Promise((resolve) => {
this.debounceTimeout = setTimeout(async () => {
try {
await this.executeNavigation(fromNode || EMPTY_NODE, toNode);
await this.executeNavigation(fromNode, toNode);
resolve();
} catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error);
throw error;
}
}, 100); // 100ms debounce
}, 100);
});
}
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
try {
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
from: fromNode.node_storage_path,
to: toNode.node_storage_path,
currentPath: this.currentNodePath
});
private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
if (this.isSaving || this.isLoading) {
this.pendingOperation = {
save: fromNode?.node_storage_path,
load: toNode.node_storage_path,
};
return;
}
// If we're already in a navigation operation, queue this one
if (this.isSaving || this.isLoading) {
this.pendingOperation = {
save: fromNode.node_storage_path || undefined,
load: toNode.node_storage_path
};
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return;
}
this.currentNodePath = null;
// Clear the store before loading new snapshot
logger.debug('snapshot-service', '🔄 Clearing store');
this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode);
}
// Load the new node's snapshot
if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.node_storage_path
});
}
// Process any pending operations
if (this.pendingOperation) {
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation);
const operation = this.pendingOperation;
this.pendingOperation = null;
await this.handleNavigationStart(
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
);
logger.debug('snapshot-service', '✅ Completed pending operation');
}
} catch (error) {
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
error: error instanceof Error ? error.message : 'Unknown error',
fromPath: fromNode.node_storage_path,
toPath: toNode.node_storage_path
});
throw error;
if (this.pendingOperation) {
const op = this.pendingOperation;
this.pendingOperation = null;
await this.handleNavigationStart(
op.save ? { node_storage_path: op.save } : null,
op.load ? { node_storage_path: op.load } : null
);
}
}
setAutoSave(enabled: boolean): void {
this.isAutoSaveEnabled = enabled;
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
enabled
});
}
setCurrentNodePath(path: string): void {
this.currentNodePath = path;
}
getCurrentNodePath(): string | null {
@@ -447,14 +282,11 @@ export class NavigationSnapshotService {
async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath);
} else {
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
}
}
clearCurrentNode(): void {
this.currentNodePath = null;
this.store.clear();
logger.debug('snapshot-service', '🧹 Cleared current node and store');
}
}
}
+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))
}
+273 -374
View File
@@ -1,32 +1,45 @@
import { create } from 'zustand';
import { UserNeoDBService } from '../services/graph/userNeoDBService';
import { logger } from '../debugConfig';
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import {
NavigationStore,
import {
NavigationStore,
NavigationNode,
NeoGraphNode,
MainContext,
BaseContext,
NavigationContextState,
isProfileContext,
isInstituteContext,
getContextDatabase,
addToHistory,
navigateHistory,
getCurrentHistoryNode,
ExtendedContext,
UnifiedContextSwitch,
NodeContext
} from '../types/navigation';
interface WhiteboardRoom {
id: string;
user_id: string;
name: string;
context_type: string;
is_default: boolean;
storage_path: string | null;
neo4j_node_id: string | null;
neo4j_db_name: string | null;
node_type: string | null;
}
interface NavigationStoreWithAuth extends NavigationStore {
_accessToken: string | null;
_userId: string | null;
setAuthInfo: (token: string | null, userId: string | null) => void;
}
const initialState: NavigationContextState = {
main: 'profile',
base: 'profile',
node: null,
history: {
nodes: [],
currentIndex: -1
}
history: { nodes: [], currentIndex: -1 }
};
function getDefaultBaseForMain(main: MainContext): BaseContext {
@@ -38,402 +51,288 @@ function validateContextTransition(
updates: Partial<NavigationContextState>
): NavigationContextState {
const newState = { ...current, ...updates };
// Validate main context
if (updates.main) {
newState.base = getDefaultBaseForMain(updates.main);
}
// Validate base context
if (updates.base) {
// Ensure base context matches main context
const isValid = newState.main === 'profile'
const isValid = newState.main === 'profile'
? isProfileContext(updates.base)
: isInstituteContext(updates.base);
if (!isValid) {
newState.base = getDefaultBaseForMain(newState.main);
}
}
return newState;
}
export interface NavigationActions {
// Context Navigation
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// History Navigation
goBack: () => void;
goForward: () => void;
// Utility Methods
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
}
export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
const pgFetch = async <T = unknown>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
table: string,
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
): Promise<T | null> => {
const token = get()._accessToken;
if (!token) throw new Error('pgFetch: no access token');
const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`;
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
};
if (options.prefer) headers['Prefer'] = options.prefer;
if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json';
const res = await fetch(url, {
method,
headers,
...(options.body ? { body: JSON.stringify(options.body) } : {}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`PostgREST ${res.status}: ${err}`);
}
if (res.status === 204) return null;
return res.json() as Promise<T>;
};
export interface NavigationState {
context: {
main: NodeContext;
base: NodeContext;
extended?: string;
node: NavigationNode;
history: {
nodes: NavigationNode[];
currentIndex: number;
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
const userId = get()._userId;
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`,
});
if (rooms && rooms.length > 0) {
const room = rooms[0];
return {
id: room.id,
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`,
label: room.name,
type: 'workspace',
};
}
const storagePath = `${userId}/workspaces/${contextType}_default.json`;
const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
body: {
user_id: userId,
name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
context_type: contextType,
is_default: true,
storage_path: storagePath,
},
prefer: 'return=representation',
single: true,
});
if (!room) throw new Error('Failed to create default whiteboard room');
logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id });
return {
id: room.id,
node_storage_path: room.storage_path || storagePath,
label: room.name,
type: 'workspace',
};
};
// ... rest of the state interface ...
}
export const useNavigationStore = create<NavigationStore>((set, get) => ({
context: initialState,
isLoading: false,
error: null,
return {
_accessToken: null,
_userId: null,
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => {
try {
// Check if we have the necessary database connections
if (contextSwitch.main === 'profile' && !userDbName) {
logger.error('navigation-context', '❌ User database connection not initialized');
set({
error: 'User database connection not initialized',
isLoading: false
});
return;
}
if (contextSwitch.main === 'institute' && !workerDbName) {
logger.error('navigation-context', '❌ Worker database connection not initialized');
set({
error: 'Worker database connection not initialized',
isLoading: false
});
context: initialState,
isLoading: false,
error: null,
switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => {
if (!get()._accessToken || !get()._userId) {
logger.warn('navigation-context', '⚠️ switchContext called without auth — skipping');
return;
}
try {
set({ isLoading: true, error: null });
const currentState = get().context;
let newState: NavigationContextState = { ...currentState, node: null };
logger.debug('navigation-context', '🔄 Starting context switch', {
from: {
main: get().context.main,
base: get().context.base,
extended: contextSwitch.extended,
nodeId: get().context.node?.id
},
to: {
main: contextSwitch.main,
base: contextSwitch.base,
extended: contextSwitch.extended
},
skipBaseContextLoad: contextSwitch.skipBaseContextLoad
});
set({ isLoading: true, error: null });
const currentState = get().context;
// Clear node state immediately
const clearedState: NavigationContextState = {
...currentState,
node: null
};
set({
context: clearedState,
isLoading: true
});
let newState: NavigationContextState = {
...currentState,
node: null
};
// Update main context if provided
if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main);
if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main);
}
}
logger.debug('navigation-state', '✅ Main context updated', {
previous: currentState.main,
new: newState.main,
defaultBase: newState.base
});
}
// Update base context if provided
if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: contextSwitch.base });
logger.debug('navigation-state', '✅ Base context updated', {
previous: currentState.base,
new: newState.base
});
}
logger.debug('navigation-state', '✅ Context validation complete', {
validatedState: newState,
originalState: currentState
});
// Determine which context to use for the node
const targetContext = contextSwitch.base ||
contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) :
newState.base);
// Get database name
const dbName = getContextDatabase(newState, userDbName, workerDbName);
logger.debug('context-switch', '🔍 Fetching default node for context', {
targetContext,
dbName,
currentState: newState
});
// Get default node for the final context
const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName);
if (!defaultNode) {
const errorMsg = `No default node found for context: ${targetContext}`;
logger.error('context-switch', '❌ Default node fetch failed', { targetContext });
set({
error: errorMsg,
isLoading: false
});
return;
}
logger.debug('context-switch', '✨ Default node fetched', {
nodeId: defaultNode.id,
node_storage_path: defaultNode.node_storage_path,
type: defaultNode.type
});
// Update history and state
const newHistory = addToHistory(currentState.history, defaultNode);
logger.debug('history-management', '📚 History updated', {
previousState: currentState.history,
newState: newHistory,
addedNode: defaultNode
});
set({
context: {
...newState,
node: defaultNode,
history: newHistory
},
isLoading: false,
error: null
});
logger.debug('navigation-context', '✅ Context switch completed', {
finalState: {
main: newState.main,
base: newState.base,
nodeId: defaultNode.id
if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: contextSwitch.base });
}
});
} catch (error) {
logger.error('navigation-context', '❌ Failed to switch context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to switch context',
isLoading: false
});
}
},
goBack: () => {
const currentState = get().context;
if (currentState.history.currentIndex > 0) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
}
});
}
},
const targetContext = contextSwitch.base ||
contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
goForward: () => {
const currentState = get().context;
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
}
});
}
},
const defaultNode = await getOrCreateDefaultRoom(targetContext);
const newHistory = addToHistory(currentState.history, defaultNode);
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ main }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set main context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set main context',
isLoading: false
});
}
},
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ base }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set base context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set base context',
isLoading: false
});
}
},
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ extended }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set extended context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set extended context',
isLoading: false
});
}
},
navigate: async (nodeId: string, dbName: string) => {
try {
set({ isLoading: true, error: null });
// Check if we already have this node in history
const currentState = get().context;
const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
// If node exists in history, just navigate to it
if (existingNodeIndex !== -1) {
logger.debug('navigation', '📍 Navigating to existing node in history', {
nodeId,
historyIndex: existingNodeIndex,
currentIndex: currentState.history.currentIndex
});
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
},
context: { ...newState, node: defaultNode, history: newHistory },
isLoading: false,
error: null
error: null,
});
logger.debug('navigation-context', '✅ Context switch complete', {
main: newState.main, base: newState.base, nodeId: defaultNode.id,
});
} catch (error) {
logger.error('navigation-context', '❌ switchContext failed', error);
set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false });
}
},
goBack: () => {
const currentState = get().context;
if (currentState.history.currentIndex > 0) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
}
},
goForward: () => {
const currentState = get().context;
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
}
},
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
await get().switchContext({ main }, userDbName, workerDbName);
},
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
await get().switchContext({ base }, userDbName, workerDbName);
},
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
await get().switchContext({ extended }, userDbName, workerDbName);
},
navigate: async (nodeId: string, _dbName: string) => {
try {
set({ isLoading: true, error: null });
if (!get()._accessToken) { set({ isLoading: false }); return; }
const currentState = get().context;
const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
if (existingIndex !== -1) {
const newHistory = navigateHistory(currentState.history, existingIndex);
set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false });
return;
}
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
});
if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
const room = rooms[0];
const node: NavigationNode = {
id: room.id,
node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`,
label: room.name,
type: 'workspace',
};
const newHistory = addToHistory(currentState.history, node);
set({ context: { ...currentState, node, history: newHistory }, isLoading: false });
} catch (error) {
logger.error('navigation', '❌ navigate failed', error);
set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false });
}
},
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
if (!isValidNodeType(node.type)) {
logger.warn('navigation', `⚠️ navigateToNode called with non-graph type: ${node.type} — navigating anyway`);
}
await get().navigate(node.id, userDbName || '');
},
refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
const currentNode = get().context.node;
if (currentNode && get()._accessToken) {
const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
query: `id=eq.${currentNode.id}`,
});
if (rooms && rooms.length > 0) {
const room = rooms[0];
set({
context: {
...get().context,
node: {
id: room.id,
node_storage_path: room.storage_path || currentNode.node_storage_path,
label: room.name,
type: 'workspace',
},
},
});
}
}
set({ isLoading: false });
} catch (error) {
logger.error('navigation', '❌ refreshNavigationState failed', error);
set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false });
}
},
navigateToNeoNode: async (neoNode: NeoGraphNode) => {
const userId = get()._userId;
if (!userId || !get()._accessToken) {
logger.warn('navigation', '⚠️ navigateToNeoNode called without auth');
return;
}
// Fetch new node data
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
if (!nodeData) {
throw new Error(`Node not found: ${nodeId}`);
}
const node: NavigationNode = {
id: nodeId,
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
type: nodeData.node_type
};
logger.debug('navigation', '📍 Adding new node to history', {
nodeId: node.id,
type: node.type,
node_storage_path: node.node_storage_path
});
// Add to history and update state
const newHistory = addToHistory(currentState.history, node);
set({
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
} catch (error) {
logger.error('navigation', '❌ Failed to navigate:', error);
set({
error: error instanceof Error ? error.message : 'Failed to navigate',
isLoading: false
});
}
},
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
if (!isValidNodeType(node.type)) {
throw new Error(`Invalid node type: ${node.type}`);
}
const dbName = getContextDatabase(get().context, userDbName, workerDbName);
await get().navigate(node.id, dbName);
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to node:', error);
set({
error: error instanceof Error ? error.message : 'Failed to navigate to node',
isLoading: false
});
}
},
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
const currentState = get().context;
if (currentState.node) {
const dbName = getContextDatabase(currentState, userDbName, workerDbName);
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName);
if (nodeData) {
const node: NavigationNode = {
id: currentState.node.id,
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
type: nodeData.node_type
};
set({
context: {
...currentState,
node
}
try {
set({ isLoading: true, error: null });
const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
});
let room: WhiteboardRoom;
if (existing && existing.length > 0) {
room = existing[0];
} else {
const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`;
const created = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
body: {
user_id: userId,
name: neoNode.label,
context_type: neoNode.node_type.toLowerCase(),
is_default: false,
storage_path: storagePath,
neo4j_node_id: neoNode.neo4j_node_id,
neo4j_db_name: neoNode.neo4j_db_name,
node_type: neoNode.node_type,
},
prefer: 'return=representation',
single: true,
});
if (!created) throw new Error('Failed to create whiteboard room for node');
room = created;
}
const node: NavigationNode = {
id: room.id,
node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`,
label: room.name,
type: neoNode.node_type,
};
const currentState = get().context;
const newHistory = addToHistory(currentState.history, node);
set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null });
logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode });
} catch (error) {
logger.error('navigation', '❌ navigateToNeoNode failed', error);
set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false });
}
set({ isLoading: false });
} catch (error) {
logger.error('navigation', '❌ Failed to refresh navigation state:', error);
set({
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
isLoading: false
});
}
}
}));
},
};
});
+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;
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,9 +1,17 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { logger } from './debugConfig';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
// resolved against the current browser origin so supabase-js receives an
// absolute URL while every request stays same-origin (no CORS) and routes
// through the app host's /__supabase nginx proxy to the backend Supabase.
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
? `${window.location.origin}${rawSupabaseUrl}`
: rawSupabaseUrl;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase configuration');
}
+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; }
+340
View File
@@ -0,0 +1,340 @@
/**
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
* the shared UUIDs (template/question/region ids, exam_code).
*/
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
export interface ExamTemplate {
id: string;
title: string;
subject: string | null;
exam_id: string | null;
exam_code: string | null;
source_file_id: string | null;
page_count: number | null;
institute_id: string;
teacher_id: string;
status: ExamTemplateStatus;
created_at: string;
updated_at: string;
}
export interface CreateTemplatePayload {
title: string;
subject?: string;
exam_id?: string;
exam_code?: string;
source_file_id?: string;
page_count?: number;
institute_id?: string;
}
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
export interface MarkSchemePoint {
mark: number;
text: string;
}
export interface MarkSchemeLevel {
level: string;
min: number;
max: number;
descriptor: string;
}
export interface MarkSchemePart {
label: string;
marks: number;
guidance: string;
}
export interface MarkSchemeChecklistItem {
text: string;
marks: number;
}
export interface MarkScheme {
type?: MarkSchemeType;
points?: MarkSchemePoint[];
levels?: MarkSchemeLevel[];
parts?: MarkSchemePart[];
checklist?: MarkSchemeChecklistItem[];
text?: string;
notes?: string;
[key: string]: unknown;
}
export interface UpdateTemplateMetaPayload {
title?: string;
subject?: string | null;
page_count?: number | null;
status?: ExamTemplateStatus;
}
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export type ExamTemplateSource = 'manual' | 'ai';
export interface ExamQuestion {
id: string;
template_id: string;
parent_id: string | null;
label: string;
order: number;
max_marks: number;
answer_type: string | null;
mcq_options: unknown | null;
mark_scheme: MarkScheme;
is_container: boolean;
spec_ref: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
derivation: string | null;
}
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
export interface ExamResponseArea {
id: string;
question_id: string;
template_id: string;
page: number;
bounds: Record<string, number>;
kind: ExamResponseAreaKind;
response_form: string | null;
context_type?: string | null;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
mark_subtype?: ExamMarkSubtype | null;
derivation?: string | null;
}
export interface ExamBoundary {
id: string;
template_id: string;
question_id: string | null;
label: string | null;
page_index: number;
y: number;
bounds: Record<string, number> | null;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
derivation: string | null;
}
export interface ExamTemplateLayout {
id: string;
template_id: string;
page_index: number;
role: string | null;
margin_left: number | null;
margin_right: number | null;
margin_top: number | null;
margin_bottom: number | null;
margins_enabled: boolean;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
derivation: string | null;
meta: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
export interface ExamTemplateDetail extends ExamTemplate {
questions: ExamQuestion[];
response_areas: ExamResponseArea[];
boundaries: ExamBoundary[];
layout: ExamTemplateLayout[];
}
export interface TemplateReplacePayload {
meta?: {
title?: string;
subject?: string;
page_count?: number;
status?: ExamTemplateStatus;
};
questions: Array<{
id?: string;
parent_id?: string | null;
label: string;
order?: number;
max_marks?: number;
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
mcq_options?: unknown | null;
mark_scheme?: Record<string, unknown>;
is_container?: boolean;
spec_ref?: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
source?: ExamTemplateSource;
confirmed?: boolean;
confidence?: number | null;
derivation?: string | null;
}>;
response_areas: Array<{
id?: string;
question_id: string;
page: number;
bounds: Record<string, number>;
kind: ExamResponseArea['kind'];
response_form?: string | null;
context_type?: string | null;
source?: 'manual' | 'ai';
confirmed?: boolean;
confidence?: number | null;
mark_subtype?: ExamMarkSubtype | null;
derivation?: string | null;
}>;
boundaries: Array<{
id?: string;
question_id?: string | null;
label?: string | null;
page_index: number;
y: number;
bounds?: Record<string, number> | null;
source?: ExamTemplateSource;
confirmed?: boolean;
confidence?: number | null;
derivation?: string | null;
}>;
layout?: Array<{
id?: string;
page_index: number;
role?: string | null;
margin_left?: number | null;
margin_right?: number | null;
margin_top?: number | null;
margin_bottom?: number | null;
margins_enabled?: boolean;
source?: ExamTemplateSource;
confirmed?: boolean;
confidence?: number | null;
derivation?: string | null;
meta?: Record<string, unknown>;
}>;
}
export interface PatchQuestionPayload {
label?: string;
order?: number;
max_marks?: number;
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
mcq_options?: unknown;
mark_scheme?: MarkScheme;
is_container?: boolean;
spec_ref?: string | null;
}
export interface SpecPoint {
uid?: string;
uuid_string?: string;
ref: string;
description: string;
spec_code: string;
exam_board_code?: string;
}
export interface Neo4jSyncResult {
status: string;
projection?: Record<string, unknown>;
}
export interface AutoMapAcceptedResponse {
status: 'accepted';
job_id: string;
}
export interface AutoMapJobStatus {
job_id: string;
status: 'queued' | 'running' | 'completed' | 'failed' | string;
template_id: string;
updated_at?: number;
counts?: Record<string, number>;
error?: string;
template?: ExamTemplateDetail;
}
export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse;
export interface MarkingBatch {
id: string;
template_id: string;
class_id: string | null;
institute_id: string;
teacher_id: string;
title: string | null;
status: 'open' | 'closed' | 'archived' | string;
created_at: string;
updated_at?: string;
submission_count?: number;
}
export interface StudentSubmission {
id: string;
batch_id: string;
student_id: string | null;
student_name: string | null;
status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
storage_path?: string | null;
mark_entry_count?: number;
}
export interface BatchQueueResponse {
batch: MarkingBatch;
submissions: StudentSubmission[];
progress: {
total: number;
absent: number;
complete: number;
in_progress: number;
};
}
export interface ExamResultRow {
submission_id: string;
student_id: string | null;
student_name: string | null;
status: string | null;
marks: Record<string, number | null | undefined>;
total: number | null;
}
export interface BatchResultsResponse {
batch: MarkingBatch;
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
results: ExamResultRow[];
}
export interface CreateBatchPayload {
template_id: string;
class_id?: string;
title?: string;
}
export interface MarkUpsertPayload {
submission_id: string;
question_id: string;
awarded_marks: number;
mark_scheme_detail?: Record<string, unknown>;
annotation_shape_ids?: unknown;
comment?: string;
confirmed?: boolean;
}
+8
View File
@@ -227,6 +227,13 @@ export interface UnifiedContextSwitch {
}
// Navigation Actions Interface
export interface NeoGraphNode {
neo4j_node_id: string;
neo4j_db_name: string;
node_type: string;
label: string;
}
export interface NavigationActions {
// Unified Context Switch
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
@@ -239,6 +246,7 @@ export interface NavigationActions {
// Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
navigateToNeoNode: (neoNode: NeoGraphNode) => Promise<void>;
// History Navigation
goBack: () => void;
+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 {
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import type { ExamTemplateDetail } from '../../types/exam.types'
import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
const template: ExamTemplateDetail = {
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
}
describe('exam setup canvas serialization', () => {
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
const payload = serializeCanvasShapes(template, [
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
{ id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
])
const main = payload.questions.find((q) => q.is_container)
const part = payload.questions.find((q) => !q.is_container)
expect(main?.label).toBe('Q1')
expect(part?.parent_id).toBe(main?.id)
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' })
expect(payload.boundaries).toHaveLength(2)
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
expect(payload.questions.every((q) => isUuid(q.id))).toBe(true)
expect(payload.response_areas.every((r) => isUuid(r.id))).toBe(true)
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
})
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
const pages = [
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
]
expect(pageForY(1050, pages)).toBe(2)
const payload = serializeCanvasShapes(template, [
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
], pages)
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
expect(payload.response_areas[0].page).toBe(2)
})
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
const shapes = shapesFromTemplate({
...template,
questions: [
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
],
response_areas: [
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
],
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
})
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
})
it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => {
const detail: ExamTemplateDetail = {
...template,
questions: [
{ id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
{ id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
{ id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' },
],
response_areas: [
{ id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' },
],
boundaries: [
{ id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
{ id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
],
}
const shapes = shapesFromTemplate(detail)
const part = shapes.find((shape) => shape.kind === 'part')
expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes']))
const payload = serializeCanvasShapes(template, shapes)
expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' })
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
})
})
+208
View File
@@ -0,0 +1,208 @@
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
export const PAGE_HEIGHT = 1100
export const PAGE_WIDTH = 780
export const PAGE_GAP = 0
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
export interface ExamCanvasShapeModel {
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
id: string
kind: ExamCanvasShapeKind
x: number
y: number
w: number
h: number
label?: string
maxMarks?: number
answerType?: 'written' | 'mcq' | 'short' | 'diagram'
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
contextType?: string
questionId?: string | null
source?: ExamTemplateSource
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string[]
}
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
if (pages?.length) {
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
if (hit) return hit.pageNumber
const nearest = pages.reduce((best, page) => {
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
return dy < best.dy ? { page, dy } : best
}, { page: pages[0], dy: Number.POSITIVE_INFINITY })
return nearest.page.pageNumber
}
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
}
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
const hit = pages?.find((p) => p.pageNumber === page)
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
}
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
return pageForY(shape.y + shape.h / 2, pages)
}
export function isUuid(value: string | null | undefined): value is string {
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
}
export function newDomainId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16)
const v = c === 'x' ? r : (r % 4) + 8
return v.toString(16)
})
}
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
}
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
}
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
const page = pageGeometry(pageForShape(shape, pages), pages)
return { x: page.x, y: shape.y, w: page.w, h: 8 }
}
function persistedSource(shape: ExamCanvasShapeModel): ExamTemplateSource {
return shape.source ?? 'manual'
}
function persistedConfirmed(shape: ExamCanvasShapeModel): boolean {
return shape.confirmed ?? persistedSource(shape) !== 'ai'
}
function persistedConfidence(shape: ExamCanvasShapeModel): number | null {
return typeof shape.confidence === 'number' ? shape.confidence : null
}
function persistedDerivation(shape: ExamCanvasShapeModel): string | null {
return shape.derivation ?? null
}
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
const ox2 = outer.x + outer.w
const oy2 = outer.y + outer.h
const ix2 = inner.x + inner.w
const iy2 = inner.y + inner.h
return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2
}
function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean {
const minY = Math.min(top.y, bottom.y)
const maxY = Math.max(top.y, bottom.y)
const cy = shape.y + shape.h / 2
return cy >= minY && cy <= maxY
}
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
const parts = shapes.filter((s) => s.kind === 'part')
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
const questions: TemplateReplacePayload['questions'] = []
const boundaries: TemplateReplacePayload['boundaries'] = []
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
for (let i = 0; i < orderedBoundaries.length; i += 2) {
const top = orderedBoundaries[i]
const bottom = orderedBoundaries[i + 1]
if (!top || !bottom) break
const qNum = bands.length + 1
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
bands.push({ questionId, top, bottom })
for (const b of [top, bottom]) {
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
}
}
const partQuestionIds = new Map<string, string>()
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
partQuestionIds.set(part.id, qid)
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
})
const response_areas: TemplateReplacePayload['response_areas'] = []
for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
}
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
}
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) {
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
// but render and save a full rendered-page-width horizontal rule.
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id, source: b.source, confirmed: b.confirmed, confidence: b.confidence, derivation: b.derivation })
}
for (const q of detail.questions ?? []) {
if (q.is_container || !q.bounds) continue
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: (q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id, source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
}
for (const r of detail.response_areas ?? []) {
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
const q = questions.get(r.question_id)
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `${q.label}` : r.kind, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation })
}
return addCheapReviewFlags(shapes, pages)
}
function overlaps(a: ExamCanvasShapeModel, b: ExamCanvasShapeModel): boolean {
const ax2 = a.x + a.w
const ay2 = a.y + a.h
const bx2 = b.x + b.w
const by2 = b.y + b.h
return a.x < bx2 && ax2 > b.x && a.y < by2 && ay2 > b.y
}
function looksUncertainLabel(label: string | undefined): boolean {
return !label || /\b(unknown|uncertain|maybe|todo|tbd)\b|\?/.test(label.toLowerCase())
}
function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
const markAreasByQuestion = new Set(shapes.filter((shape) => shape.kind === 'mark_area' && shape.questionId).map((shape) => shape.questionId as string))
return shapes.map((shape, index) => {
const flags: string[] = []
if (shape.source === 'ai' && shape.confirmed === false) flags.push('unconfirmed AI')
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
const samePageOverlap = shapes.some((other, otherIndex) => otherIndex !== index && shape.kind !== 'boundary' && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && (shape.kind === other.kind || (!contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))))
if (samePageOverlap) flags.push('overlapping shapes')
return flags.length ? { ...shape, reviewFlags: flags } : shape
})
}
+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',

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