Compare commits

...
Author SHA1 Message Date
CC WorkerandClaude Opus 4.8 4001772d5e FX-8: stop dropping / mis-attaching response areas on save
serializeCanvasShapes recomputed each region's owner purely from geometry,
ignoring the persisted question_id: a region with no enclosing part (or any
region when a template had no parts) was silently dropped, and one overflowing
its part by a pixel re-parented to parts[0].

Resolve the owner in order: geometric containment (a drag into a part re-attaches)
-> persisted question_id if it still points at a saved question (survives drift /
no-container) -> nearest part on the page -> first part -> first question of any
kind. exam_response_areas.question_id is NOT NULL, so a region is never dropped
while any question exists.

Tests: never drops a region with no enclosing part; persisted question wins over
geometric nearest. Full model.test.ts suite (6) passes; tsc clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:40:11 +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
kcarandClaude Sonnet 4.6 0345258247 fix: transcription getUserMedia called before enumerateDevices
The old startTranscription() called enumerateDevices() first to find a
device ID, then getUserMedia. Without microphone permission, enumerateDevices()
returns devices with deviceId="" (empty string, falsy). This caused the
!this.selectedDeviceId check to bail out early, never calling getUserMedia,
never prompting the user for mic permission, and never creating the WebSocket.

Result: user clicks Start Recording → isRecording=true → button changes to
Stop Recording → but no mic prompt, no WebSocket, no transcription.

Fix: call getUserMedia directly (with optional echoCancellation/noiseSuppression
if no device pre-selected). getUserMedia triggers the browser permission prompt
automatically. Device selection is still honoured via exact deviceId constraint
when one has been explicitly chosen.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 19:32:43 +00:00
kcarandClaude Sonnet 4.6 83f17c9ab6 fix: UserContext fast-path initialization — no spinner on page refresh
Before: UserContext waited for a Supabase profiles table query (~200ms)
before setting isInitialized=true, causing FullContextRoutes to show
a spinner on every page refresh even with a valid stored session.

After: UserContext immediately builds a profile from auth metadata +
localStorage (synchronous, zero network calls) and sets isInitialized=true
before the Supabase query. The Supabase profiles table is still queried
in the background and the profile is updated when it returns with
authoritative user_db_name/school_db_name data.

On refresh with a stored session: auth metadata is available
synchronously via INITIAL_SESSION, so the page renders with no visible
spinner.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 19:16:02 +00:00
kcarandClaude Sonnet 4.6 5e21a2c7fc fix: add missing transcription entry to PANEL_DIMENSIONS
BasePanel reads PANEL_DIMENSIONS[currentPanelType] to get the panel
dimensions (topOffset, bottomOffset, width). The transcription panel
type was added to the panel list and render switch during CIS Phase 3C
but was never added to PANEL_DIMENSIONS. This caused a crash whenever
the BasePanel rendered with the transcription tab active:

  PANEL_DIMENSIONS["transcription"] === undefined
  undefined.topOffset → TypeError: Cannot read properties of undefined

This crash appeared as a topOffset error anywhere a Tldraw canvas was
rendered (singlePlayerPage, multiplayerUser, etc.).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 19:05:51 +00:00
kcarandClaude Sonnet 4.6 e27d1f5c8d fix: prevent TLDraw topOffset crash — decouple store init from editor ready
Three changes to singlePlayerPage:

1. Store creation no longer waits for isEditorReady. The editor ref
   is already passed as optional to NavigationSnapshotService and
   loadNodeSnapshotFromDatabase, so we can create and populate the
   store without needing the editor to be mounted first.

2. <Tldraw> only renders when store is defined: {store && <Tldraw>}.
   Previously it rendered immediately with store={undefined}, which
   caused TLDraw to initialize with no backing store and crash
   reading topOffset on an unresolved internal object.

3. Loading guard also checks !user so the component never renders
   with a null profile before the redirect effect fires.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 18:17:52 +00:00
kcarandClaude Sonnet 4.6 b1681d86fb fix: rewrite AuthContext to use canonical Supabase onAuthStateChange pattern
The previous implementation had two concurrent session recovery paths:
1. loadInitialSession() calling supabase.auth.getSession()
2. onAuthStateChange handling INITIAL_SESSION/SIGNED_IN

These raced unpredictably causing setLoading(false) to never fire in
certain interleavings, leaving the app permanently stuck on the spinner.

Fix: Remove loadInitialSession() entirely. Start loading=true. Rely
solely on onAuthStateChange — INITIAL_SESSION fires immediately with
the current session state, eliminating the race. One path, one
setLoading(false) call, no interleaving possible.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 18:05:51 +00:00
kcarandClaude Sonnet 4.6 d789586fca fix: nginx serves index.html with no-cache headers
Vite hashes all JS/CSS assets, so those can be cached for 30 days.
But index.html must never be cached — it references the current hashes.
Caching index.html meant users ran old JS after deploys.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:54:49 +00:00
kcarandClaude Sonnet 4.6 3c0fd4f647 fix: FullContextRoutes no longer blocks on Neo4j contexts
Removed NeoUserContext and NeoInstituteContext from the gateway
spinner. They were blocking ALL authenticated routes until Neo4j
graph data was loaded, which took 1-8 seconds and caused persistent
spinners. Now only UserContext (Supabase profile, ~200ms) must be
ready before routes render. Each page handles its own graph data
loading state independently.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:52:31 +00:00
kcarandClaude Sonnet 4.6 679653c118 fix: workspace spinner — use profile (not null user) and add 8s context timeouts
singlePlayerPage was using useUser().user which is always null (no setter
in UserContext). This caused the redirect effect to always fire, making
the workspace completely inaccessible. Fixed by destructuring profile as
user, which IS properly set from the Supabase profiles query.

Also added 8s timeout to NeoUserContext.switchContext call to match the
NeoInstituteContext timeout fix, preventing infinite spinner if the
navigation API call hangs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:43:38 +00:00
kcarandClaude Sonnet 4.6 ab1f8111f6 fix: add 8s timeout to NeoInstituteContext school node fetch
Races the SchoolNeoDBService.getSchoolNode() call against an 8-second
timeout. If Neo4j is slow or unavailable the workspace now loads within
seconds rather than waiting for the full axios 120s timeout. The context
degrades gracefully — workspace opens without institute data, error is
logged as a warning not an exception.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:28:57 +00:00
kcarandClaude Sonnet 4.6 d3c2a9bdff security: pass TLSYNC_SECRET token in sync WebSocket URI
Reads VITE_TLSYNC_SECRET from env and appends ?token=... to the
/connect/:roomId WebSocket URI so tlsync server can authenticate connections.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 17:09:39 +00:00
kcar 06f761e750 feat(cis): add Keywords tab — watches, real-time detection, match log (Phase 3C)
- KeywordWatch and KeywordMatch interfaces in transcriptionStore
- loadKeywordWatches, addKeywordWatch, deleteKeywordWatch actions via API with JWT auth
- checkSegmentForKeywords: client-side detection on each final segment, logs events to backend
- clearKeywordMatches: resets session-scoped match list
- Keywords tab in CCTranscriptionPanel: add/delete watches, match log with timestamp
- Match count badge on Keywords tab when hits exist during recording
- Also fixes missing Close import that was present in summary modal
2026-05-21 12:21:17 +00:00
134 changed files with 29185 additions and 4953 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_CLIENT_SECRET=dummy_secret
VITE_MICROSOFT_TENANT_ID=common 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 .env
# Build output .env.local
dist/ .env.*.local
.env.bak
# Lock files - clean install # IDE
package-lock.json .vscode/
*.bak .idea/
*.bak *.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')
});
});
+67 -4
View File
@@ -1,24 +1,87 @@
FROM node:20 AS builder FROM node:20 AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ 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 # 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 RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && npm ci
COPY . . 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 FROM nginx:alpine
# Copy built files # Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
# Create a simple nginx configuration # Create a simple nginx configuration
RUN echo 'server { \ RUN echo 'server { \
listen 3000; \ listen 3000; \
root /usr/share/nginx/html; \ root /usr/share/nginx/html; \
index index.html; \ index index.html; \
location = /index.html { \
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 / { \ location / { \
try_files $uri $uri/ /index.html; \ try_files $uri $uri/ /index.html; \
expires 30d; \ 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: services:
classroom-copilot: frontend:
container_name: classroom-copilot container_name: cc-prod
image: cc-app-prod:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
ENV_FILE: .env
env_file: env_file:
- .env - .env
ports: ports:
- 3000:3000 - "3000:3000"
volumes:
- ./:/app
networks: networks:
- kevlarai-network - kevlarai-network
restart: unless-stopped
networks: networks:
kevlarai-network: kevlarai-network:
external: true
name: kevlarai-network name: kevlarai-network
driver: bridge
+14397
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,7 +5,9 @@
"start": "vite --host", "start": "vite --host",
"build": "vite build", "build": "vite build",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
@@ -82,7 +84,6 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"react-refresh": "^0.14.2", "react-refresh": "^0.14.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite-plugin-pwa": "^0.21.1", "vite-plugin-pwa": "^0.21.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
+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 { export default {
plugins: { plugins: {
tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
'postcss-import': {}, 'postcss-import': {},
}, },
+16 -2
View File
@@ -1,9 +1,23 @@
class AudioProcessor extends AudioWorkletProcessor { class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._buffer = new Float32Array(4096);
this._bufferIndex = 0;
}
process(inputs) { process(inputs) {
const input = inputs[0]; const input = inputs[0];
if (input.length > 0) { if (input.length > 0) {
const audioData = input[0]; const samples = input[0];
this.port.postMessage(audioData); 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; return true;
} }
+49 -13
View File
@@ -1,34 +1,70 @@
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles'; import { useMemo, useEffect } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { useTLDraw } from './contexts/TLDrawContext';
import { theme } from './services/themeService'; import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext'; import { TLDrawProvider } from './contexts/TLDrawContext';
import { UserProvider } from './contexts/UserContext'; import { UserProvider } from './contexts/UserContext';
import { NeoUserProvider } from './contexts/NeoUserContext';
import { NeoInstituteProvider } from './contexts/NeoInstituteContext';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import { ErrorBoundary } from './components/ErrorBoundary';
import React from 'react'; import React from 'react';
// Wrap the entire app in a memo to prevent unnecessary re-renders /**
const App = React.memo(() => ( * Syncs tldraw's color scheme to data-color-mode on <html>.
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
* to the document root so --cc-* token overrides apply globally.
*/
const DarkModeSync: React.FC = () => {
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
useEffect(() => {
const scheme = tldrawPreferences?.colorScheme ?? 'system';
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
return null;
};
const App = React.memo(() => {
const prefersDarkMode =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const appTheme = useMemo(() => {
// App is outside TLDrawProvider so tldrawPreferences is always null here.
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
// updating data-color-mode once tldraw preferences are loaded.
const mode = prefersDarkMode ? 'dark' : 'light';
return createTheme({ palette: { mode } });
}, [prefersDarkMode]);
return (
<ErrorBoundary>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
<AuthProvider> <AuthProvider>
<UserProvider> <UserProvider>
<NeoUserProvider>
<NeoInstituteProvider>
<TLDrawProvider> <TLDrawProvider>
<DarkModeSync />
<ThemeProvider theme={appTheme}>
<CssBaseline />
<AppRoutes /> <AppRoutes />
</ThemeProvider>
</TLDrawProvider> </TLDrawProvider>
</NeoInstituteProvider>
</NeoUserProvider>
</UserProvider> </UserProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
)); </ErrorBoundary>
);
});
// Add display name for better debugging
App.displayName = import.meta.env.VITE_APP_NAME; 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();
});
});
+68 -21
View File
@@ -2,18 +2,18 @@ import React from 'react';
import { Routes, Route, useLocation, Outlet, Navigate } from 'react-router-dom'; import { Routes, Route, useLocation, Outlet, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import { useUser } from './contexts/UserContext'; import { useUser } from './contexts/UserContext';
import { useNeoUser } from './contexts/NeoUserContext';
import { useNeoInstitute } from './contexts/NeoInstituteContext';
import Layout from './pages/Layout'; import Layout from './pages/Layout';
import LoginPage from './pages/auth/loginPage'; import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage'; import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser'; import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker'; import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage'; import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage'; import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas'; import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
import AdminDashboard from './pages/auth/adminPage'; import AdminDashboard from './pages/auth/adminPage';
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
import TLDrawDevPage from './pages/tldraw/devPlayerPage'; import TLDrawDevPage from './pages/tldraw/devPlayerPage';
import DevPage from './pages/tldraw/devPage'; import DevPage from './pages/tldraw/devPage';
import TeacherPlanner from './pages/react-flow/teacherPlanner'; import TeacherPlanner from './pages/react-flow/teacherPlanner';
@@ -31,25 +31,25 @@ import DashboardPage from './pages/user/dashboardPage';
// Timetable Module Pages // Timetable Module Pages
import { import {
TimetablePage, TimetablePage,
TimetableListPage,
ClassesPage, ClassesPage,
LessonPage, LessonPage,
TaughtLessonsPage,
MyClassesPage, MyClassesPage,
EnrollmentRequestsPage, EnrollmentRequestsPage,
StaffManagerPage,
StudentManagerPage,
SchoolSettingsPage,
ClassDetailPage,
StudentLessonsPage,
LessonPlansPage,
LessonPlanDetailPage,
} from './pages/timetable'; } from './pages/timetable';
const FullContextRoutes: React.FC = () => { const FullContextRoutes: React.FC = () => {
const { isInitialized: isUserInitialized } = useUser(); const { isInitialized: isUserInitialized } = useUser();
const { isLoading: isNeoUserLoading, isInitialized: isNeoUserInitialized } = useNeoUser();
const { isLoading: isNeoInstituteLoading, isInitialized: isNeoInstituteInitialized } = useNeoInstitute();
const isLoading = if (!isUserInitialized) {
!isUserInitialized ||
isNeoUserLoading ||
!isNeoUserInitialized ||
isNeoInstituteLoading ||
!isNeoInstituteInitialized;
if (isLoading) {
return ( return (
<div <div
style={{ style={{
@@ -68,9 +68,36 @@ const FullContextRoutes: React.FC = () => {
return <Outlet />; return <Outlet />;
}; };
const RequireAuth: React.FC = () => {
const { user, isAuthResolving } = useAuth();
if (isAuthResolving) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}
>
<CircularProgress />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
};
const AppRoutes: React.FC = () => { const AppRoutes: React.FC = () => {
const { user, loading: isAuthLoading } = useAuth(); const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation(); const location = useLocation();
const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
// Debug log for routing // Debug log for routing
logger.debug('routing', '🔄 Rendering routes', { logger.debug('routing', '🔄 Rendering routes', {
@@ -123,24 +150,44 @@ const AppRoutes: React.FC = () => {
<Route <Route
path="/admin" path="/admin"
element={ 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 */} {/* Authentication only routes - only render if all contexts are initialized */}
{user && ( <Route element={<RequireAuth />}>
<Route element={<FullContextRoutes />}> <Route element={<FullContextRoutes />}>
{/* Timetable Module Routes */} {/* Timetable Module Routes */}
<Route path="/timetable" element={<TimetablePage />} /> <Route path="/timetable" element={<TimetableListPage />} />
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
<Route path="/classes" element={<ClassesPage />} /> <Route path="/classes" element={<ClassesPage />} />
<Route path="/my-classes" element={<MyClassesPage />} /> <Route path="/my-classes" element={<MyClassesPage />} />
<Route path="/classes/:classId" element={<ClassDetailPage />} />
<Route path="/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="/enrollment-requests" element={<EnrollmentRequestsPage />} />
<Route path="/lessons/:lessonId" element={<LessonPage />} /> <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 */} {/* Existing Routes */}
<Route path="/search" element={<SearxngPage />} /> <Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} /> <Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<CCExamMarker />} /> <Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></ErrorBoundary>} />
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} /> <Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} /> <Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} /> <Route path="/tldraw-dev" element={<TLDrawDevPage />} />
@@ -151,10 +198,10 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} /> <Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
)} </Route>
{/* Fallback route - use different NotFound pages based on auth state */} {/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <NotFound /> : <NotFoundPublic />} /> <Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
</Routes> </Routes>
</Layout> </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 axios from 'axios';
import { API_BASE as CONFIG_API_BASE } from './config/apiConfig';
import { logger } from './debugConfig'; import { logger } from './debugConfig';
// Use development backend URL if no custom URL is provided // 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) { if (!import.meta.env.VITE_API_BASE && !import.meta.env.VITE_API_URL) {
logger.warn('axios', '⚠️ VITE_API_BASE not set, defaulting to http://localhost:8080'); logger.warn('axios', '⚠️ VITE_API_BASE not set, using relative /api fallback');
} }
const instance = axios.create({ 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 @@
+49 -386
View File
@@ -1,333 +1,60 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState } from 'react';
import { import {
IconButton, IconButton, Tooltip, Box, Menu, MenuItem,
Tooltip, ListItemIcon, ListItemText, Chip, styled,
Box,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Button,
styled
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack as ArrowBackIcon, ArrowBack as ArrowBackIcon,
ArrowForward as ArrowForwardIcon, ArrowForward as ArrowForwardIcon,
History as HistoryIcon, History as HistoryIcon,
School as SchoolIcon, Home as HomeIcon,
Person as PersonIcon, CalendarToday,
AccountCircle as AccountCircleIcon, DateRange,
CalendarToday as CalendarIcon, Event,
School as TeachingIcon, WorkspacesOutlined,
Business as BusinessIcon,
AccountTree as DepartmentIcon,
Class as ClassIcon,
ExpandMore as ExpandMoreIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigationStore } from '../../stores/navigationStore'; 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)` const NavigationRoot = styled(Box)`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
height: 100%; height: 100%;
overflow: hidden;
`; `;
const NavigationControls = styled(Box)` function getNodeIcon(nodeType: string) {
display: flex; switch (nodeType) {
align-items: center; case 'User': return <HomeIcon fontSize="small" />;
gap: 4px; case 'CalendarYear': return <CalendarToday fontSize="small" />;
`; case 'CalendarMonth': return <DateRange fontSize="small" />;
case 'CalendarDay': return <Event fontSize="small" />;
const ContextToggleContainer = styled(Box)(({ theme }) => ({ default: return <WorkspacesOutlined fontSize="small" />;
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'
} }
} }
}));
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 = () => { export const GraphNavigator: React.FC = () => {
const { const { context, goBack, goForward, isLoading } = useNavigationStore();
context,
switchContext,
goBack,
goForward,
isLoading
} = useNavigationStore();
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null); 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 { history } = context;
const canGoBack = history.currentIndex > 0; const canGoBack = history.currentIndex > 0;
const canGoForward = history.currentIndex < history.nodes.length - 1; 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 ( return (
<NavigationRoot ref={rootRef}> <NavigationRoot>
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
<Tooltip title="Back"> <Tooltip title="Back">
<span> <span>
<IconButton <IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
onClick={goBack}
disabled={!canGoBack || isDisabled}
size="small"
>
<ArrowBackIcon fontSize="small" /> <ArrowBackIcon fontSize="small" />
</IconButton> </IconButton>
</span> </span>
@@ -337,7 +64,7 @@ export const GraphNavigator: React.FC = () => {
<span> <span>
<IconButton <IconButton
onClick={handleHistoryClick} onClick={handleHistoryClick}
disabled={!history.nodes.length || isDisabled} disabled={!history.nodes.length}
size="small" size="small"
> >
<HistoryIcon fontSize="small" /> <HistoryIcon fontSize="small" />
@@ -347,112 +74,48 @@ export const GraphNavigator: React.FC = () => {
<Tooltip title="Forward"> <Tooltip title="Forward">
<span> <span>
<IconButton <IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
onClick={goForward}
disabled={!canGoForward || isDisabled}
size="small"
>
<ArrowForwardIcon fontSize="small" /> <ArrowForwardIcon fontSize="small" />
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
</NavigationControls>
{/* History Menu */} {currentNode && (
<Chip
size="small"
icon={getNodeIcon(currentNode.type)}
label={currentNode.label || currentNode.type}
variant="outlined"
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
/>
)}
<Menu <Menu
anchorEl={historyMenuAnchor} anchorEl={historyMenuAnchor}
open={Boolean(historyMenuAnchor)} open={Boolean(historyMenuAnchor)}
onClose={handleHistoryClose} onClose={handleHistoryClose}
anchorOrigin={{ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
vertical: 'bottom', transformOrigin={{ vertical: 'top', horizontal: 'center' }}
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
> >
{history.nodes.map((node, index) => ( {history.nodes.map((node, index) => (
<MenuItem <MenuItem
key={`${node.id}-${index}`} key={`${node.id}-${index}`}
onClick={() => handleHistoryItemClick(index)} onClick={() => handleHistoryItemClick(index)}
selected={index === history.currentIndex} selected={index === history.currentIndex}
dense
> >
<ListItemIcon> <ListItemIcon sx={{ minWidth: 32 }}>
{getContextIcon(node.type)} {getNodeIcon(node.type)}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={node.label || node.id} primary={node.label || node.id}
secondary={node.type} secondary={node.type}
primaryTypographyProps={{ fontSize: '0.8rem' }}
secondaryTypographyProps={{ fontSize: '0.7rem' }}
/> />
</MenuItem> </MenuItem>
))} ))}
</Menu> </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> </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/";
+77 -140
View File
@@ -4,35 +4,46 @@ import { Session, User } from '@supabase/supabase-js';
import { CCUser, CCUserMetadata, authService } from '../services/auth/authService'; import { CCUser, CCUserMetadata, authService } from '../services/auth/authService';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { supabase } from '../supabaseClient'; import { supabase } from '../supabaseClient';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { storageService, StorageKeys } from '../services/auth/localStorageService'; import { storageService, StorageKeys } from '../services/auth/localStorageService';
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
export interface AuthContextType { export interface AuthContextType {
user: CCUser | null; user: CCUser | null;
user_role: string | null; user_role: string | null;
accessToken: string | null;
loading: boolean; loading: boolean;
error: Error | null; error: Error | null;
signIn: (email: string, password: string) => Promise<void>; signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
clearError: () => void; clearError: () => void;
bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
} }
export const AuthContext = createContext<AuthContextType>({ export const AuthContext = createContext<AuthContextType>({
user: null, user: null,
user_role: null, user_role: null,
accessToken: null,
loading: true, loading: true,
error: null, error: null,
signIn: async () => {}, signIn: async () => {},
signOut: async () => {}, signOut: async () => {},
clearError: () => {} clearError: () => {},
bootstrapData: null,
isAuthResolving: false
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [user, setUser] = useState<CCUser | null>(null); const [user, setUser] = useState<CCUser | null>(null);
const [user_role, setUserRole] = useState<string | null>(null); const [user_role, setUserRole] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
const [isAuthResolving, setIsAuthResolving] = useState(false);
const persistSession = useCallback((session: Session | null) => { const persistSession = useCallback((session: Session | null) => {
if (session) { if (session) {
@@ -42,40 +53,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, []); }, []);
const restoreSessionFromStorage = useCallback(async (): Promise<Session | null> => {
const persistedSession = storageService.get(StorageKeys.SUPABASE_SESSION);
if (!persistedSession) {
return null;
}
if (!persistedSession.access_token || !persistedSession.refresh_token) {
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}
const { data: restored, error: restoreError } = await supabase.auth.setSession({
access_token: persistedSession.access_token,
refresh_token: persistedSession.refresh_token,
});
if (restoreError) {
logger.warn('auth-context', '⚠️ Failed to restore persisted Supabase session', {
error: restoreError.message ?? restoreError,
});
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}
if (restored.session) {
persistSession(restored.session);
return restored.session;
}
storageService.remove(StorageKeys.SUPABASE_SESSION);
return null;
}, [persistSession]);
const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => { const buildUserFromSupabase = useCallback(async (supabaseUser: User | null): Promise<{ user: CCUser | null; role: string | null }> => {
if (!supabaseUser) { if (!supabaseUser) {
return { user: null, role: null }; return { user: null, role: null };
@@ -86,20 +63,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername; const baseDisplayName = metadata.display_name || metadata.name || metadata.preferred_username || baseUsername;
const userType = (metadata.user_type || 'email_teacher').trim(); 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 = { const resolvedUser: CCUser = {
id: supabaseUser.id, id: supabaseUser.id,
email: supabaseUser.email, email: supabaseUser.email,
user_type: userType, user_type: userType,
username: baseUsername, username: baseUsername,
display_name: baseDisplayName, display_name: baseDisplayName,
user_db_name: userDbName, school_id: null,
school_db_name: schoolDbName,
created_at: supabaseUser.created_at, created_at: supabaseUser.created_at,
updated_at: supabaseUser.updated_at updated_at: supabaseUser.updated_at
}; };
@@ -108,128 +78,90 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { user: resolvedUser, role: resolvedRole }; 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(() => { useEffect(() => {
let isMounted = true;
const loadInitialSession = async () => {
setLoading(true);
try {
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
throw error;
}
let activeSession: Session | null = session ?? null;
if (!activeSession) {
activeSession = await restoreSessionFromStorage();
}
if (!isMounted) {
return;
}
if (activeSession?.user) {
persistSession(activeSession);
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(activeSession.user);
if (!isMounted) {
return;
}
setUser(resolvedUser);
setUserRole(role);
} catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from initial session', {
error: buildError,
});
if (!isMounted) {
return;
}
setUser(null);
setUserRole(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
persistSession(null);
setUser(null);
setUserRole(null);
}
} catch (error) {
logger.error('auth-context', '❌ Failed to load initial session', { error });
if (isMounted) {
setError(error instanceof Error ? error : new Error('Failed to load user'));
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadInitialSession();
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => { async (event, session) => {
if (!isMounted) { logger.debug('auth-context', '🔄 Auth state change', { event, hasSession: !!session });
return;
}
switch (event) { if (event === 'SIGNED_IN') {
case 'SIGNED_IN':
case 'TOKEN_REFRESHED':
case 'INITIAL_SESSION': {
persistSession(session ?? null); persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) { if (session?.user) {
setLoading(true);
try { try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user); const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
if (!isMounted) {
return;
}
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(session.access_token ?? null);
await loadBootstrap(session.access_token);
} catch (buildError) { } catch (buildError) {
logger.error('auth-context', '❌ Failed to build user from session', { logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
event,
error: buildError,
});
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user')); setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
} finally {
if (isMounted) {
setLoading(false);
}
} }
} else { } else {
setUser(null); setUser(null);
setUserRole(null); setUserRole(null);
if (isMounted) { setAccessToken(null);
}
setLoading(false); setLoading(false);
setIsAuthResolving(false);
return;
} }
}
break; if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
} persistSession(session ?? null);
case 'SIGNED_OUT': { setIsAuthResolving(!!session?.user);
persistSession(null); 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); setUser(null);
setUserRole(null); setUserRole(null);
if (isMounted) { setAccessToken(null);
setError(buildError instanceof Error ? buildError : new Error('Failed to load user'));
}
} else {
setUser(null);
setUserRole(null);
setAccessToken(null);
}
setLoading(false); setLoading(false);
setIsAuthResolving(false);
return;
} }
break;
} if (event === 'SIGNED_OUT') {
default: persistSession(null);
break; setIsAuthResolving(false);
setUser(null);
setUserRole(null);
setAccessToken(null);
setBootstrapData(null);
invalidateBootstrapCache();
setLoading(false);
} }
} }
); );
return () => { return () => subscription.unsubscribe();
isMounted = false; }, [buildUserFromSupabase, persistSession, loadBootstrap]);
subscription.unsubscribe();
};
}, [buildUserFromSupabase, persistSession, restoreSessionFromStorage]);
const signIn = async (email: string, password: string) => { const signIn = async (email: string, password: string) => {
try { try {
@@ -249,6 +181,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { user: resolvedUser, role } = await buildUserFromSupabase(data.user); const { user: resolvedUser, role } = await buildUserFromSupabase(data.user);
setUser(resolvedUser); setUser(resolvedUser);
setUserRole(role); setUserRole(role);
setAccessToken(data.session?.access_token ?? null);
await loadBootstrap(data.session?.access_token ?? null);
} }
} catch (error) { } catch (error) {
logger.error('auth-context', '❌ Sign in failed', { error }); logger.error('auth-context', '❌ Sign in failed', { error });
@@ -282,11 +216,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
value={{ value={{
user, user,
user_role, user_role,
accessToken,
loading, loading,
error, error,
signIn, signIn,
signOut, signOut,
clearError clearError,
bootstrapData,
isAuthResolving
}} }}
> >
{children} {children}
+14 -2
View File
@@ -5,6 +5,8 @@ import { SchoolNeoDBService } from '../services/graph/schoolNeoDBService';
import { CCSchoolNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; import { CCSchoolNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
const NEO_INSTITUTE_TIMEOUT_MS = 8000;
export interface NeoInstituteContextType { export interface NeoInstituteContextType {
schoolNode: CCSchoolNodeProps | null; schoolNode: CCSchoolNodeProps | null;
isLoading: boolean; isLoading: boolean;
@@ -58,7 +60,17 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
userEmail: user?.email userEmail: user?.email
}); });
const node = await SchoolNeoDBService.getSchoolNode(profile.school_db_name); // Race the Neo4j call against a timeout so a slow/unavailable Neo4j
// never blocks the workspace from loading for more than 8 seconds.
const timeoutPromise = new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error(`Neo4j timed out after ${NEO_INSTITUTE_TIMEOUT_MS}ms`)), NEO_INSTITUTE_TIMEOUT_MS)
);
const node = await Promise.race([
SchoolNeoDBService.getSchoolNode(profile.school_db_name),
timeoutPromise
]);
if (node) { if (node) {
setSchoolNode(node); setSchoolNode(node);
logger.debug('neo-institute-context', '✅ School node loaded', { logger.debug('neo-institute-context', '✅ School node loaded', {
@@ -71,7 +83,7 @@ export const NeoInstituteProvider: React.FC<{ children: ReactNode }> = ({ childr
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load school node'; const errorMessage = error instanceof Error ? error.message : 'Failed to load school node';
logger.error('neo-institute-context', '❌ Failed to load school node', { logger.warn('neo-institute-context', '⚠️ School node unavailable — workspace will load without institute data', {
error: errorMessage, error: errorMessage,
schoolDbName: profile.school_db_name schoolDbName: profile.school_db_name
}); });
+36 -34
View File
@@ -3,7 +3,6 @@ import { useAuth } from './AuthContext';
import { useUser } from './UserContext'; import { useUser } from './UserContext';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { CCUserNodeProps, CCCalendarNodeProps, CCUserTeacherTimetableNodeProps } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; 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 { CalendarStructure, WorkerStructure } from '../types/navigation';
import { useNavigationStore } from '../stores/navigationStore'; import { useNavigationStore } from '../stores/navigationStore';
@@ -131,7 +130,7 @@ const NeoUserContext = createContext<NeoUserContextType>({
}); });
export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth(); const { user, accessToken } = useAuth();
const { profile, isInitialized: isUserInitialized } = useUser(); const { profile, isInitialized: isUserInitialized } = useUser();
const navigationStore = useNavigationStore(); const navigationStore = useNavigationStore();
@@ -215,24 +214,27 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
// Set database names // Inject auth into navigation store so Supabase queries work
const userDb = profile.user_db_name || (user?.email ? if (user?.id && accessToken) {
DatabaseNameService.getStoredUserDatabase() || null : null); navigationStore.setAuthInfo(accessToken, user.id);
if (!userDb) {
throw new Error('No user database name available');
} }
// Initialize user node in profile context // Initialize user node in profile context
logger.debug('neo-user-context', '🔄 Starting context initialization'); logger.debug('neo-user-context', '🔄 Starting context initialization');
// Initialize user node // Initialize user node — race against 8s timeout so spinner never hangs
const switchTimeout = new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('switchContext timed out after 8000ms')), 8000)
)
try { try {
await navigationStore.switchContext({ await Promise.race([
navigationStore.switchContext({
main: 'profile', main: 'profile',
base: 'profile', base: 'profile',
extended: 'overview' extended: 'overview'
}, userDb, profile.school_db_name); }, null, null),
switchTimeout
]);
const userNavigationNode = navigationStore.context.node; const userNavigationNode = navigationStore.context.node;
if (userNavigationNode?.id && userNavigationNode?.data) { if (userNavigationNode?.id && userNavigationNode?.data) {
@@ -265,9 +267,9 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Continue without user node - this is not critical for basic functionality // Continue without user node - this is not critical for basic functionality
} }
// Set final state // Set final state — userDbName signals auth availability for UI guards
setUserDbName(userDb); setUserDbName(user?.id || null);
setWorkerDbName(profile.school_db_name); setWorkerDbName(null);
setIsInitialized(true); setIsInitialized(true);
setIsLoading(false); setIsLoading(false);
initializationRef.current.isComplete = true; initializationRef.current.isComplete = true;
@@ -288,13 +290,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Calendar Navigation Functions // Calendar Navigation Functions
const navigateToDay = async (id: string) => { const navigateToDay = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'day' extended: 'day'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -328,13 +330,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToWeek = async (id: string) => { const navigateToWeek = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'week' extended: 'week'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -368,13 +370,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToMonth = async (id: string) => { const navigateToMonth = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'month' extended: 'month'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -408,13 +410,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToYear = async (id: string) => { const navigateToYear = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'calendar', base: 'calendar',
extended: 'year' extended: 'year'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -449,13 +451,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
// Worker Navigation Functions // Worker Navigation Functions
const navigateToTimetable = async (id: string) => { const navigateToTimetable = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'timetable' extended: 'timetable'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -486,13 +488,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToJournal = async (id: string) => { const navigateToJournal = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'journal' extended: 'journal'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -523,13 +525,13 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToPlanner = async (id: string) => { const navigateToPlanner = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'planner' extended: 'planner'
}, userDbName, workerDbName); }, null, null);
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -560,14 +562,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToClass = async (id: string) => { const navigateToClass = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'classes' extended: 'classes'
}, userDbName, workerDbName); }, null, null);
await navigationStore.navigate(id, userDbName); await navigationStore.navigate(id, '');
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
@@ -598,14 +600,14 @@ export const NeoUserProvider: React.FC<{ children: ReactNode }> = ({ children })
}; };
const navigateToLesson = async (id: string) => { const navigateToLesson = async (id: string) => {
if (!userDbName) return; if (!user?.id) return;
setIsLoading(true); setIsLoading(true);
try { try {
await navigationStore.switchContext({ await navigationStore.switchContext({
base: 'teaching', base: 'teaching',
extended: 'lessons' extended: 'lessons'
}, userDbName, workerDbName); }, null, null);
await navigationStore.navigate(id, userDbName); await navigationStore.navigate(id, '');
const node = navigationStore.context.node; const node = navigationStore.context.node;
if (node?.data) { if (node?.data) {
+27 -128
View File
@@ -4,8 +4,6 @@ import { supabase } from '../supabaseClient';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { CCUser, CCUserMetadata } from '../services/auth/authService'; import { CCUser, CCUserMetadata } from '../services/auth/authService';
import { UserPreferences } from '../services/auth/profileService'; import { UserPreferences } from '../services/auth/profileService';
import { DatabaseNameService } from '../services/graph/databaseNameService';
import { provisionUser } from '../services/provisioningService';
import { storageService, StorageKeys } from '../services/auth/localStorageService'; import { storageService, StorageKeys } from '../services/auth/localStorageService';
export interface UserContextType { export interface UserContextType {
@@ -112,73 +110,49 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
let profileRow: Record<string, unknown> | null = null; let profileRow: Record<string, unknown> | null = null;
logger.debug('user-context', '🔧 Step 5: Querying profiles table...', { // Fast-path: build profile from auth metadata immediately (no spinner on refresh).
userId: userInfo.id const fastMetadata = userInfo.user_metadata as CCUserMetadata;
}); const fastProfile: CCUser = {
id: userInfo.id,
// Set loading state when we start the actual database query email: userInfo.email,
setLoading(true); user_type: fastMetadata?.user_type || '',
username: fastMetadata?.username || userInfo.email?.split('@')[0] || 'user',
// Query profiles table without timeout to see actual error display_name: String(fastMetadata?.display_name || ''),
logger.debug('user-context', '🔧 Step 5b: Starting profiles query...', { school_id: null,
userId: userInfo.id, created_at: userInfo.created_at,
clientType: 'authenticated' updated_at: userInfo.updated_at
}); };
if (mountedRef.current && !isInitialized) {
logger.debug('user-context', '🔧 Step 5b1: About to make profiles query...', { setProfile(fastProfile);
userId: userInfo.id, setLoading(false);
queryStarted: true setIsInitialized(true);
}); 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).
// No setLoading toggles — spinner is already cleared above.
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.select('*') .select('*')
.eq('id', userInfo.id) .eq('id', userInfo.id)
.single(); .single();
logger.debug('user-context', '🔧 Step 5b2: Direct fetch completed...', {
userId: userInfo.id,
hasData: !!data,
hasError: !!error
});
logger.debug('user-context', '🔧 Step 5c: Profiles query completed', {
hasData: !!data,
hasError: !!error,
errorCode: error?.code,
errorMessage: error?.message
});
logger.debug('user-context', '🔧 Step 5a: Profiles query result', {
hasData: !!data,
hasError: !!error,
errorCode: error?.code,
errorMessage: error?.message
});
if (error && error.code !== 'PGRST116') { if (error && error.code !== 'PGRST116') {
logger.warn('user-context', '⚠️ Profiles query failed, using fallback', { logger.warn('user-context', '⚠️ Profiles query failed, using fast-path profile', {
error: error.message, error: error.message,
code: error.code code: error.code
}); });
// Don't throw error, just use fallback profile
profileRow = null; profileRow = null;
} else if (data) { } else if (data) {
profileRow = data; profileRow = data;
logger.debug('user-context', '✅ Found profile data in database', { logger.debug('user-context', '✅ Supabase profile fetched', {
userId: data.id, userId: data.id,
userType: data.user_type,
userDbName: data.user_db_name userDbName: data.user_db_name
}); });
} else { } else {
logger.debug('user-context', '⚠️ No profile data found - will create default');
profileRow = null; profileRow = null;
} }
// Clear loading state after profiles query completes
setLoading(false);
logger.debug('user-context', '🔧 Step 5d: Loading state cleared');
logger.debug('user-context', '🔧 Step 6: Processing profile data...', { logger.debug('user-context', '🔧 Step 6: Processing profile data...', {
userId: userInfo.id, userId: userInfo.id,
hasProfileRow: !!profileRow, hasProfileRow: !!profileRow,
@@ -186,80 +160,15 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
hasSchoolDb: !!profileRow?.school_db_name hasSchoolDb: !!profileRow?.school_db_name
}); });
const metadata = userInfo.user_metadata as CCUserMetadata; 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 = { const userProfile: CCUser = {
id: userInfo.id, id: userInfo.id,
email: userInfo.email, email: userInfo.email,
user_type: metadata.user_type || '', user_type: metadata.user_type || '',
username: metadata.username || '', username: metadata.username || '',
display_name: String(metadata.display_name || ''), display_name: String(metadata.display_name || ''),
user_db_name: String(userDbName || ''), school_id: (profileRow?.school_id as string) ?? null,
school_db_name: String(schoolDbName || ''),
created_at: userInfo.created_at, created_at: userInfo.created_at,
updated_at: userInfo.updated_at updated_at: userInfo.updated_at
}; };
@@ -282,9 +191,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
logger.debug('user-context', '✅ User profile loaded', { logger.debug('user-context', '✅ User profile loaded', {
userId: userProfile.id, userId: userProfile.id,
userType: userProfile.user_type, userType: userProfile.user_type,
username: userProfile.username, username: userProfile.username
userDbName: userProfile.user_db_name,
schoolDbName: userProfile.school_db_name
}); });
setError(null); setError(null);
} catch (error) { } catch (error) {
@@ -309,22 +216,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
user_type: metadata?.user_type || 'email_teacher', user_type: metadata?.user_type || 'email_teacher',
username: metadata?.username || userInfo.email?.split('@')[0] || 'user', username: metadata?.username || userInfo.email?.split('@')[0] || 'user',
display_name: metadata?.display_name || 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_id: null,
school_db_name: '',
created_at: userInfo.created_at, created_at: userInfo.created_at,
updated_at: userInfo.updated_at updated_at: userInfo.updated_at
}; };
DatabaseNameService.rememberDatabaseNames({
userDbName: fallbackProfile.user_db_name,
schoolDbName: fallbackProfile.school_db_name
});
setProfile(fallbackProfile); setProfile(fallbackProfile);
logger.debug('user-context', '✅ Fallback profile created', { logger.debug('user-context', '✅ Fallback profile created', {
userId: fallbackProfile.id, userId: fallbackProfile.id,
userType: fallbackProfile.user_type, userType: fallbackProfile.user_type
userDbName: fallbackProfile.user_db_name
}); });
} else { } else {
setProfile(null); setProfile(null);
+2
View File
@@ -23,6 +23,7 @@ export type LogCategory =
| 'neo-institute-context' | 'neo-institute-context'
| 'auth-service' | 'auth-service'
| 'graph-service' | 'graph-service'
| 'graph-sidebar'
| 'registration-service' | 'registration-service'
| 'provisioning-service' | 'provisioning-service'
| 'snapshot-service' | 'snapshot-service'
@@ -111,6 +112,7 @@ export type LogCategory =
| 'user-context' | 'user-context'
| 'neo-user-context' | 'neo-user-context'
| 'neo-institute-context' | 'neo-institute-context'
| 'cc-live-transcription'
| 'search-service'; | 'search-service';
interface LogConfig { interface LogConfig {
+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 */ /* src/index.css */
@tailwind base; /* Tailwind removed — utility classes provided by tailwind-compat.css shim */
@tailwind components; @import './tailwind-compat.css';
@tailwind utilities;
/* Base styles */ /* Base styles */
html, html,
@@ -152,10 +151,10 @@ body {
.login-role-header { .login-role-header {
font-weight: 600 !important; font-weight: 600 !important;
color: #1976d2 !important; color: var(--cc-action-primary) !important;
margin-bottom: 24px !important; margin-bottom: 24px !important;
padding-bottom: 8px !important; padding-bottom: 8px !important;
border-bottom: 2px solid #1976d2 !important; border-bottom: 2px solid var(--cc-action-primary) !important;
width: fit-content !important; width: fit-content !important;
} }
+62 -102
View File
@@ -7,6 +7,7 @@ import {
Typography, Typography,
IconButton, IconButton,
Box, Box,
Chip,
useTheme, useTheme,
Menu, Menu,
MenuItem, MenuItem,
@@ -19,9 +20,6 @@ import Login from '@mui/icons-material/Login';
import Logout from '@mui/icons-material/Logout'; import Logout from '@mui/icons-material/Logout';
import Teacher from '@mui/icons-material/School'; import Teacher from '@mui/icons-material/School';
import Student from '@mui/icons-material/Person'; 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 Calendar from '@mui/icons-material/CalendarToday';
import TeacherPlanner from '@mui/icons-material/Assignment'; import TeacherPlanner from '@mui/icons-material/Assignment';
import ExamMarker from '@mui/icons-material/AssignmentTurnedIn'; 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 Class from '@mui/icons-material/Class';
import Book from '@mui/icons-material/Book'; import Book from '@mui/icons-material/Book';
import Enrollment from '@mui/icons-material/HowToReg'; 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 { HEADER_HEIGHT } from './Layout';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { GraphNavigator } from '../components/navigation/GraphNavigator'; import { GraphNavigator } from '../components/navigation/GraphNavigator';
const Header: React.FC = () => { const Header: React.FC = () => {
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, signOut } = useAuth(); const { user, signOut, bootstrapData } = useAuth();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [isAuthenticated, setIsAuthenticated] = useState(!!user); const [isAuthenticated, setIsAuthenticated] = useState(!!user);
const isAdmin = user?.email === import.meta.env.VITE_SUPER_ADMIN_EMAIL;
const showGraphNavigation = location.pathname === '/single-player'; 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(() => { useEffect(() => {
const newAuthState = !!user; setIsAuthenticated(!!user);
setIsAuthenticated(newAuthState); }, [user]);
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]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -129,6 +127,15 @@ const Header: React.FC = () => {
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <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 && ( {isAuthenticated && (
<Typography <Typography
variant="body2" variant="body2"
@@ -192,9 +199,9 @@ const Header: React.FC = () => {
</MenuItem>, </MenuItem>,
<Divider key="home-divider" />, <Divider key="home-divider" />,
// Timetable Section // My Work Section
<Typography <Typography
key="timetable-header" key="work-header"
variant="subtitle2" variant="subtitle2"
sx={{ sx={{
px: 2, px: 2,
@@ -206,26 +213,8 @@ const Header: React.FC = () => {
fontSize: '0.75rem' fontSize: '0.75rem'
}} }}
> >
Timetable & Classes My Work
</Typography>, </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')}> <MenuItem key="my-classes" onClick={() => handleNavigation('/my-classes')}>
<ListItemIcon> <ListItemIcon>
<Book /> <Book />
@@ -235,84 +224,59 @@ const Header: React.FC = () => {
secondary="Manage your classes" secondary="Manage your classes"
/> />
</MenuItem>, </MenuItem>,
<MenuItem key="enrollment-requests" onClick={() => handleNavigation('/enrollment-requests')}> <MenuItem key="my-lessons" onClick={() => handleNavigation('/my-lessons')}>
<ListItemIcon> <ListItemIcon>
<Enrollment /> <Lessons />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary="Enrollment Requests" primary="My Lessons"
secondary="Review pending enrollments" secondary="Weekly lesson view"
/> />
</MenuItem>, </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 // School Admin Section
...(isSchoolAdmin ? [
<Typography <Typography
key="features-header" key="school-admin-header"
variant="subtitle2" variant="subtitle2"
sx={{ sx={{
px: 2, px: 2, py: 1,
py: 1, color: theme.palette.primary.main,
color: theme.palette.text.secondary,
fontWeight: 600, fontWeight: 600,
letterSpacing: '0.5px', letterSpacing: '0.5px',
textTransform: 'uppercase', textTransform: 'uppercase',
fontSize: '0.75rem' fontSize: '0.75rem',
}} }}
> >
Features School Admin
</Typography>, </Typography>,
<MenuItem key="calendar" onClick={() => handleNavigation('/calendar')}> <MenuItem key="school-settings" onClick={() => handleNavigation('/school-settings')}>
<ListItemIcon> <ListItemIcon><SchoolSettings /></ListItemIcon>
<Calendar /> <ListItemText primary="School Settings" secondary="Calendar, overview" />
</ListItemIcon>
<ListItemText primary="Calendar" />
</MenuItem>, </MenuItem>,
<MenuItem key="teacher-planner" onClick={() => handleNavigation('/teacher-planner')}> <MenuItem key="staff-manager" onClick={() => handleNavigation('/staff-manager')}>
<ListItemIcon> <ListItemIcon><People /></ListItemIcon>
<TeacherPlanner /> <ListItemText primary="Staff Manager" secondary="Invite & manage teachers" />
</ListItemIcon>
<ListItemText primary="Teacher Planner" />
</MenuItem>, </MenuItem>,
<MenuItem key="exam-marker" onClick={() => handleNavigation('/exam-marker')}> <MenuItem key="student-manager" onClick={() => handleNavigation('/student-manager')}>
<ListItemIcon> <ListItemIcon><Teacher /></ListItemIcon>
<ExamMarker /> <ListItemText primary="Student Manager" secondary="Invite & manage students" />
</ListItemIcon>
<ListItemText primary="Exam Marker" />
</MenuItem>, </MenuItem>,
<Divider key="features-divider" />, <Divider key="school-admin-divider" />,
] : []),
// Utilities Section // Platform Admin Section
<Typography ...(isPlatformAdmin ? [
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 ? [
<Divider key="admin-divider" />, <Divider key="admin-divider" />,
<Typography <Typography
key="admin-header" key="admin-header"
@@ -330,15 +294,11 @@ const Header: React.FC = () => {
Administration Administration
</Typography>, </Typography>,
<MenuItem key="admin" onClick={() => handleNavigation('/admin')}> <MenuItem key="admin" onClick={() => handleNavigation('/admin')}>
<ListItemIcon> <ListItemIcon><Admin /></ListItemIcon>
<Admin /> <ListItemText primary="Platform Admin" secondary="Schools & system stats" />
</ListItemIcon>
<ListItemText primary="Admin Dashboard" />
</MenuItem> </MenuItem>
] : []), ] : []),
// Authentication Section
<Divider key="auth-divider" />,
<MenuItem key="signout" onClick={handleSignOut}> <MenuItem key="signout" onClick={handleSignOut}>
<ListItemIcon> <ListItemIcon>
<Logout /> <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 { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
const getLoginErrorMessage = (error: unknown): string => {
const status = (error as { status?: number } | null)?.status;
const code = (error as { code?: string } | null)?.code;
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
return 'Please confirm your email before logging in';
}
if (status === 401 || code === 'invalid_grant') {
return 'Invalid email or password';
}
return error instanceof Error ? error.message : 'Login failed';
};
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, signIn } = useAuth(); const { user, signIn, isAuthResolving } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', { logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user hasUser: !!user,
isAuthResolving
}); });
useEffect(() => { useEffect(() => {
if (user) { if (!isAuthResolving && user) {
navigate('/dashboard'); navigate('/dashboard');
} }
}, [user, navigate]); }, [user, isAuthResolving, navigate]);
const handleLogin = async (credentials: EmailCredentials) => { const handleLogin = async (credentials: EmailCredentials) => {
try { try {
setError(null); setError(null);
await signIn(credentials.email, credentials.password); await signIn(credentials.email, credentials.password);
navigate('/dashboard');
} catch (error) { } catch (error) {
logger.error('login-page', '❌ Login failed', error); logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed'); const message = getLoginErrorMessage(error);
setError(message);
throw error; throw error;
} }
}; };
if (user) { if (user && !isAuthResolving) {
return null; return null;
} }
+1 -1
View File
@@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
import { EmailCredentials } from '../../services/auth/authService'; import { EmailCredentials } from '../../services/auth/authService';
import { RegistrationService } from '../../services/auth/registrationService'; import { RegistrationService } from '../../services/auth/registrationService';
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
import MicrosoftIcon from '@mui/icons-material/Microsoft'; import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
const SignupPage: React.FC = () => { const SignupPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
+7 -2
View File
@@ -31,6 +31,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { import {
CloudUpload as UploadIcon, CloudUpload as UploadIcon,
Upload,
Folder as FolderIcon, Folder as FolderIcon,
FolderOpen as FolderOpenIcon, FolderOpen as FolderOpenIcon,
Description as FileIcon, Description as FileIcon,
@@ -39,8 +40,12 @@ import {
PlayArrow as ProcessIcon, PlayArrow as ProcessIcon,
CheckCircle as SuccessIcon, CheckCircle as SuccessIcon,
Error as ErrorIcon, Error as ErrorIcon,
Info as InfoIcon Info as InfoIcon,
InsertDriveFile,
} from '@mui/icons-material'; } from '@mui/icons-material';
const FolderOpen = FolderOpenIcon;
const Refresh = RefreshIcon;
const Info = InfoIcon;
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import { import {
pickDirectory, pickDirectory,
@@ -123,7 +128,7 @@ const SimpleUploadTest: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dirInputRef = 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 apiFetch = useCallback(async (url: string, init?: { method?: string; body?: FormData | string; headers?: Record<string, string> }) => {
const session = await supabase.auth.getSession(); 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,487 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository'
import type { AutoMapJobStatus, ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
const TOOLS = [
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const },
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const },
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const },
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const },
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const },
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const },
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
]
const PAGE_START_X = 0
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
let y = 0
return pages.map((page) => {
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
y += page.height
return geometry
})
}
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
editor.setCameraOptions({
constraints: {
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
padding: { x: 64, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
isLocked: false,
})
}
function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
return { conflict: false, message: detail ?? err.message }
}
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
}
function reviewSummary(template: ExamTemplateDetail | null) {
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
return rows.reduce((acc, row) => {
if (row.source === 'ai') acc.ai += 1
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
return acc
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
}
function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
}
function domainIdForShape(shape: ExamCanvasTLShape): string {
const fromProps = shape.props.domainId
if (isUuid(fromProps)) return fromProps
const fromShapeId = stripShapePrefix(shape.id)
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
}
function ensureDomainIds(editor: Editor) {
const updates = editor.getCurrentPageShapes()
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
.filter((shape) => !isUuid(shape.props.domainId))
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
if (updates.length) editor.updateShapes(updates)
}
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
const kind = shapeTypeToKind(shape.type)
if (!kind) return null
const s = shape as ExamCanvasTLShape
return {
id: domainIdForShape(s),
kind,
x: Number(s.x ?? 0),
y: Number(s.y ?? 0),
w: Number(s.props.w ?? 1),
h: Number(s.props.h ?? 1),
label: s.props.label,
maxMarks: s.props.maxMarks,
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
contextType: s.props.contextType,
questionId: s.props.questionId ?? null,
source: s.props.source ?? 'manual',
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
derivation: s.props.derivation ?? null,
}
}
function bringDomainShapesToFront(editor: Editor) {
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
}
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!models.length) return
editor.createShapes(models.map((m) => ({
id: createShapeId(m.id),
type: SHAPE_TYPES[m.kind],
x: m.x,
y: m.y,
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
})))
}
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!pages.length) return
const geometries = pageGeometryFromImages(pages)
editor.createShapes(geometries.map((geometry) => {
const page = pages[geometry.pageNumber - 1]
return {
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
type: PDF_PAGE_SHAPE_TYPE,
x: geometry.x,
y: geometry.y,
isLocked: true,
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
} as any
}))
// z-order is enforced by the caller via bringDomainShapesToFront
}
function seedGuide(editor: Editor) {
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
if (current.length) return
editor.createShapes([
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
])
}
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
}
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
if (!status) return 'Auto-map running'
if (status.status === 'queued') return 'Auto-map queued'
if (status.status === 'running') return 'Auto-map running'
if (status.status === 'completed') return 'Auto-map complete'
if (status.status === 'failed') return 'Auto-map failed'
return `Auto-map ${status.status}`
}
const ExamTemplateSetupInner: React.FC = () => {
const { templateId } = useParams<{ templateId: string }>()
const navigate = useNavigate()
const theme = useTheme()
const editorRef = useRef<Editor | null>(null)
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
const [autoMapBusy, setAutoMapBusy] = useState(false)
const autoMapPollRef = useRef<number | null>(null)
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
setTemplate(detail)
const editor = editorRef.current
if (editor) {
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
loadShapes(editor, shapes)
if (!shapes.length) seedGuide(editor)
bringDomainShapesToFront(editor)
}
setDirty(false)
}, [])
const review = useMemo(() => reviewSummary(template), [template])
const load = useCallback(async () => {
if (!templateId) return
setLoading(true); setError(null); setConflict(null)
try {
const detail = await examRepository.getTemplate(templateId)
setTemplate(detail)
let pages: PdfPageImage[] = []
setPdfStatus('loading')
setPdfError(null)
try {
const bytes = await examRepository.getTemplateSourcePdf(templateId)
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
const newPage = partialPages[partialPages.length - 1]
const allGeometries = pageGeometryFromImages(partialPages)
pageGeometriesRef.current = allGeometries
const ed = editorRef.current
if (ed) {
const geometry = allGeometries[partialPages.length - 1]
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
bringDomainShapesToFront(ed)
}
applyDocViewConstraints(ed, partialPages)
}
setPdfStatus('ready')
})
setPdfStatus(pages.length ? 'ready' : 'missing')
} catch (pdfErr) {
const pdfMsg = apiMessage(pdfErr).message
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
setPdfError(pdfMsg)
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
}
const geometries = pageGeometryFromImages(pages)
pageGeometriesRef.current = geometries
const editor = editorRef.current
if (editor) {
syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail, geometries))
bringDomainShapesToFront(editor)
applyDocViewConstraints(editor, pages)
editor.resetZoom()
}
setDirty(false)
} catch (e) {
const msg = apiMessage(e).message
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
setError(msg)
} finally {
setLoading(false)
}
}, [templateId])
useEffect(() => { void load() }, [load])
useEffect(() => () => {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
}, [])
const pollAutoMapStatus = useCallback(async (jobId: string) => {
if (!templateId) return
try {
const status = await examRepository.getAutoMapStatus(templateId, jobId)
setAutoMapStatus(status)
if (status.status === 'completed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
const detail = status.template ?? await examRepository.getTemplate(templateId)
applyTemplateToCanvas(detail)
return
}
if (status.status === 'failed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
return
}
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
} catch (e) {
const msg = apiMessage(e).message
setAutoMapBusy(false)
setError(msg)
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
}
}, [applyTemplateToCanvas, templateId])
const autoMapFromPdf = useCallback(async () => {
if (!templateId || autoMapBusy) return
let queued = false
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
try {
const result = await examRepository.autoMapTemplate(templateId)
if (isAutoMapAccepted(result)) {
queued = true
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
await pollAutoMapStatus(result.job_id)
return
}
setAutoMapStatus(null)
applyTemplateToCanvas(result)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
} finally {
if (!queued) setAutoMapBusy(false)
}
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
const save = useCallback(async () => {
const editor = editorRef.current
if (!editor || !templateId || !template) return
setSaving(true); setError(null); setConflict(null)
try {
ensureDomainIds(editor)
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved)
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
setDirty(false)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
} finally {
setSaving(false)
}
}, [template, templateId])
const layoutSummary = useMemo(() => {
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
}, [template?.layout])
const toolButtons = useMemo(() => TOOLS.map((tool) => (
<Tooltip title={tool.tip} key={tool.id} placement="right">
<Button
size="small"
variant={activeTool === tool.id ? 'contained' : 'outlined'}
color={tool.color}
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
onClick={() => {
const editor = editorRef.current
if (!editor) return
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
setActiveTool(tool.id)
}}
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
>
{tool.label}
</Button>
</Tooltip>
)), [activeTool])
return (
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
{/* Top bar — single compact line */}
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
<Tooltip title="Back to exam marker">
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem />
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper>
{/* Body row */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left tool sidebar */}
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
<Stack spacing={1}>{toolButtons}</Stack>
</Paper>
{/* Canvas area */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
<Tldraw
shapeUtils={examCanvasShapeUtils as any}
tools={examCanvasTools as any}
hideUi
inferDarkMode={theme.palette.mode === 'dark'}
autoFocus
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => setDirty(true), { scope: 'document' })
applyDocViewConstraints(editor, [])
editor.resetZoom()
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
// (Previously `else seedGuide` fired on mount while `template` was still null during
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
if (template) {
const s = shapesFromTemplate(template, pageGeometriesRef.current)
loadShapes(editor, s)
if (!s.length) seedGuide(editor)
}
bringDomainShapesToFront(editor)
}}
/>
</Box>
{/* Guide toggle */}
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
</IconButton>
</Tooltip>
{/* Guide panel — collapsible */}
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
</Typography>
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
</Alert>
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
const p = canvasShapePalette[kind]
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
})}
</Stack>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
</Typography>
</Paper>
</Collapse>
{/* Conflict alert */}
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
</Box>
</Box>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
</Box>
)
}
const ExamTemplateSetupPage: React.FC = () => (
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
<ExamTemplateSetupInner />
</ErrorBoundary>
)
export default ExamTemplateSetupPage
+256
View File
@@ -0,0 +1,256 @@
import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
import type { TLHandle } from '@tldraw/tldraw'
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
import type { ExamTemplateSource } from '../../../types/exam.types'
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
export const SHAPE_TYPES = {
boundary: 'exam-boundary',
part: 'exam-part',
response: 'exam-region-response',
context: 'exam-region-context',
question_number: 'exam-region-question-number',
mark_area: 'exam-region-mark-area',
reference: 'exam-region-reference',
furniture: 'exam-region-furniture',
} as const
export type ExamPdfPageTLShape = TLBaseBoxShape & {
type: typeof PDF_PAGE_SHAPE_TYPE
props: { w: number; h: number; src: string; pageNumber: number }
}
export type ExamCanvasTLShape = TLBaseBoxShape & {
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
props: {
w: number
h: number
label: string
kind: ExamCanvasShapeKind
maxMarks?: number
responseForm?: string
contextType?: string
questionId?: string | null
domainId?: string
source?: 'manual' | 'ai'
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string
}
}
type CanvasPaletteEntry = {
stroke: string
fill: string
darkStroke: string
darkFill: string
dash?: string
label: string
icon: string
role: string
}
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
`
function confidenceLabel(confidence: number | null | undefined) {
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
}
function reviewFlags(shape: ExamCanvasTLShape): string[] {
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
}
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
const bits = [base]
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
const confidence = confidenceLabel(shape.props.confidence)
if (confidence) bits.push(`confidence ${confidence}`)
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
const flags = reviewFlags(shape)
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
return bits.join(' • ')
}
function renderBoundaryLine(shape: ExamCanvasTLShape) {
const p = canvasShapePalette.boundary
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
const isAi = shape.props.source === 'ai'
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isAi ? '4 6' : p.dash} strokeLinecap="round" opacity={isAi ? 0.62 : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
</svg>
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
</HTMLContainer>
)
}
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
const isBoundary = kind === 'boundary'
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
if (isBoundary) return renderBoundaryLine(shape)
const isAi = shape.props.source === 'ai'
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
const title = provenanceTitle(shape, `${p.label}: ${p.role}`)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<div
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
style={{
'--exam-light-stroke': p.stroke,
'--exam-light-fill': p.fill,
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: isAi ? 'dashed' : p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', opacity: isAi ? 0.72 : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
} as React.CSSProperties}
aria-label={title}
title={title}
>
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span aria-hidden="true">{isAi ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{!confidence && !isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
</div>
</HTMLContainer>
)
}
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = canvasShapePalette[kind]
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
}
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) }
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
static override type = PDF_PAGE_SHAPE_TYPE
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
override canEdit() { return false }
override component(shape: ExamPdfPageTLShape) {
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
</HTMLContainer>
)
}
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
}
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
static override type = SHAPE_TYPES.boundary
static override props = sharedProps
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
override canEdit() { return false }
override canResize() { return false }
override canBind() { return false }
override hideResizeHandles() { return true }
override hideRotateHandle() { return true }
override hideSelectionBoundsBg() { return true }
private pageSpanForY(y: number) {
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
if (!best) return page
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
return pageDy < bestDy ? page : best
}, null)
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
}
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
}
override getGeometry(shape: ExamCanvasTLShape) {
const y = shape.props.h / 2
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
}
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
}
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
return this.normalize({ ...current, x: initial.x })
}
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
}
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
}
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
export function isPdfPageShape(type: string): boolean {
return type === PDF_PAGE_SHAPE_TYPE
}
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
}
+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
}
+391 -274
View File
@@ -1,323 +1,440 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { AccessTime, Add, ArrowBack, CalendarToday, Delete, Edit, MenuBook, People } from '@mui/icons-material'; import {
import useTimetableStore from '../../stores/timetableStore'; Box, Typography, Button, CircularProgress, Alert, Chip, Divider,
import { useUser } from '../../contexts/UserContext'; Dialog, DialogTitle, DialogContent, DialogActions, TextField,
import Modal from '../../components/common/Modal'; 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 ClassDetailPage: React.FC = () => {
const { classId } = useParams<{ classId: string }>(); const { classId } = useParams<{ classId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { profile } = useUser(); const { accessToken, user, bootstrapData } = useAuth();
const {
currentClass,
timetables,
enrolledStudents,
classTeachers,
classDetailLoading,
classDetailError,
fetchClassDetail,
deleteClass,
clearCurrentClass,
} = useTimetableStore();
const [activeTab, setActiveTab] = useState<'timetables' | 'students' | 'teachers'>('timetables'); const [cls, setCls] = useState<ClassDetail | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false); 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(() => { const load = useCallback(async () => {
if (classId) { if (!accessToken || !classId) return;
fetchClassDetail(classId); 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,
});
} }
return () => { else setError(clsRes.detail || 'Class not found');
clearCurrentClass(); const role = bootstrapData?.active_institute?.membership_role || '';
}; setIsAdmin(role === 'school_admin' || role === 'department_head');
}, [classId, fetchClassDetail, clearCurrentClass]); } catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [accessToken, classId, bootstrapData]);
const handleDeleteClass = async () => { useEffect(() => { load(); }, [load]);
if (!classId) return;
await deleteClass(classId); const apiPost = async (path: string, body?: object) => {
navigate('/timetable/classes'); 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();
}; };
const isOwner = currentClass?.created_by === profile?.id; const apiDelete = async (path: string) => {
const isTeacher = classTeachers.some(t => t.teacher_id === profile?.id && t.is_primary); await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
};
if (classDetailLoading) { 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 ( return (
<div className="flex justify-center items-center h-64"> <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <CircularProgress />
</div> </Box>
); );
} }
if (classDetailError || !currentClass) { if (error || !cls) {
return ( return (
<div className="container mx-auto px-4 py-6"> <Box sx={{ p: 3 }}>
<div className="bg-red-50 border border-red-200 rounded-xl p-6"> <Alert severity="error">{error || 'Class not found'}</Alert>
<h2 className="text-lg font-semibold text-red-800 mb-2">Error Loading Class</h2> <Button sx={{ mt: 2 }} startIcon={<ArrowBack />} onClick={() => navigate('/classes')}>
<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 Back to Classes
</Link> </Button>
</div> </Box>
</div>
); );
} }
const existingStudentIds = new Set(cls.students.map(s => s.student_id));
const pendingCount = cls.enrollment_requests.length;
return ( return (
<div className="container mx-auto px-4 py-6 max-w-6xl"> <Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
{/* Header */} {/* Header */}
<div className="mb-6"> <Button
<Link size="small"
to="/timetable/classes" startIcon={<ArrowBack />}
className="inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 mb-4" onClick={() => navigate('/classes')}
sx={{ mb: 2 }}
> >
<ArrowBack size={18} />
Back to Classes Back to Classes
</Link> </Button>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4"> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 3 }}>
<div> <Box>
<div className="flex items-center gap-3 mb-2"> <Typography variant="h5" fontWeight={700}>{cls.name}</Typography>
<h1 className="text-3xl font-bold text-gray-900">{currentClass.name}</h1> <Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
<span className="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full"> {cls.class_code && <Chip label={cls.class_code} size="small" />}
{currentClass.subject} {cls.subject && <Chip label={cls.subject} size="small" variant="outlined" />}
</span> {cls.year_group && <Chip label={`Y${cls.year_group}`} size="small" variant="outlined" />}
</div> <Chip
<p className="text-gray-500"> label={cls.is_active ? 'Active' : 'Inactive'}
{currentClass.school_year} {currentClass.academic_term} size="small"
</p> color={cls.is_active ? 'success' : 'default'}
</div> />
</Box>
{(isOwner || isTeacher) && ( {cls.description && (
<div className="flex items-center gap-2"> <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
<Link {cls.description}
to={`/timetable/classes/${classId}/edit`} </Typography>
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> </Box>
</div> <School sx={{ color: 'primary.main', fontSize: 40, opacity: 0.3 }} />
</Box>
{/* Stats */} {actionError && (
<div className="grid grid-cols-3 gap-4 mb-8"> <Alert severity="error" onClose={() => setActionError(null)} sx={{ mb: 2 }}>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200"> {actionError}
<div className="flex items-center gap-3"> </Alert>
<div className="p-2 bg-blue-50 rounded-lg"> )}
<CalendarToday className="text-blue-600" size={20} />
</div> <ResultsWidget classId={cls.id} className={cls.name} />
<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 */} {/* Tabs */}
<div className="border-b border-gray-200 mb-6"> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<div className="flex gap-6"> <Tab label={`Students (${cls.student_count})`} />
<button <Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
onClick={() => setActiveTab('timetables')} <Tab label={`Teachers (${cls.teachers.length})`} />
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${ </Tabs>
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 */} {/* Students tab */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> {tab === 0 && (
{activeTab === 'timetables' && ( <Box>
<div> {isAdmin && (
<div className="flex items-center justify-between mb-4"> <Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
<h2 className="text-lg font-semibold text-gray-900">Timetables</h2> <Button
{(isOwner || isTeacher) && ( variant="contained"
<Link size="small"
to={`/timetable/classes/${classId}/timetables/new`} startIcon={<PersonAdd />}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" onClick={() => setAddOpen(true)}
> >
<Add size={18} /> Add Student
Add Timetable </Button>
</Link> </Box>
)} )}
</div> {cls.students.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
{timetables.length === 0 ? ( No students enrolled yet
<div className="text-center py-12 text-gray-500"> </Typography>
<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"> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{timetables.map((timetable) => ( {cls.students.map(s => (
<Link <Box
key={timetable.id} key={s.student_id}
to={`/timetable/timetables/${timetable.id}`} sx={{
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors" display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1,
}}
> >
<div> <Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'primary.light' }}>
<h3 className="font-medium text-gray-900">{timetable.name}</h3> {initials(s.profile?.full_name || '?')}
<p className="text-sm text-gray-500"> </Avatar>
{timetable.lesson_count} lessons <Box sx={{ flex: 1 }}>
{timetable.is_recurring && ' • Recurring'} <Typography variant="body2" fontWeight={600}>
</p> {s.profile?.full_name || s.student_id}
</div> </Typography>
<ArrowBack className="rotate-180 text-gray-400" size={18} /> <Typography variant="caption" color="text.secondary">
</Link> {s.profile?.email}
))} </Typography>
</div> </Box>
{isAdmin && (
<Tooltip title="Remove student">
<IconButton
size="small"
color="error"
onClick={() => handleRemoveStudent(s.student_id)}
>
<PersonRemove fontSize="small" />
</IconButton>
</Tooltip>
)} )}
</div> </Box>
))}
</Box>
)}
</Box>
)} )}
{activeTab === 'students' && ( {/* Enrollment requests tab */}
<div> {tab === 1 && (
<h2 className="text-lg font-semibold text-gray-900 mb-4">Enrolled Students</h2> <Box>
{enrolledStudents.length === 0 ? ( {cls.enrollment_requests.length === 0 ? (
<div className="text-center py-12 text-gray-500"> <Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
<People size={48} className="mx-auto mb-4 opacity-50" /> No pending enrollment requests
<p className="text-lg font-medium mb-2">No students enrolled</p> </Typography>
<p>Students can request enrollment or be added by teachers</p>
</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{enrolledStudents.map((student) => ( {cls.enrollment_requests.map(req => (
<div <Box
key={student.user_id} key={req.id}
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg" sx={{
display: 'flex', alignItems: 'center', gap: 1.5,
p: 1.5, border: '1px solid', borderColor: 'warning.light',
borderRadius: 1, bgcolor: 'warning.50',
}}
> >
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center"> <Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'warning.light' }}>
<span className="text-gray-600 font-medium"> {initials(req.profile?.full_name || '?')}
{student.full_name.charAt(0)} </Avatar>
</span> <Box sx={{ flex: 1 }}>
</div> <Typography variant="body2" fontWeight={600}>
<div> {req.profile?.full_name || req.student_id}
<p className="font-medium text-gray-900">{student.full_name}</p> </Typography>
<p className="text-sm text-gray-500">{student.email}</p> <Typography variant="caption" color="text.secondary">
</div> {req.profile?.email} · requested{' '}
</div> {new Date(req.created_at).toLocaleDateString('en-GB')}
))} </Typography>
</div> </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>
)} )}
</div> </Box>
))}
</Box>
)}
</Box>
)} )}
{activeTab === 'teachers' && ( {/* Teachers tab */}
<div> {tab === 2 && (
<h2 className="text-lg font-semibold text-gray-900 mb-4">Teachers</h2> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> {cls.teachers.length === 0 ? (
{classTeachers.map((teacher) => ( <Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
<div No teachers assigned
key={teacher.teacher_id} </Typography>
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg" ) : (
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,
}}
> >
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"> <Avatar sx={{ width: 36, height: 36, fontSize: '0.85rem', bgcolor: 'secondary.light' }}>
<span className="text-blue-600 font-medium"> {initials(t.profile?.full_name || '?')}
{teacher.full_name.charAt(0)} </Avatar>
</span> <Box sx={{ flex: 1 }}>
</div> <Typography variant="body2" fontWeight={600}>
<div className="flex-1"> {t.profile?.full_name || t.teacher_id}
<p className="font-medium text-gray-900">{teacher.full_name}</p> </Typography>
<p className="text-sm text-gray-500">{teacher.email}</p> <Typography variant="caption" color="text.secondary">
</div> {t.profile?.email}
{teacher.is_primary && ( </Typography>
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded-full"> </Box>
Primary {t.is_primary && (
</span> <Chip label="Primary" size="small" color="primary" />
)} )}
</div> </Box>
))} ))
</div> )}
</div> </Box>
)} )}
</div>
{/* Delete Modal */} <AddStudentDialog
<Modal open={addOpen}
isOpen={showDeleteModal} onClose={() => setAddOpen(false)}
onClose={() => setShowDeleteModal(false)} onAdd={handleAddStudent}
title="Delete Class" accessToken={accessToken || ''}
> existingIds={existingStudentIds}
<div className="p-6"> />
<p className="text-gray-600 mb-6"> </Box>
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"
>
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>
); );
}; };
@@ -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 { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore'; import useTimetableStore from '../../stores/timetableStore';
import { useUser } from '../../contexts/UserContext'; import { useUser } from '../../contexts/UserContext';
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
import { CircularProgress } from '@mui/material';
import { logger } from '../../debugConfig';
const MyClassesSkeleton = () => (
<div className="flex justify-center items-center h-64">
<CircularProgress />
</div>
);
const MyClassesPage: React.FC = () => { const MyClassesPage: React.FC = () => {
const { profile } = useUser(); const { profile } = useUser();
const { selectedInstituteId } = useNeoInstitute();
const { const {
myClasses, myClasses,
myClassesLoading, classesLoading: myClassesLoading,
myClassesError, classesError: myClassesError,
fetchMyClasses, fetchMyClasses,
} = useTimetableStore(); } = useTimetableStore();
useEffect(() => { useEffect(() => {
fetchMyClasses(); fetchMyClasses().catch((error) =>
}, [fetchMyClasses]); logger.warn('my-classes', 'Fetch my classes failed', {
message: error instanceof Error ? error.message : String(error),
})
);
}, [fetchMyClasses, selectedInstituteId]);
if (myClassesLoading) {
return <MyClassesSkeleton />;
}
const getRoleLabel = (role: string) => { const getRoleLabel = (role: string) => {
switch (role) { switch (role) {
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
} }
}; };
if (myClassesLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (myClassesError) { if (myClassesError) {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -88,25 +98,25 @@ const MyClassesPage: React.FC = () => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teachingClasses.map((classItem) => ( {teachingClasses.map((classItem) => (
<Link <Link
key={classItem.class_id} key={classItem.id}
to={`/timetable/classes/${classItem.class_id}`} to={`/classes/${classItem.id}`}
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200" className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
> >
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate"> <h3 className="text-lg font-semibold text-gray-900 truncate">
{classItem.class?.name} {classItem.name}
</h3> </h3>
{classItem.class?.code && ( {classItem.class_code && (
<p className="text-sm text-gray-500">{classItem.class.code}</p> <p className="text-sm text-gray-500">{classItem.class_code}</p>
)} )}
</div> </div>
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} /> <KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
</div> </div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2"> <p className="text-gray-600 text-sm mb-4 line-clamp-2">
{classItem.class?.description || 'No description'} {classItem.description || 'No description'}
</p> </p>
<div className="flex items-center gap-2 flex-wrap"> <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="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"> <div className="flex items-center gap-1">
<People size={14} /> <People size={14} />
<span>{classItem.class?.enrolled_count || 0} students</span> <span>{(classItem as any).enrolled_count || 0} students</span>
</div> </div>
{classItem.class?.academic_year && ( {classItem.academic_year && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<School size={14} /> <School size={14} />
<span>{classItem.class.academic_year}</span> <span>{classItem.academic_year}</span>
</div> </div>
)} )}
</div> </div>
@@ -151,25 +161,25 @@ const MyClassesPage: React.FC = () => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{enrolledClasses.map((classItem) => ( {enrolledClasses.map((classItem) => (
<Link <Link
key={classItem.class_id} key={classItem.id}
to={`/timetable/classes/${classItem.class_id}`} to={`/classes/${classItem.id}`}
className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200" className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200"
> >
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate"> <h3 className="text-lg font-semibold text-gray-900 truncate">
{classItem.class?.name} {classItem.name}
</h3> </h3>
{classItem.class?.code && ( {classItem.class_code && (
<p className="text-sm text-gray-500">{classItem.class.code}</p> <p className="text-sm text-gray-500">{classItem.class_code}</p>
)} )}
</div> </div>
<KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} /> <KeyboardArrowRight className="text-gray-400 flex-shrink-0 ml-2" size={20} />
</div> </div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2"> <p className="text-gray-600 text-sm mb-4 line-clamp-2">
{classItem.class?.description || 'No description'} {classItem.description || 'No description'}
</p> </p>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -182,13 +192,13 @@ const MyClassesPage: React.FC = () => {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<School size={14} /> <School size={14} />
<span> <span>
{classItem.class?.teachers?.[0]?.first_name} {classItem.class?.teachers?.[0]?.last_name} {(classItem as any).primary_teacher_name} {}
</span> </span>
</div> </div>
{classItem.class?.academic_year && ( {classItem.academic_year && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<School size={14} /> <School size={14} />
<span>{classItem.class.academic_year}</span> <span>{classItem.academic_year}</span>
</div> </div>
)} )}
</div> </div>
@@ -208,7 +218,7 @@ const MyClassesPage: React.FC = () => {
You are not enrolled in or teaching any classes yet. You are not enrolled in or teaching any classes yet.
</p> </p>
<Link <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" 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 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 * 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 TimetablePage } from './TimetablePage';
export { default as TimetableListPage } from './TimetableListPage';
export { default as ClassesPage } from './ClassesPage'; export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage'; export { default as LessonPage } from './LessonPage';
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
// Class management pages
export { default as MyClassesPage } from './MyClassesPage'; export { default as MyClassesPage } from './MyClassesPage';
export { default as EnrollmentRequestsPage } from './EnrollmentRequestsPage'; 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 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 React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select'; import { SelectChangeEvent } from '@mui/material/Select';
import { supabase } from '../../../supabaseClient'; import { useAuth } from '../../../contexts/AuthContext';
type Manifest = { type Manifest = {
bucket: string; bucket: string;
@@ -41,6 +41,7 @@ export const CCBundleViewer: React.FC<{
currentPage?: number; currentPage?: number;
combinedBundles?: Array<{ id: string }>; combinedBundles?: Array<{ id: string }>;
}> = ({ fileId, bundleId, currentPage, combinedBundles }) => { }> = ({ fileId, bundleId, currentPage, combinedBundles }) => {
const { accessToken } = useAuth();
const [manifest, setManifest] = useState<Manifest | null>(null); const [manifest, setManifest] = useState<Manifest | null>(null);
const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null); const [combinedManifests, setCombinedManifests] = useState<Manifest[] | null>(null);
const [mode, setMode] = useState<Mode>('markdown_full'); const [mode, setMode] = useState<Mode>('markdown_full');
@@ -49,13 +50,13 @@ export const CCBundleViewer: React.FC<{
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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 = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []);
const API_BASE_FALLBACK = 'http://127.0.0.1:8080'; const API_BASE_FALLBACK = '/api';
const proxyUrl = useCallback(async (bucket: string, relPath: string) => { 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)}`; 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) => { const replaceAllSafe = useCallback((s: string, search: string, replacement: string) => {
if (!s || typeof s !== 'string') return s || ''; if (!s || typeof s !== 'string') return s || '';
@@ -222,7 +223,7 @@ export const CCBundleViewer: React.FC<{
setManifest(null); setManifest(null);
if (combinedBundles && combinedBundles.length > 0) { if (combinedBundles && combinedBundles.length > 0) {
try { try {
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
const ms: Manifest[] = []; const ms: Manifest[] = [];
for (const b of combinedBundles) { for (const b of combinedBundles) {
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(b.id)}/manifest`, { headers: { Authorization: `Bearer ${token}` } }); 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; if (!bundleId) return;
try { 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}` } }); 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()); if (!res.ok) throw new Error(await res.text());
const rawManifest: Manifest = await res.json(); const rawManifest: Manifest = await res.json();
@@ -266,7 +267,7 @@ export const CCBundleViewer: React.FC<{
let textParts: string[] = []; let textParts: string[] = [];
let jsonParts: string[] = []; let jsonParts: string[] = [];
for (const m of combinedManifests) { for (const m of combinedManifests) {
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
let rel: string | undefined; let rel: string | undefined;
if (mode === 'markdown_full') rel = m.markdown_full || m.html_full || m.text_full || m.json_full; 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; 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; relPath = rec?.path;
} }
if (!relPath) { setContent(''); setLoading(false); return; } 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); const url = await proxyUrl(bucket, relPath);
let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(await res.text()); 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 { Box, CircularProgress, IconButton } from '@mui/material';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; 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 }; type Artefact = { id: string; type: string; rel_path: string; created_at: string };
@@ -43,6 +43,7 @@ export const CCDoclingViewer: React.FC<{
hideToolbar?: boolean; hideToolbar?: boolean;
sectionRange?: { start: number; end: number }; sectionRange?: { start: number; end: number };
}> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => { }> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => {
const { accessToken } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]); const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
@@ -181,10 +182,10 @@ export const CCDoclingViewer: React.FC<{
setError(null); setError(null);
try { try {
// Try page-images manifest first // 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 { try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { 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) { if (mRes.ok) {
const m: PageImagesManifest = await mRes.json(); 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 // Legacy: Load artefacts for file to find docling JSON artefacts
const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, { const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || '/api'}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` } headers: { 'Authorization': `Bearer ${accessToken || ''}` }
}); });
if (!artefactsRes.ok) throw new Error(await artefactsRes.text()); if (!artefactsRes.ok) throw new Error(await artefactsRes.text());
const artefacts: Artefact[] = await artefactsRes.json(); 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 // 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`, { 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()); if (!jsonRes.ok) throw new Error(await jsonRes.text());
const doc: DoclingJson = await jsonRes.json(); const doc: DoclingJson = await jsonRes.json();
@@ -238,7 +239,7 @@ export const CCDoclingViewer: React.FC<{
run(); run();
}, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */ }, [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(() => { const pageProxyUrl = useMemo(() => {
if (!manifest) return undefined; if (!manifest) return undefined;
@@ -267,7 +268,7 @@ export const CCDoclingViewer: React.FC<{
setPageObjectUrl(cached); setPageObjectUrl(cached);
return; return;
} }
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } }); let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok && manifest) { if (!resp.ok && manifest) {
// Fallback to thumbnail if the full image is not accessible yet // Fallback to thumbnail if the full image is not accessible yet
@@ -369,6 +370,7 @@ export const CCDoclingViewer: React.FC<{
export default CCDoclingViewer; export default CCDoclingViewer;
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => { const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
const { accessToken } = useAuth();
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined); const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); 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; let revoked: string | null = null;
const load = async () => { const load = async () => {
try { try {
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob(); const blob = await resp.blob();
@@ -6,7 +6,7 @@ import { SelectChangeEvent } from '@mui/material/Select';
import { CCDoclingViewer } from './CCDoclingViewer.tsx'; import { CCDoclingViewer } from './CCDoclingViewer.tsx';
import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx'; import CCEnhancedFilePanel from './CCEnhancedFilePanel.tsx';
import CCBundleViewer from './CCBundleViewer.tsx'; import CCBundleViewer from './CCBundleViewer.tsx';
import { supabase } from '../../../supabaseClient'; import { useAuth } from '../../../contexts/AuthContext';
type CanonicalDoclingConfig = { type CanonicalDoclingConfig = {
pipeline: 'standard' | 'vlm' | 'asr'; pipeline: 'standard' | 'vlm' | 'asr';
@@ -46,6 +46,7 @@ export const CCDocumentIntelligence: React.FC = () => {
const { fileId } = useParams<{ fileId: string }>(); const { fileId } = useParams<{ fileId: string }>();
const validFileId = useMemo(() => fileId || '', [fileId]); const validFileId = useMemo(() => fileId || '', [fileId]);
const { accessToken } = useAuth();
const [page, setPage] = useState<number>(1); const [page, setPage] = useState<number>(1);
const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]); const [outlineOptions, setOutlineOptions] = useState<Array<{ id: string; title: string; start_page: number; end_page: number }>>([]);
const [profile, setProfile] = useState<Profile>('default'); const [profile, setProfile] = useState<Profile>('default');
@@ -147,8 +148,8 @@ export const CCDocumentIntelligence: React.FC = () => {
useEffect(() => { useEffect(() => {
const loadBundles = async () => { const loadBundles = async () => {
if (!validFileId) return; if (!validFileId) return;
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } }); const res = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return; if (!res.ok) return;
const arts: Artefact[] = await res.json(); const arts: Artefact[] = await res.json();
@@ -222,17 +223,17 @@ export const CCDocumentIntelligence: React.FC = () => {
const run = async () => { const run = async () => {
if (!validFileId) return; if (!validFileId) return;
setOutlineOptions([]); 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 { try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts`, { 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; if (!artsRes.ok) return;
const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json(); const arts: Array<{ id: string; type: string; rel_path?: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (!outlineArt) return; if (!outlineArt) return;
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { 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; if (!jsonRes.ok) return;
const doc = await jsonRes.json(); const doc = await jsonRes.json();
@@ -242,7 +243,7 @@ export const CCDocumentIntelligence: React.FC = () => {
const splitArt = arts.find(a => a.type === 'split_map_json'); const splitArt = arts.find(a => a.type === 'split_map_json');
if (splitArt) { if (splitArt) {
const smRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(validFileId)}/artefacts/${encodeURIComponent(splitArt.id)}/json`, { 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) { if (smRes.ok) {
const sm = await smRes.json(); const sm = await smRes.json();
@@ -485,8 +486,8 @@ export const CCDocumentIntelligence: React.FC = () => {
<Button variant="contained" disabled={busy || !validFileId} onClick={async () => { <Button variant="contained" disabled={busy || !validFileId} onClick={async () => {
try { try {
setBusy(true); setBusy(true);
const API_BASE = import.meta.env.VITE_API_BASE || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api'); const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
const body: CanonicalDoclingRequest = { const body: CanonicalDoclingRequest = {
use_split_map: selectedSectionId === 'full' ? autoSplit : false, use_split_map: selectedSectionId === 'full' ? autoSplit : false,
config: { config: {
@@ -11,7 +11,8 @@ import Schedule from '@mui/icons-material/Schedule';
import Visibility from '@mui/icons-material/Visibility'; import Visibility from '@mui/icons-material/Visibility';
import Psychology from '@mui/icons-material/Psychology'; import Psychology from '@mui/icons-material/Psychology';
import Overview from '@mui/icons-material/Home'; import Overview from '@mui/icons-material/Home';
import { supabase } from '../../../supabaseClient'; import CalendarViewMonth from '@mui/icons-material/CalendarViewMonth';
import { useAuth } from '../../../contexts/AuthContext';
// Types // Types
type PageImagesManifest = { type PageImagesManifest = {
@@ -66,6 +67,7 @@ export const CCEnhancedFilePanel: React.FC<CCEnhancedFilePanelProps> = ({
fileId, selectedPage, onSelectPage, currentSection fileId, selectedPage, onSelectPage, currentSection
}) => { }) => {
// State // State
const { accessToken } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | 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 thumbnailsRef = useRef<HTMLDivElement>(null);
const API_BASE = useMemo(() => 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); setError(null);
try { try {
const token = (await supabase.auth.getSession()).data.session?.access_token || ''; const token = accessToken || '';
// Load page images manifest // Load page images manifest
const manifestRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/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 { try {
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pageInfo.thumbnail_path)}`; 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}` } }); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!response.ok) return undefined; if (!response.ok) return undefined;
@@ -5,7 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import { supabase } from '../../../supabaseClient'; import { useAuth } from '../../../contexts/AuthContext';
type PageImagesManifest = { type PageImagesManifest = {
version: number; version: number;
@@ -54,6 +54,7 @@ export const CCFileDetailPanel: React.FC<{
selectedPage: number; selectedPage: number;
onSelectPage: (p: number) => void; onSelectPage: (p: number) => void;
}> = ({ fileId, selectedPage, onSelectPage }) => { }> = ({ fileId, selectedPage, onSelectPage }) => {
const { accessToken } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null); const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
@@ -64,7 +65,7 @@ export const CCFileDetailPanel: React.FC<{
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [adminData, setAdminData] = useState<FileTasksResponse | null>(null); 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(() => { useEffect(() => {
const run = async () => { const run = async () => {
@@ -73,7 +74,7 @@ export const CCFileDetailPanel: React.FC<{
setError(null); setError(null);
try { try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { 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()); if (!mRes.ok) throw new Error(await mRes.text());
const m: PageImagesManifest = await mRes.json(); 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 to load outline structure artefact (for grouping only)
try { try {
const artsRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts`, { 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) { if (artsRes.ok) {
const arts: Array<{ id: string; type: string }> = await artsRes.json(); const arts: Array<{ id: string; type: string }> = await artsRes.json();
const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy'); const outlineArt = arts.find(a => a.type === 'document_outline_hierarchy');
if (outlineArt) { if (outlineArt) {
const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(outlineArt.id)}/json`, { 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) { if (jsonRes.ok) {
const outJson = await jsonRes.json(); const outJson = await jsonRes.json();
@@ -118,7 +119,7 @@ export const CCFileDetailPanel: React.FC<{
const pg = manifest.page_images[idx]; const pg = manifest.page_images[idx];
if (!pg) return undefined; if (!pg) return undefined;
const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`; 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}` } }); const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!resp.ok) return undefined; if (!resp.ok) return undefined;
const blob = await resp.blob(); const blob = await resp.blob();
@@ -150,7 +151,7 @@ export const CCFileDetailPanel: React.FC<{
<IconButton size="small" onClick={async () => { <IconButton size="small" onClick={async () => {
try { try {
setShowAdmin(true); 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 res = await fetch(`${API_BASE}/queue/queue/tasks/by-file/${encodeURIComponent(fileId)}`, { headers: { Authorization: `Bearer ${token}` } });
const data = await res.json(); const data = await res.json();
setAdminData(data); 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[];
}
+145 -66
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 { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Tldraw, Tldraw,
@@ -26,39 +26,117 @@ import { allBindingUtils } from '../../utils/tldraw/bindings';
import { multiplayerEmbeds } from '../../utils/tldraw/embeds'; import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
// Layout // Layout
import { HEADER_HEIGHT } from '../../pages/Layout'; import { HEADER_HEIGHT } from '../../pages/Layout';
import { TLSYNC_URL } from '../../config/apiConfig';
// Styles // Styles
import '../../utils/tldraw/tldraw.css'; import '../../utils/tldraw/tldraw.css';
// App debug // App debug
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http') const SYNC_WORKER_URL = TLSYNC_URL;
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
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 { user } = useAuth();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const { const {
tldrawPreferences, tldrawPreferences,
setTldrawPreferences, setTldrawPreferences,
initializePreferences, initializePreferences,
presentationMode presentationMode,
} = useTLDraw(); } = useTLDraw();
const navigate = useNavigate(); const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const [searchParams] = useSearchParams();
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
// Get room ID from URL params const userInfo = useMemo(
const roomId = searchParams.get('room') || 'multiplayer'; () => ({
// Memoize user information to ensure consistency
const userInfo = useMemo(() => ({
id: user?.id ?? '', id: user?.id ?? '',
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User', name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)` color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`,
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]); }),
[user?.id, user?.display_name, user?.email, tldrawPreferences?.color],
);
// Create editor user with memoization
const editorUser = useTldrawUser({ const editorUser = useTldrawUser({
userPreferences: { userPreferences: {
id: userInfo.id, id: userInfo.id,
@@ -67,18 +145,23 @@ export default function TldrawMultiUser() {
locale: tldrawPreferences?.locale, locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme, colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed, animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode isSnapMode: tldrawPreferences?.isSnapMode,
}, },
setUserPreferences: setTldrawPreferences setUserPreferences: setTldrawPreferences,
}); });
const connectionOptions = useMemo(() => createSyncConnectionOptions({ const connectionOptions = useMemo(
() =>
createSyncConnectionOptions({
userId: userInfo.id, userId: userInfo.id,
displayName: userInfo.name, displayName: userInfo.name,
color: userInfo.color, color: userInfo.color,
roomId, roomId,
baseUrl: SYNC_WORKER_URL baseUrl: SYNC_WORKER_URL,
}), [userInfo, roomId]); token: tlsyncToken,
}),
[userInfo, roomId, tlsyncToken],
);
const store = useSync({ const store = useSync({
...connectionOptions, ...connectionOptions,
@@ -88,19 +171,17 @@ export default function TldrawMultiUser() {
userInfo: { userInfo: {
id: userInfo.id, id: userInfo.id,
name: userInfo.name, name: userInfo.name,
color: userInfo.color color: userInfo.color,
} },
}); });
// Log connection status changes
useEffect(() => { useEffect(() => {
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, { logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
status: store.status, status: store.status,
connectionOptions roomId: connectionOptions.roomId,
}); });
}, [store.status, connectionOptions]); }, [store.status, connectionOptions.roomId]);
// Effect for initializing preferences
useEffect(() => { useEffect(() => {
if (user?.id && !tldrawPreferences) { if (user?.id && !tldrawPreferences) {
logger.info('multiplayer-page', '🔄 Initializing preferences'); logger.info('multiplayer-page', '🔄 Initializing preferences');
@@ -108,20 +189,11 @@ export default function TldrawMultiUser() {
} }
}, [user?.id, tldrawPreferences, initializePreferences]); }, [user?.id, tldrawPreferences, initializePreferences]);
// Effect for redirecting if user is not authenticated
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
// Effect for presentation mode
useEffect(() => { useEffect(() => {
if (presentationMode && editorRef.current) { if (presentationMode && editorRef.current) {
const editor = editorRef.current; const editor = editorRef.current;
const presentationService = new PresentationService(editor); const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode(); const cleanup = presentationService.startPresentationMode();
return () => { return () => {
presentationService.stopPresentationMode(); presentationService.stopPresentationMode();
cleanup(); cleanup();
@@ -129,47 +201,24 @@ export default function TldrawMultiUser() {
} }
}, [presentationMode]); }, [presentationMode]);
// Memoize UI overrides and components
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]); const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
const uiComponents = useMemo(() => getUiComponents(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) { if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
return ( return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
<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 ( return (
<div style={{ <div
style={{
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
top: `${HEADER_HEIGHT}px`, top: `${HEADER_HEIGHT}px`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden' overflow: 'hidden',
}}> }}
>
<Tldraw <Tldraw
user={editorUser} user={editorUser}
store={store.store} store={store.store}
@@ -198,3 +247,33 @@ export default function TldrawMultiUser() {
</div> </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} />;
}
+94 -321
View File
@@ -7,15 +7,14 @@ import {
DEFAULT_SUPPORT_VIDEO_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore, TLStore,
TLStoreWithStatus createTLStore,
} from '@tldraw/tldraw'; } from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext'; import { useTLDraw } from '../../contexts/TLDrawContext';
import { useAuth } from '../../contexts/AuthContext';
import { useUser } from '../../contexts/UserContext'; import { useUser } from '../../contexts/UserContext';
// Tldraw services // Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService'; import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService'; import { PresentationService } from '../../services/tldraw/presentationService';
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService'; import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
// Tldraw utils // Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides'; import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
// App debug // App debug
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material'; import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles'; import { ErrorBoundary } from '../../components/ErrorBoundary';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
interface LoadingState { interface LoadingState {
status: 'ready' | 'loading' | 'error'; status: 'ready' | 'loading' | 'error';
@@ -45,7 +42,9 @@ interface LoadingState {
export default function SinglePlayerPage() { export default function SinglePlayerPage() {
// Context hooks with initialization states // Context hooks with initialization states
const { user, loading: userLoading } = useUser(); const { profile: user, loading: userLoading } = useUser();
const { accessToken } = useAuth();
const { context, setAuthInfo, switchContext } = useNavigationStore();
const { const {
tldrawPreferences, tldrawPreferences,
initializePreferences, initializePreferences,
@@ -55,8 +54,6 @@ export default function SinglePlayerPage() {
const routerNavigate = useNavigate(); const routerNavigate = useNavigate();
const location = useLocation(); const location = useLocation();
// Navigation store
const { context } = useNavigationStore();
// Refs // Refs
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
@@ -67,9 +64,8 @@ export default function SinglePlayerPage() {
status: 'ready', status: 'ready',
error: '' error: ''
}); });
const [isInitialLoad, setIsInitialLoad] = useState(true); const storeRef = useRef<TLStore | null>(null);
const [isEditorReady, setIsEditorReady] = useState(false); const [storeReady, setStoreReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
// TLDraw user preferences // TLDraw user preferences
const tldrawUser = useTldrawUser({ const tldrawUser = useTldrawUser({
@@ -85,247 +81,107 @@ export default function SinglePlayerPage() {
setUserPreferences: setTldrawPreferences setUserPreferences: setTldrawPreferences
}); });
// Initialize store const [canvasPhase, setCanvasPhase] = useState<
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
>('idle');
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
// the store alive for the component lifetime avoids all disposal race conditions.
useEffect(() => { useEffect(() => {
if (!isEditorReady) { if (!user) return;
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
return;
}
if (!user) { setCanvasPhase('store-init');
logger.debug('single-player-page', '⏳ Waiting for user data'); const store = createTLStore({
return;
}
if (!editorRef.current) {
logger.debug('single-player-page', '⏳ Waiting for editor ref');
return;
}
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
});
const initializeStoreAndSnapshot = async () => {
try {
setLoadingState({ status: 'loading', error: '' });
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({
schema: customSchema, schema: customSchema,
shapeUtils: allShapeUtils, shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils bindingUtils: allBindingUtils,
}); });
logger.debug('single-player-page', '✅ TLStore created'); // Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
// 2. Initialize snapshot service const snapSvc = new NavigationSnapshotService(store, undefined);
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined); if (accessToken) snapSvc.setAccessToken(accessToken);
snapshotServiceRef.current = snapshotService; snapshotServiceRef.current = snapSvc;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node let debounce: ReturnType<typeof setTimeout> | null = null;
if (context.node) { const unsubscribe = store.listen(() => {
const nodeStoragePath = getNodeStoragePath(context.node); if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
if (nodeStoragePath) { if (debounce) clearTimeout(debounce);
logger.debug('single-player-page', '📥 Loading snapshot from database', { debounce = setTimeout(async () => {
dbName: user.user_db_name, await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
node: context.node, }, 2000);
node_storage_path: nodeStoragePath,
user_type: user.user_type,
username: user.username
}); });
await NavigationSnapshotService.loadNodeSnapshotFromDatabase( storeRef.current = store;
nodeStoragePath, setStoreReady(true);
user.user_db_name, setCanvasPhase('ready');
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');
}
// 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 () => { return () => {
logger.debug('single-player-page', '🧹 Starting cleanup'); unsubscribe?.();
if (snapshotServiceRef.current) { snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => { snapshotServiceRef.current?.clearCurrentNode();
logger.error('single-player-page', '❌ Final save failed', error);
});
snapshotServiceRef.current.clearCurrentNode();
snapshotServiceRef.current = null; snapshotServiceRef.current = null;
} setStoreReady(false);
newStore.dispose(); setCanvasPhase('idle');
setStore(undefined); store.dispose();
logger.debug('single-player-page', '🧹 Cleanup complete'); storeRef.current = null;
};
} 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;
}
}; };
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
initializeStoreAndSnapshot(); // Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
}, [isEditorReady, user, context.node]);
// Handle initial node placement
useEffect(() => { useEffect(() => {
const placeInitialNode = async () => { if (!user || !storeRef.current) return;
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
hasNode: !!context.node,
hasEditor: !!editorRef.current,
hasStore: !!store,
isInitialLoad
});
return;
}
// Debug: Log the actual node structure const store = storeRef.current;
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', { let cancelled = false;
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 loadNodeSnapshot = async () => {
const nodeStoragePath = getNodeStoragePath(context.node); if (!context.node?.node_storage_path) {
if (!context.node.id || !nodeStoragePath) { // No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
logger.error('single-player-page', '❌ Node missing required properties', { (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
nodeId: context.node.id, snapshotServiceRef.current?.clearCurrentNode();
hasStoragePath: !!nodeStoragePath, setCanvasPhase('ready');
node: context.node
});
setLoadingState({
status: 'error',
error: 'Node is missing required information'
});
return; return;
} }
setCanvasPhase('snapshot-loading');
try { try {
setLoadingState({ status: 'loading', error: '' }); await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.node_storage_path,
// Center the node accessToken || '',
const nodeData = await loadNodeData(context.node); store,
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData); setLoadingState,
undefined,
setIsInitialLoad(false); editorRef.current || undefined
setLoadingState({ status: 'ready', error: '' }); );
} catch (error) { if (!cancelled) {
logger.error('single-player-page', '❌ Failed to place initial node', error); // Repair store in case snapshot overwrote TLINSTANCE
setLoadingState({ (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
status: 'error', snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
error: error instanceof Error ? error.message : 'Failed to place initial node' setCanvasPhase('ready');
}); }
} catch (e) {
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
} }
}; };
placeInitialNode(); loadNodeSnapshot();
}, [context.node, store, isInitialLoad]); return () => { cancelled = true; };
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
// Handle navigation changes // 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(() => { useEffect(() => {
const handleNodeChange = async () => { if (user?.id && accessToken) {
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) { setAuthInfo(accessToken, user.id);
return; if (!context.node) {
switchContext({ main: 'profile', base: 'profile' }, null, null);
} }
// 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'
});
} }
}; }, [user?.id, accessToken]);
handleNodeChange();
}, [context.node, context.history, store, isInitialLoad]);
// Initialize preferences when user is available // Initialize preferences when user is available
useEffect(() => { useEffect(() => {
@@ -451,7 +307,7 @@ export default function SinglePlayerPage() {
const uiComponents = getUiComponents(presentationMode); const uiComponents = getUiComponents(presentationMode);
// Show loading state if user context is still loading // Show loading state if user context is still loading
if (userLoading) { if (userLoading || !user) {
return ( return (
<div style={{ <div style={{
position: 'fixed', position: 'fixed',
@@ -472,12 +328,9 @@ export default function SinglePlayerPage() {
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
top: `${HEADER_HEIGHT}px`, top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}> }}>
{/* Loading overlay - show when loading or contexts not initialized */} {/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && ( {(loadingState.status === 'loading' || !storeReady) && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -505,9 +358,15 @@ export default function SinglePlayerPage() {
</Alert> </Alert>
</Snackbar> </Snackbar>
<Tldraw <ErrorBoundary fallback={
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
</div>
}>
{storeReady && storeRef.current && <Tldraw
user={tldrawUser} user={tldrawUser}
store={store} store={storeRef.current}
tools={singlePlayerTools} tools={singlePlayerTools}
shapeUtils={allShapeUtils} shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils} bindingUtils={allBindingUtils}
@@ -537,106 +396,20 @@ export default function SinglePlayerPage() {
// Update snapshot service with editor reference // Update snapshot service with editor reference
if (snapshotServiceRef.current) { if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor); snapshotServiceRef.current.setEditor(editor);
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
} }
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', { logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id, editorId: editor.store.id,
presentationMode, presentationMode,
isEditorReady: true
}); });
} catch (error) { } catch (error) {
logger.error('single-player-page', '❌ Error in onMount', error); logger.error('single-player-page', '❌ Error in onMount', error);
} }
}} }}
/> />}
</ErrorBoundary>
</div> </div>
); );
} }
// Helper function to safely extract node_storage_path from different node structures
const getNodeStoragePath = (node: NavigationNode): string | null => {
// Try direct access first
if (node.node_storage_path) {
return node.node_storage_path;
}
// Try nested under data
if (node.data?.node_storage_path) {
return node.data.node_storage_path;
}
// Try other possible locations
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
return node.data.storage_path;
}
return null;
};
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 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 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> <Typography variant="h2" component="h1" gutterBottom>
404 404
</Typography> </Typography>
+93 -1
View File
@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert,
Box, Box,
Button, Button,
Chip,
Container, Container,
Grid, Grid,
Paper, Paper,
@@ -10,17 +12,28 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import { useUser } from '../../contexts/UserContext'; import { useUser } from '../../contexts/UserContext';
const DashboardPage: React.FC = () => { const DashboardPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user: authUser } = useAuth(); const { user: authUser, bootstrapData } = useAuth();
const { profile, loading } = useUser(); const { profile, loading } = useUser();
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member'; const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
const emailAddress = profile?.email || authUser?.email || ''; const emailAddress = profile?.email || authUser?.email || '';
const userType = profile?.user_type || authUser?.user_type || ''; 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 ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={6}> <Stack spacing={6}>
@@ -72,6 +85,12 @@ const DashboardPage: React.FC = () => {
> >
Open workspace Open workspace
</Button> </Button>
<Button
variant="outlined"
onClick={() => navigate('/exam-marker')}
>
Exam Marker
</Button>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => navigate('/calendar')} onClick={() => navigate('/calendar')}
@@ -88,6 +107,79 @@ const DashboardPage: React.FC = () => {
</Paper> </Paper>
</Grid> </Grid>
</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> </Stack>
</Container> </Container>
); );
+3 -20
View File
@@ -3,7 +3,6 @@ import { TLUserPreferences } from '@tldraw/tldraw';
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import { storageService, StorageKeys } from './localStorageService'; import { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
import { DatabaseNameService } from '../graph/databaseNameService';
export interface CCUser { export interface CCUser {
id: string; id: string;
@@ -11,8 +10,7 @@ export interface CCUser {
user_type: string; user_type: string;
username: string; username: string;
display_name: string; display_name: string;
user_db_name: string; school_id?: string | null;
school_db_name: string;
created_at?: string; created_at?: string;
updated_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 // Default to student if no user type specified
const userType = metadata.user_type || 'student'; 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 { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
user_type: userType, user_type: userType,
username: username, username,
display_name: displayName, display_name: displayName,
user_db_name: userDbName, school_id: null,
school_db_name: schoolDbName,
created_at: user.created_at, created_at: user.created_at,
updated_at: user.updated_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 { storageService, StorageKeys } from './localStorageService';
import { logger } from '../../debugConfig'; import { logger } from '../../debugConfig';
import { provisionUser } from '../provisioningService'; import { provisionUser } from '../provisioningService';
import { DatabaseNameService } from '../graph/databaseNameService';
const REGISTRATION_SERVICE = 'registration-service'; const REGISTRATION_SERVICE = 'registration-service';
@@ -87,14 +86,6 @@ export class RegistrationService {
try { try {
const provisioned = await provisionUser(ccUser.id, provisioningToken); const provisioned = await provisionUser(ccUser.id, provisioningToken);
if (provisioned) { 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', { logger.info(REGISTRATION_SERVICE, '✅ Provisioning successful', {
userId: ccUser.id, userId: ccUser.id,
userDbName: provisioned.user_db_name, 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 { return {
user: ccUser, user: ccUser,
accessToken: authData.session?.access_token || null, 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 { supabase } from '../supabaseClient';
import { import {
Class, Class,
ClassWithRole,
ClassWithRelations, ClassWithRelations,
ClassFilters, ClassFilters,
Timetable, Timetable,
@@ -14,7 +15,7 @@ import {
} from '../types/timetable.types'; } from '../types/timetable.types';
// API base URL // 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 // Helper: Get auth headers
@@ -79,10 +80,30 @@ export const classService = {
await axios.delete(`${API_BASE}/database/timetable/classes/${classId}`, { headers }); await axios.delete(`${API_BASE}/database/timetable/classes/${classId}`, { headers });
}, },
async getMyClasses(): Promise<Class[]> { async getMyClasses(): Promise<ClassWithRole[]> {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const response = await axios.get(`${API_BASE}/database/timetable/classes/me/student`, { headers }); const [teacherRes, studentRes] = await Promise.allSettled([
return response.data.classes; 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[]> { async getMyTeachingClasses(): Promise<Class[]> {
+17 -11
View File
@@ -19,12 +19,17 @@ export class SharedStoreService {
private lastSaveTime: number = Date.now(); private lastSaveTime: number = Date.now();
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null; private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
private config: AutoSaveConfig; private config: AutoSaveConfig;
private isDirty = false;
private dirtyListener: (() => void) | null = null;
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) { constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
this.config = { this.config = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...config ...config
}; };
this.dirtyListener = store.listen(() => {
this.isDirty = true;
});
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService'); logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
} }
@@ -52,18 +57,16 @@ export class SharedStoreService {
} }
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> { private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
const now = Date.now(); if (!this.isDirty) {
if (now - this.lastSaveTime >= this.config.saveInterval) { return;
const currentSnapshot = getSnapshot(this.store);
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
await this.saveSnapshot(currentSnapshot, setLoadingState);
this.lastSaveTime = now;
} else {
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
} }
this.isDirty = false;
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
this.lastSaveTime = now;
} }
} }
@@ -109,7 +112,10 @@ export class SharedStoreService {
public clear(): void { public clear(): void {
this.stopAutoSave(); this.stopAutoSave();
this.dirtyListener?.();
this.dirtyListener = null;
this.store.clear(); this.store.clear();
this.isDirty = false;
logger.debug('shared-store-service', '🧹 Store cleared'); logger.debug('shared-store-service', '🧹 Store cleared');
} }
} }
+125 -293
View File
@@ -1,27 +1,52 @@
// External imports // External imports
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw'; import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import logger from '../../debugConfig'; import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService'; import { SharedStoreService } from './sharedStoreService';
import { StorageKeys, storageService } from '../auth/localStorageService';
import { NavigationNode } from '../../types/navigation';
export interface LoadingState { export interface LoadingState {
status: 'loading' | 'ready' | 'error'; status: 'loading' | 'ready' | 'error';
error: string; error: string;
} }
const EMPTY_NODE: NavigationNode = { const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
id: '', const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
node_storage_path: '', const BUCKET = 'cc.users';
type: '',
label: '' 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 { export class NavigationSnapshotService {
private store: TLStore; private store: TLStore;
private editor: Editor | null = null; private editor: Editor | null = null;
private currentNodePath: string | null = null; private currentNodePath: string | null = null;
private _accessToken: string | null = null;
private isAutoSaveEnabled = true; private isAutoSaveEnabled = true;
private isSaving = false; private isSaving = false;
private isLoading = false; private isLoading = false;
@@ -33,24 +58,21 @@ export class NavigationSnapshotService {
this.editor = editor || null; this.editor = editor || null;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', { logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id, storeId: store.id,
hasEditor: !!editor hasEditor: !!editor,
}); });
} }
setEditor(editor: Editor): void { setEditor(editor: Editor): void {
this.editor = editor; this.editor = editor;
logger.debug('snapshot-service', '🔄 Editor reference updated', {
editorId: editor.store.id
});
} }
private static replaceBackslashes(input: string | undefined): string { setAccessToken(token: string): void {
return input ? input.replace(/\\/g, '/') : ''; this._accessToken = token;
} }
static async loadNodeSnapshotFromDatabase( static async loadNodeSnapshotFromDatabase(
nodePath: string, nodePath: string,
dbName: string, accessToken: string,
store: TLStore, store: TLStore,
setLoadingState: (state: LoadingState) => void, setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService, sharedStore?: SharedStoreService,
@@ -58,248 +80,121 @@ export class NavigationSnapshotService {
): Promise<void> { ): Promise<void> {
try { try {
setLoadingState({ status: 'loading', error: '' }); setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
logger.info('snapshot-service', '📂 Loading file from path', { const snapshot = await storageGet(nodePath, accessToken);
path: nodePath,
db_name: dbName
});
const response = await axios.get( if (!snapshot) {
'/database/tldraw_supabase/get_tldraw_node_file', { logger.debug('snapshot-service', '️ No snapshot found at path — clearing canvas', { nodePath });
params: { // Clear all shapes so the canvas is blank for this new node
path: this.replaceBackslashes(nodePath), if (editor) {
db_name: dbName const shapeIds = [...editor.getCurrentPageShapeIds()];
if (shapeIds.length > 0) {
editor.deleteShapes(shapeIds);
} }
} }
); setLoadingState({ status: 'ready', error: '' });
return;
}
const snapshot = response.data; const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
logger.debug('snapshot-service', '🔍 Snapshot data received', { if (!snap.document || !snap.session) {
hasSnapshot: !!snapshot, logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
hasDocument: !!snapshot?.document, setLoadingState({ status: 'ready', error: '' });
hasSession: !!snapshot?.session, return;
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) { if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState); await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else { return;
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', { }
hasStore: !!store,
snapshotType: typeof snapshot, // Sanitize session.currentPageId against pages actually in the document
snapshotKeys: Object.keys(snapshot), const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
snapshotSchemaVersion: snapshot?.schemaVersion, const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
snapshotDocument: !!snapshot?.document, const session = snap.session as { currentPageId?: string } | undefined;
snapshotSession: !!snapshot?.session 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];
}
// Create a defensive copy to ensure the snapshot doesn't get modified
const snapshotCopy = { const snapshotCopy = {
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion, schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snapshot.document, document: snap.document,
session: snapshot.session session: snap.session,
}; };
logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', { const targetStore = editor ? editor.store : store;
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 { try {
// Ensure store is properly initialized before loading snapshot loadSnapshot(targetStore, snapshotCopy as any);
if (!store) { // Post-load validation: ensure instance.currentPageId is still valid
throw new Error('Store is null or undefined'); 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();
} }
// 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'); 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 { } else {
// Fallback: use global loadSnapshot if no editor available logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot'); targetStore.clear();
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: '' }); 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' });
}
} catch (error) { } catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', { logger.error('snapshot-service', '❌ Failed to load snapshot', {
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error',
}); });
setLoadingState({ setLoadingState({
status: 'error', status: 'error',
error: error instanceof Error ? error.message : 'Failed to load file' error: error instanceof Error ? error.message : 'Failed to load snapshot',
}); });
} }
} }
static async saveNodeSnapshotToDatabase( static async saveNodeSnapshotToDatabase(
nodePath: string, nodePath: string,
dbName: string, accessToken: string,
store: TLStore store: TLStore
): Promise<void> { ): Promise<void> {
try { try {
logger.info('snapshot-service', '💾 Saving snapshot to database', { logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
path: nodePath,
db_name: dbName
});
const snapshot = getSnapshot(store); const snapshot = getSnapshot(store);
await storagePut(nodePath, accessToken, snapshot);
// 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'); logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} else {
throw new Error('Failed to save snapshot');
}
} catch (error) { } catch (error) {
logger.error('snapshot-service', '❌ Failed to save snapshot', { logger.error('snapshot-service', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error',
}); });
throw error; throw error;
} }
} }
private async saveCurrentSnapshot(nodePath: string): Promise<void> { private async saveCurrentSnapshot(nodePath: string): Promise<void> {
if (!this.currentNodePath || this.currentNodePath !== nodePath) { if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', { if (!this._accessToken) {
currentPath: this.currentNodePath, logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
savePath: nodePath
});
return; return;
} }
try { try {
this.isSaving = true; this.isSaving = true;
const user = storageService.get(StorageKeys.USER); await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
if (!user) { logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
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
});
} catch (error) { } catch (error) {
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', { logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
nodePath nodePath,
}); });
throw error; throw error;
} finally { } 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 { try {
this.isLoading = true; 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( await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.node_storage_path, node.node_storage_path,
dbName, this._accessToken,
this.store, this.store,
(state: LoadingState) => { (state: LoadingState) => {
if (state.status === 'ready') { if (state.status === 'ready') {
this.currentNodePath = node.node_storage_path; 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 undefined,
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot 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 { } finally {
this.isLoading = false; this.isLoading = false;
} }
} }
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> { async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
if (!toNode) { if (!toNode) return;
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node'); if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
return;
}
// Clear any pending debounce
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Debounce the navigation operation
return new Promise((resolve) => { return new Promise((resolve) => {
this.debounceTimeout = setTimeout(async () => { this.debounceTimeout = setTimeout(async () => {
try { try {
await this.executeNavigation(fromNode || EMPTY_NODE, toNode); await this.executeNavigation(fromNode, toNode);
resolve(); resolve();
} catch (error) { } catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error); logger.error('snapshot-service', '❌ Navigation failed', error);
throw error; throw error;
} }
}, 100); // 100ms debounce }, 100);
}); });
} }
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> { private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
try {
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
from: fromNode.node_storage_path,
to: toNode.node_storage_path,
currentPath: this.currentNodePath
});
// If we're already in a navigation operation, queue this one
if (this.isSaving || this.isLoading) { if (this.isSaving || this.isLoading) {
this.pendingOperation = { this.pendingOperation = {
save: fromNode.node_storage_path || undefined, save: fromNode?.node_storage_path,
load: toNode.node_storage_path load: toNode.node_storage_path,
}; };
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return; return;
} }
// Clear the store before loading new snapshot
logger.debug('snapshot-service', '🔄 Clearing store');
this.currentNodePath = null; this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
// Load the new node's snapshot
if (toNode.node_storage_path) { if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode); await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.node_storage_path
});
} }
// Process any pending operations
if (this.pendingOperation) { if (this.pendingOperation) {
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation); const op = this.pendingOperation;
const operation = this.pendingOperation;
this.pendingOperation = null; this.pendingOperation = null;
await this.handleNavigationStart( await this.handleNavigationStart(
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null, op.save ? { node_storage_path: op.save } : null,
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null op.load ? { node_storage_path: op.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;
} }
} }
setAutoSave(enabled: boolean): void { setAutoSave(enabled: boolean): void {
this.isAutoSaveEnabled = enabled; this.isAutoSaveEnabled = enabled;
logger.debug('snapshot-service', '🔄 Auto-save setting changed', { }
enabled
}); setCurrentNodePath(path: string): void {
this.currentNodePath = path;
} }
getCurrentNodePath(): string | null { getCurrentNodePath(): string | null {
@@ -447,14 +282,11 @@ export class NavigationSnapshotService {
async forceSaveCurrentNode(): Promise<void> { async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) { if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath); await this.saveCurrentSnapshot(this.currentNodePath);
} else {
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
} }
} }
clearCurrentNode(): void { clearCurrentNode(): void {
this.currentNodePath = null; this.currentNodePath = null;
this.store.clear(); this.store.clear();
logger.debug('snapshot-service', '🧹 Cleared current node and store');
} }
} }
+6 -4
View File
@@ -15,6 +15,7 @@ export interface SyncConnectionOptions {
color: string; color: string;
roomId?: string; roomId?: string;
baseUrl: string; baseUrl: string;
token?: string;
} }
export function createSyncConnectionOptions(options: SyncConnectionOptions) { export function createSyncConnectionOptions(options: SyncConnectionOptions) {
@@ -22,8 +23,8 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
userId, userId,
displayName, displayName,
roomId = 'multiplayer', roomId = 'multiplayer',
baseUrl baseUrl,
token
} = options; } = options;
// Ensure we have valid user info // Ensure we have valid user info
@@ -72,8 +73,10 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
roomId: effectiveRoomId roomId: effectiveRoomId
}); });
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
return { return {
uri: `${baseUrl}/connect/${effectiveRoomId}`, uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`,
assets: multiplayerAssets, assets: multiplayerAssets,
roomId: effectiveRoomId roomId: effectiveRoomId
}; };
@@ -114,4 +117,3 @@ export function generateSharedRoomId(path: string): string {
const sanitizedPath = path.replace(/[^a-zA-Z0-9]/g, '-'); const sanitizedPath = path.replace(/[^a-zA-Z0-9]/g, '-');
return `shared-${sanitizedPath}`; return `shared-${sanitizedPath}`;
} }
+13
View File
@@ -1,3 +1,16 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { expect } from 'vitest'; import { expect } from 'vitest';
if (typeof globalThis.matchMedia !== 'function') {
vi.stubGlobal('matchMedia', () => ({
matches: false,
media: '',
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
} as unknown as MediaQueryList))
}
+199 -300
View File
@@ -1,32 +1,45 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { UserNeoDBService } from '../services/graph/userNeoDBService';
import { logger } from '../debugConfig'; import { logger } from '../debugConfig';
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import { import {
NavigationStore, NavigationStore,
NavigationNode, NavigationNode,
NeoGraphNode,
MainContext, MainContext,
BaseContext, BaseContext,
NavigationContextState, NavigationContextState,
isProfileContext, isProfileContext,
isInstituteContext, isInstituteContext,
getContextDatabase,
addToHistory, addToHistory,
navigateHistory, navigateHistory,
getCurrentHistoryNode, getCurrentHistoryNode,
ExtendedContext, ExtendedContext,
UnifiedContextSwitch, UnifiedContextSwitch,
NodeContext
} from '../types/navigation'; } 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 = { const initialState: NavigationContextState = {
main: 'profile', main: 'profile',
base: 'profile', base: 'profile',
node: null, node: null,
history: { history: { nodes: [], currentIndex: -1 }
nodes: [],
currentIndex: -1
}
}; };
function getDefaultBaseForMain(main: MainContext): BaseContext { function getDefaultBaseForMain(main: MainContext): BaseContext {
@@ -38,210 +51,140 @@ function validateContextTransition(
updates: Partial<NavigationContextState> updates: Partial<NavigationContextState>
): NavigationContextState { ): NavigationContextState {
const newState = { ...current, ...updates }; const newState = { ...current, ...updates };
// Validate main context
if (updates.main) { if (updates.main) {
newState.base = getDefaultBaseForMain(updates.main); newState.base = getDefaultBaseForMain(updates.main);
} }
// Validate base context
if (updates.base) { if (updates.base) {
// Ensure base context matches main context
const isValid = newState.main === 'profile' const isValid = newState.main === 'profile'
? isProfileContext(updates.base) ? isProfileContext(updates.base)
: isInstituteContext(updates.base); : isInstituteContext(updates.base);
if (!isValid) { if (!isValid) {
newState.base = getDefaultBaseForMain(newState.main); newState.base = getDefaultBaseForMain(newState.main);
} }
} }
return newState; return newState;
} }
export interface NavigationActions { export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
// Context Navigation const pgFetch = async <T = unknown>(
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>; method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>; table: string,
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>; 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>;
};
// Node Navigation const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
navigate: (nodeId: string, dbName: string) => Promise<void>; const userId = get()._userId;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>; if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
// History Navigation const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
goBack: () => void; query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`,
goForward: () => void; });
// Utility Methods if (rooms && rooms.length > 0) {
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>; const room = rooms[0];
} return {
id: room.id,
node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`,
label: room.name,
type: 'workspace',
};
}
export interface NavigationState { const storagePath = `${userId}/workspaces/${contextType}_default.json`;
context: { const room = await pgFetch<WhiteboardRoom>('POST', 'whiteboard_rooms', {
main: NodeContext; body: {
base: NodeContext; user_id: userId,
extended?: string; name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`,
node: NavigationNode; context_type: contextType,
history: { is_default: true,
nodes: NavigationNode[]; storage_path: storagePath,
currentIndex: number; },
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) => ({ return {
_accessToken: null,
_userId: null,
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
context: initialState, context: initialState,
isLoading: false, isLoading: false,
error: null, error: null,
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | 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 { 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
});
return;
}
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 }); set({ isLoading: true, error: null });
const currentState = get().context; const currentState = get().context;
let newState: NavigationContextState = { ...currentState, node: null };
// 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) { if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main }); newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) { if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main); 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) { if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: 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 || const targetContext = contextSwitch.base ||
contextSwitch.extended || contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
newState.base);
// Get database name const defaultNode = await getOrCreateDefaultRoom(targetContext);
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); const newHistory = addToHistory(currentState.history, defaultNode);
logger.debug('history-management', '📚 History updated', {
previousState: currentState.history,
newState: newHistory,
addedNode: defaultNode
});
set({ set({
context: { context: { ...newState, node: defaultNode, history: newHistory },
...newState,
node: defaultNode,
history: newHistory
},
isLoading: false, isLoading: false,
error: null error: null,
}); });
logger.debug('navigation-context', '✅ Context switch complete', {
logger.debug('navigation-context', '✅ Context switch completed', { main: newState.main, base: newState.base, nodeId: defaultNode.id,
finalState: {
main: newState.main,
base: newState.base,
nodeId: defaultNode.id
}
}); });
} catch (error) { } catch (error) {
logger.error('navigation-context', '❌ Failed to switch context:', error); logger.error('navigation-context', '❌ switchContext failed', error);
set({ set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false });
error: error instanceof Error ? error.message : 'Failed to switch context',
isLoading: false
});
} }
}, },
@@ -249,14 +192,7 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
const currentState = get().context; const currentState = get().context;
if (currentState.history.currentIndex > 0) { if (currentState.history.currentIndex > 0) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1); const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1);
const node = getCurrentHistoryNode(newHistory); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
set({
context: {
...currentState,
node,
history: newHistory
}
});
} }
}, },
@@ -264,176 +200,139 @@ export const useNavigationStore = create<NavigationStore>((set, get) => ({
const currentState = get().context; const currentState = get().context;
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
const node = getCurrentHistoryNode(newHistory); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } });
set({
context: {
...currentState,
node,
history: newHistory
}
});
} }
}, },
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ main }, userDbName, workerDbName); 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) => { setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ base }, userDbName, workerDbName); 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) => { setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ extended }, userDbName, workerDbName); 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) => { navigate: async (nodeId: string, _dbName: string) => {
try { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
if (!get()._accessToken) { set({ isLoading: false }); return; }
// Check if we already have this node in history
const currentState = get().context; const currentState = get().context;
const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
if (existingIndex !== -1) {
// If node exists in history, just navigate to it const newHistory = navigateHistory(currentState.history, existingIndex);
if (existingNodeIndex !== -1) { set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false });
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
},
isLoading: false,
error: null
});
return; return;
} }
// Fetch new node data const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName); query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`,
if (!nodeData) { });
throw new Error(`Node not found: ${nodeId}`); if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`);
}
const room = rooms[0];
const node: NavigationNode = { const node: NavigationNode = {
id: nodeId, id: room.id,
node_storage_path: nodeData.node_data.node_storage_path || '', node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`,
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId, label: room.name,
type: nodeData.node_type type: 'workspace',
}; };
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); const newHistory = addToHistory(currentState.history, node);
set({ set({ context: { ...currentState, node, history: newHistory }, isLoading: false });
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
} catch (error) { } catch (error) {
logger.error('navigation', '❌ Failed to navigate:', error); logger.error('navigation', '❌ navigate failed', error);
set({ set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false });
error: error instanceof Error ? error.message : 'Failed to navigate',
isLoading: false
});
} }
}, },
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { 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 { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
const currentNode = get().context.node;
if (!isValidNodeType(node.type)) { if (currentNode && get()._accessToken) {
throw new Error(`Invalid node type: ${node.type}`); const rooms = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
} query: `id=eq.${currentNode.id}`,
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
}); });
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 });
} }
}, },
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => { navigateToNeoNode: async (neoNode: NeoGraphNode) => {
const userId = get()._userId;
if (!userId || !get()._accessToken) {
logger.warn('navigation', '⚠️ navigateToNeoNode called without auth');
return;
}
try { try {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
const currentState = get().context; const existing = await pgFetch<WhiteboardRoom[]>('GET', 'whiteboard_rooms', {
query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`,
if (currentState.node) { });
const dbName = getContextDatabase(currentState, userDbName, workerDbName); let room: WhiteboardRoom;
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName); if (existing && existing.length > 0) {
if (nodeData) { 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 = { const node: NavigationNode = {
id: currentState.node.id, id: room.id,
node_storage_path: nodeData.node_data.node_storage_path || '', node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`,
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id, label: room.name,
type: nodeData.node_type type: neoNode.node_type,
}; };
set({ const currentState = get().context;
context: { const newHistory = addToHistory(currentState.history, node);
...currentState, set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null });
node logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode });
}
});
}
}
set({ isLoading: false });
} catch (error) { } catch (error) {
logger.error('navigation', '❌ Failed to refresh navigation state:', error); logger.error('navigation', '❌ navigateToNeoNode failed', error);
set({ set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false });
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 { devtools, persist } from 'zustand/middleware';
import { import {
Class, Class,
ClassWithRole,
ClassWithRelations, ClassWithRelations,
Timetable, Timetable,
TimetableWithRelations, TimetableWithRelations,
@@ -21,7 +22,7 @@ interface TimetableState {
// Classes // Classes
classes: Class[]; classes: Class[];
currentClass: ClassWithRelations | null; currentClass: ClassWithRelations | null;
myClasses: Class[]; myClasses: ClassWithRole[];
myTeachingClasses: Class[]; myTeachingClasses: Class[];
classesLoading: boolean; classesLoading: boolean;
classesError: string | null; classesError: string | null;
+390 -55
View File
@@ -1,5 +1,4 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { supabase } from '../supabaseClient';
export interface TranscriptionSegment { export interface TranscriptionSegment {
text: string; text: string;
@@ -23,6 +22,12 @@ export interface TranscriptionSession {
segment_count: number; segment_count: number;
} }
export interface ServerSegment {
text: string;
start: number;
end: number;
}
export interface TimetablePeriod { export interface TimetablePeriod {
period_id: string | null; period_id: string | null;
event_type: string | null; event_type: string | null;
@@ -35,10 +40,30 @@ export interface LLMConfig {
provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google'; provider: 'openai' | 'anthropic' | 'ollama' | 'openrouter' | 'google';
model: string; model: string;
apiKey: string; apiKey: string;
baseUrl?: string; // for Ollama: e.g. https://ollama.kevlarai.com
whisperModel?: string; // faster-whisper model size sent to WhisperLive
} }
export type ExportFormat = 'srt' | 'txt' | 'json'; export type ExportFormat = 'srt' | 'txt' | 'json';
export interface KeywordWatch {
id: string;
user_id: string;
keyword: string;
match_type: string;
action: string;
is_active: boolean;
created_at: string;
}
export interface KeywordMatch {
keyword: string;
watch_id: string | null;
segment_text: string;
elapsed_seconds: number;
matched_at: string;
}
const LLM_CONFIG_STORAGE_KEY = 'cc_llm_config'; const LLM_CONFIG_STORAGE_KEY = 'cc_llm_config';
function loadLLMConfig(): LLMConfig { function loadLLMConfig(): LLMConfig {
@@ -54,6 +79,8 @@ function loadLLMConfig(): LLMConfig {
provider: 'openai', provider: 'openai',
model: '', model: '',
apiKey: '', apiKey: '',
baseUrl: '',
whisperModel: 'large-v3',
}; };
} }
@@ -72,8 +99,9 @@ interface TranscriptionState {
activeSession: TranscriptionSession | null; activeSession: TranscriptionSession | null;
// Live feed // Live feed
completedSegments: TranscriptionSegment[]; completedSegments: TranscriptionSegment[]; // segments that scrolled off the server window (archived)
currentSegment: TranscriptionSegment | null; serverWindow: ServerSegment[]; // the current server-provided segment window (last N)
currentSegment: TranscriptionSegment | null; // the live (last) segment if still being refined
// Canvas event buffer (flushed to API every 5s) // Canvas event buffer (flushed to API every 5s)
pendingCanvasEvents: any[]; pendingCanvasEvents: any[];
@@ -97,9 +125,19 @@ interface TranscriptionState {
isExporting: boolean; isExporting: boolean;
exportError: string | null; exportError: string | null;
// Keyword state
keywordWatches: KeywordWatch[];
keywordMatches: KeywordMatch[];
// Auth (set by panel via setAuthInfo after SIGNED_IN)
_accessToken: string | null;
_userId: string | null;
setAuthInfo: (token: string | null, userId: string | null) => void;
// Actions // Actions
startSession: (timetableTag?: TimetablePeriod) => Promise<void>; startSession: (timetableTag?: TimetablePeriod) => Promise<void>;
stopSession: () => Promise<void>; stopSession: () => Promise<void>;
updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => void;
saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>; saveSegment: (text: string, isFinal: boolean, metadata: { start: number; end: number }) => Promise<void>;
resetSession: () => void; resetSession: () => void;
tickElapsed: () => void; tickElapsed: () => void;
@@ -120,13 +158,53 @@ interface TranscriptionState {
// Export actions // Export actions
exportSession: (sessionId: string, format: ExportFormat) => Promise<void>; exportSession: (sessionId: string, format: ExportFormat) => Promise<void>;
setExportError: (error: string | null) => void; setExportError: (error: string | null) => void;
// Keyword actions
loadKeywordWatches: () => Promise<void>;
addKeywordWatch: (keyword: string) => Promise<void>;
deleteKeywordWatch: (watchId: string) => Promise<void>;
checkSegmentForKeywords: (text: string, elapsedSeconds: number) => Promise<void>;
clearKeywordMatches: () => void;
} }
export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({ export const useTranscriptionStore = create<TranscriptionState>((set, get) => {
// Direct PostgREST fetch — uses stored _accessToken, no GoTrueClient lock.
const pgFetch = async <T = any>(
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>;
};
return {
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
activeSession: null, activeSession: null,
_accessToken: null,
_userId: null,
completedSegments: [], completedSegments: [],
serverWindow: [],
currentSegment: null, currentSegment: null,
pendingCanvasEvents: [], pendingCanvasEvents: [],
timetableContext: null, timetableContext: null,
@@ -145,23 +223,30 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isExporting: false, isExporting: false,
exportError: null, exportError: null,
// Keyword state
keywordWatches: [],
keywordMatches: [],
setTimetableContext: (context) => { setTimetableContext: (context) => {
set({ timetableContext: context }); set({ timetableContext: context });
}, },
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
startSession: async (timetableTag?: TimetablePeriod) => { startSession: async (timetableTag?: TimetablePeriod) => {
set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null }); set({ isRecording: true, isConnecting: false, elapsedSeconds: 0, timetableContext: timetableTag || null });
// Create session in Supabase
try { try {
const user = await supabase.auth.getUser(); const { _userId: userId } = get();
if (!user.data.user) { if (!userId) {
console.error('No authenticated user'); console.error('No authenticated user');
return; return;
} }
const sessionData = { const sessionData = {
user_id: user.data.user.id, user_id: userId,
title: timetableTag?.event_label || 'Untitled Session', title: timetableTag?.event_label || 'Untitled Session',
canvas_type: 'teaching-canvas', canvas_type: 'teaching-canvas',
timetable_period_id: timetableTag?.period_id || null, timetable_period_id: timetableTag?.period_id || null,
@@ -170,14 +255,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
auto_tagged: !!timetableTag, auto_tagged: !!timetableTag,
}; };
const { data, error } = await supabase const data = await pgFetch<TranscriptionSession>('POST', 'transcription_sessions', {
.from('transcription_sessions') body: sessionData,
.insert(sessionData) prefer: 'return=representation',
.select() single: true,
.single(); });
if (error) { if (!data) {
console.error('Failed to create session:', error); console.error('Failed to create session: no data returned');
return; return;
} }
@@ -188,18 +273,45 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
}, },
stopSession: async () => { stopSession: async () => {
const { activeSession, completedSegments } = get(); const { activeSession, currentSegment, completedSegments } = get();
// The live segment (currentSegment) was never added to completedSegments — flush it now.
let newCompleted = [...completedSegments];
if (currentSegment && currentSegment.text.trim()) {
const alreadyIn = newCompleted.some(s => Math.abs(s.start - currentSegment.start) < 0.5);
if (!alreadyIn) {
const idx = newCompleted.length;
newCompleted.push({ ...currentSegment, isFinal: true });
if (activeSession) {
pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: idx,
text: currentSegment.text,
start_seconds: currentSegment.start,
end_seconds: currentSegment.end,
is_final: true,
},
}).catch(err => console.error('Failed to save live segment on stop:', err));
}
}
}
const finalWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
if (activeSession) { if (activeSession) {
try { try {
await supabase await pgFetch('PATCH', 'transcription_sessions', {
.from('transcription_sessions') query: `id=eq.${activeSession.id}`,
.update({ body: {
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
word_count: get().wordCount, word_count: finalWordCount,
segment_count: completedSegments.length, segment_count: newCompleted.length,
}) },
.eq('id', activeSession.id); });
} catch (error) { } catch (error) {
console.error('Failed to end session:', error); console.error('Failed to end session:', error);
} }
@@ -209,47 +321,156 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
activeSession: null, activeSession: null,
completedSegments: newCompleted,
serverWindow: [],
currentSegment: null,
wordCount: finalWordCount,
}); });
}, },
saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => { updateServerWindow: (segments: ServerSegment[], isLastLive: boolean) => {
const { completedSegments, currentSegment, activeSession, wordCount } = get(); const { completedSegments, activeSession } = get();
if (isFinal) { if (segments.length === 0) return;
// Final segment - move current to completed, clear current
const newCompleted = [...completedSegments]; // The server marks every finalized segment with completed=true and the live
if (currentSegment && currentSegment.text.trim()) { // one with completed=false. Rather than relying on window-scroll detection
newCompleted.push({ ...currentSegment, isFinal: true }); // (which can miss segments when the server creates several at once), we
// directly merge every completed segment from this message into the store.
// This guarantees no gaps: any segment the server says is complete is captured
// immediately, regardless of how many were created since the last message.
const serverCompleted = isLastLive ? segments.slice(0, -1) : segments;
let newCompleted = [...completedSegments];
const toSave: Array<{ seg: ServerSegment; idx: number }> = [];
for (const seg of serverCompleted) {
if (!seg.text.trim()) continue;
const existingIdx = newCompleted.findIndex(s => Math.abs(s.start - seg.start) < 0.5);
if (existingIdx >= 0) {
// Server refined an existing segment — update text and end time in place.
newCompleted[existingIdx] = {
...newCompleted[existingIdx],
text: seg.text,
end: seg.end,
};
} else {
const newIdx = newCompleted.length;
newCompleted.push({ text: seg.text, isFinal: true, start: seg.start, end: seg.end });
toSave.push({ seg, idx: newIdx });
} }
}
// Keep sorted by start time so display order is always correct.
newCompleted.sort((a, b) => a.start - b.start);
// Persist and keyword-check only truly new segments.
if (toSave.length > 0) {
const elapsed = get().elapsedSeconds;
for (const { seg, idx } of toSave) {
if (activeSession) {
pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: idx,
text: seg.text,
start_seconds: seg.start,
end_seconds: seg.end,
is_final: true,
},
}).catch(err => console.error('Failed to save segment:', err));
}
get().checkSegmentForKeywords(seg.text, elapsed);
}
}
const lastSeg = segments[segments.length - 1];
const newCurrentSegment: TranscriptionSegment | null = isLastLive
? { text: lastSeg.text, isFinal: false, start: lastSeg.start, end: lastSeg.end }
: null;
const newWordCount = newCompleted.reduce( const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length, (sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0 0
); );
set({ set({
serverWindow: segments,
completedSegments: newCompleted, completedSegments: newCompleted,
currentSegment: null, currentSegment: newCurrentSegment,
wordCount: newWordCount, wordCount: newWordCount,
}); });
},
// Save to Supabase if session is active saveSegment: async (text: string, isFinal: boolean, metadata: { start: number; end: number }) => {
if (activeSession) { const { completedSegments, currentSegment, activeSession } = get();
if (isFinal) {
// Deduplicate by start time: if a segment with this start already exists, update it
// rather than appending. This prevents doubles when the stability timer fires and
// the segment later appears in the server's finalized list with a slightly extended end.
const existingIdx = completedSegments.findIndex(
(s) => Math.abs(s.start - metadata.start) < 0.5
);
let newCompleted: TranscriptionSegment[];
let isNew: boolean;
if (existingIdx >= 0) {
newCompleted = completedSegments.map((s, i) =>
i === existingIdx ? { text, isFinal: true, ...metadata } : s
);
isNew = false;
} else {
newCompleted = [...completedSegments, { text, isFinal: true, ...metadata }];
isNew = true;
}
const newWordCount = newCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: newCompleted, currentSegment: null, wordCount: newWordCount });
if (isNew && activeSession) {
try { try {
const sequenceIndex = newCompleted.length - 1; await pgFetch('POST', 'transcription_segments', {
await supabase.from('transcription_segments').insert({ body: {
session_id: activeSession.id, session_id: activeSession.id,
sequence_index: sequenceIndex, sequence_index: newCompleted.length - 1,
text: text, text,
start_seconds: metadata.start, start_seconds: metadata.start,
end_seconds: metadata.end, end_seconds: metadata.end,
is_final: true, is_final: true,
},
}); });
} catch (error) { } catch (error) {
console.error('Failed to save segment:', error); console.error('Failed to save segment:', error);
} }
} }
} else { } else {
// In-progress segment // In-progress segment. If the start time jumped to a new position, the previous
// live segment is done — auto-commit it before switching.
if (currentSegment && metadata.start > currentSegment.start + 0.5 && currentSegment.text.trim()) {
const autoCompleted = [...completedSegments, { ...currentSegment, isFinal: true }];
const autoWordCount = autoCompleted.reduce(
(sum, seg) => sum + seg.text.trim().split(/\s+/).filter(Boolean).length,
0
);
set({ completedSegments: autoCompleted, wordCount: autoWordCount });
if (activeSession) {
pgFetch('POST', 'transcription_segments', {
body: {
session_id: activeSession.id,
sequence_index: autoCompleted.length - 1,
text: currentSegment.text,
start_seconds: currentSegment.start,
end_seconds: currentSegment.end,
is_final: true,
},
}).catch(err => console.error('Failed to save auto-committed segment:', err));
}
}
set({ currentSegment: { text, isFinal: false, ...metadata } }); set({ currentSegment: { text, isFinal: false, ...metadata } });
} }
}, },
@@ -259,12 +480,14 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
isRecording: false, isRecording: false,
isConnecting: false, isConnecting: false,
completedSegments: [], completedSegments: [],
serverWindow: [],
currentSegment: null, currentSegment: null,
wordCount: 0, wordCount: 0,
elapsedSeconds: 0, elapsedSeconds: 0,
activeSession: null, activeSession: null,
pendingCanvasEvents: [], pendingCanvasEvents: [],
timetableContext: null, timetableContext: null,
keywordMatches: [],
}); });
}, },
@@ -287,9 +510,10 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
try { try {
for (const event of eventsToFlush) { for (const event of eventsToFlush) {
await supabase.from('canvas_events').insert({ await pgFetch('POST', 'canvas_events', {
body: {
session_id: activeSession?.id || null, session_id: activeSession?.id || null,
user_id: (await supabase.auth.getUser()).data.user?.id || '', user_id: get()._userId || '',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
session_elapsed_seconds: event.sessionElapsedSeconds || null, session_elapsed_seconds: event.sessionElapsedSeconds || null,
event_type: event.eventType, event_type: event.eventType,
@@ -297,6 +521,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
canvas_snapshot_url: event.snapshotUrl || null, canvas_snapshot_url: event.snapshotUrl || null,
tldraw_page_id: event.pageId || null, tldraw_page_id: event.pageId || null,
tldraw_shape_ids: event.shapeIds || null, tldraw_shape_ids: event.shapeIds || null,
},
}); });
} }
@@ -308,20 +533,12 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
loadSessions: async (): Promise<TranscriptionSession[]> => { loadSessions: async (): Promise<TranscriptionSession[]> => {
try { try {
const user = await supabase.auth.getUser(); const { _userId: userId } = get();
if (!user.data.user) return []; if (!userId) return [];
const { data, error } = await supabase const data = await pgFetch<TranscriptionSession[]>('GET', 'transcription_sessions', {
.from('transcription_sessions') query: `user_id=eq.${userId}&order=started_at.desc&limit=50&select=*`,
.select('*') });
.eq('user_id', user.data.user.id)
.order('started_at', { ascending: false })
.limit(50);
if (error) {
console.error('Failed to load sessions:', error);
return [];
}
return data || []; return data || [];
} catch (error) { } catch (error) {
@@ -360,7 +577,7 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
set({ isExporting: true, exportError: null }); set({ isExporting: true, exportError: null });
try { try {
const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, { const response = await fetch(`${apiBaseUrl}/transcribe/sessions/${sessionId}/export`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -405,4 +622,122 @@ export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
setExportError: (error: string | null) => { setExportError: (error: string | null) => {
set({ exportError: error }); set({ exportError: error });
}, },
}));
loadKeywordWatches: async () => {
try {
const { _accessToken: token } = get();
if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!response.ok) return;
const watches = await response.json();
set({ keywordWatches: watches });
} catch (error) {
console.error('Failed to load keyword watches:', error);
}
},
addKeywordWatch: async (keyword: string) => {
try {
const { _accessToken: token, _userId: userId } = get();
if (!token || !userId) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
user_id: userId,
keyword: keyword.trim(),
match_type: 'contains',
action: 'alert',
}),
});
if (!response.ok) return;
const newWatch = await response.json();
set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] }));
} catch (error) {
console.error('Failed to add keyword watch:', error);
}
},
deleteKeywordWatch: async (watchId: string) => {
try {
const { _accessToken: token } = get();
if (!token) return;
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) }));
} catch (error) {
console.error('Failed to delete keyword watch:', error);
}
},
checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => {
const { keywordWatches, activeSession } = get();
if (keywordWatches.length === 0) return;
const lowerText = text.toLowerCase();
const matches: KeywordMatch[] = [];
for (const watch of keywordWatches) {
if (!watch.is_active) continue;
const lowerKeyword = watch.keyword.toLowerCase();
const matched =
watch.match_type === 'exact'
? lowerText === lowerKeyword
: watch.match_type === 'starts_with'
? lowerText.startsWith(lowerKeyword)
: lowerText.includes(lowerKeyword);
if (matched) {
matches.push({
keyword: watch.keyword,
watch_id: watch.id,
segment_text: text,
elapsed_seconds: elapsedSeconds,
matched_at: new Date().toISOString(),
});
if (activeSession) {
try {
const { _accessToken: kwToken } = get();
const apiBaseUrl = import.meta.env.VITE_API_BASE || '/api';
await fetch(`${apiBaseUrl}/transcribe/keywords/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(kwToken ? { 'Authorization': `Bearer ${kwToken}` } : {}),
},
body: JSON.stringify({
session_id: activeSession.id,
keyword_watch_id: watch.id,
keyword_text: watch.keyword,
matched_in_text: text,
session_elapsed_seconds: elapsedSeconds,
}),
});
} catch (error) {
console.error('Failed to log keyword event:', error);
}
}
}
}
if (matches.length > 0) {
set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] }));
}
},
clearKeywordMatches: () => {
set({ keywordMatches: [] });
},
};
});
+9 -1
View File
@@ -1,9 +1,17 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { logger } from './debugConfig'; import { logger } from './debugConfig';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
// resolved against the current browser origin so supabase-js receives an
// absolute URL while every request stays same-origin (no CORS) and routes
// through the app host's /__supabase nginx proxy to the backend Supabase.
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
? `${window.location.origin}${rawSupabaseUrl}`
: rawSupabaseUrl;
if (!supabaseUrl || !supabaseAnonKey) { if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase configuration'); throw new Error('Missing Supabase configuration');
} }
+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 // Navigation Actions Interface
export interface NeoGraphNode {
neo4j_node_id: string;
neo4j_db_name: string;
node_type: string;
label: string;
}
export interface NavigationActions { export interface NavigationActions {
// Unified Context Switch // Unified Context Switch
switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>; switchContext: (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => Promise<void>;
@@ -239,6 +246,7 @@ export interface NavigationActions {
// Node Navigation // Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>; navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>; navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
navigateToNeoNode: (neoNode: NeoGraphNode) => Promise<void>;
// History Navigation // History Navigation
goBack: () => void; goBack: () => void;
+9
View File
@@ -27,6 +27,15 @@ export interface Class {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
is_active: boolean; 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 { export interface ClassTeacher {
+119
View File
@@ -0,0 +1,119 @@
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' })
})
it('never drops a response region that has no enclosing part — it falls back to a question', () => {
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: 120, label: 'Q1(a)', maxMarks: 3 },
{ id: 'resp-far', kind: 'response', x: 100, y: 520, w: 300, h: 90, responseForm: 'lines' }, // outside the part box
])
const part = payload.questions.find((q) => !q.is_container)
expect(payload.response_areas).toHaveLength(1) // previously dropped (no containing part)
expect(payload.response_areas[0].question_id).toBe(part?.id) // falls back to the part, not lost
})
it('respects a region persisted question over geometric nearest when no part contains it', () => {
const A = '11111111-1111-4111-8111-111111111111'
const B = '22222222-2222-4222-8222-222222222222'
const R = '33333333-3333-4333-8333-333333333333'
const payload = serializeCanvasShapes(template, [
{ id: 'pa', kind: 'part', x: 100, y: 120, w: 300, h: 80, label: 'A', questionId: A },
{ id: 'pb', kind: 'part', x: 100, y: 500, w: 300, h: 80, label: 'B', questionId: B },
{ id: R, kind: 'response', x: 120, y: 260, w: 200, h: 60, questionId: B }, // contained by neither; nearest is A
])
expect(payload.response_areas.find((r) => r.id === R)?.question_id).toBe(B) // persisted B wins over nearest A
})
})
+219
View File
@@ -0,0 +1,219 @@
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) })
})
// Resolve each region's owner question. Order: current geometric containment (a user who drags a
// region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved
// question (survives pixel-overflow drift and regions with no enclosing part) → nearest part on the
// page → first part → first question of any kind. exam_response_areas.question_id is NOT NULL, so we
// never silently drop a region while any question exists (only a template with zero questions can).
const questionIds = new Set(questions.map((q) => q.id))
const response_areas: TemplateReplacePayload['response_areas'] = []
for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
const nearestPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
const questionId =
(containingPart && partQuestionIds.get(containingPart.id))
|| persisted
|| (nearestPart && partQuestionIds.get(nearestPart.id))
|| questions[0]?.id
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
})
}
+12 -52
View File
@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw' import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
import { CCBaseShape } from './cc-types' import { CCBaseShape } from './cc-types'
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
import { logger } from '../../../debugConfig' import { logger } from '../../../debugConfig'
export interface ToolbarItem { export interface ToolbarItem {
@@ -40,44 +39,32 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
component(shape: T) { component(shape: T) {
const { const {
props: { w, h, isLocked }, props: { w, h, isLocked, headerColor },
} = shape } = shape
const toolbarItems = this.getToolbarItems(shape) const toolbarItems = this.getToolbarItems(shape)
return ( return (
<HTMLContainer <HTMLContainer
id={shape.id} id={shape.id}
className="cc-shape-container"
style={{ style={{
width: toDomPrecision(w), width: toDomPrecision(w),
height: toDomPrecision(h), height: toDomPrecision(h),
backgroundColor: shape.props.headerColor, '--shape-header-color': headerColor,
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius, } as React.CSSProperties}
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
overflow: 'hidden',
position: 'relative',
}}
> >
{/* Header */} {/* Header */}
<div <div
style={{ className="cc-shape-header"
backgroundColor: shape.props.headerColor, style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: isLocked ? 'not-allowed' : 'move',
pointerEvents: 'all',
position: 'relative',
zIndex: 1,
}}
> >
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span> <span className="shape-title">{shape.props.title}</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}> <div className="cc-shape-toolbar">
{toolbarItems.map((item) => ( {toolbarItems.map((item) => (
<button <button
key={item.id} key={item.id}
title={item.label} title={item.label}
style={{ opacity: item.isActive ? 1 : 0.7 }}
onClick={(e) => { onClick={(e) => {
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id) logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
e.preventDefault() e.preventDefault()
@@ -89,46 +76,19 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
}} }}
style={{
background: 'transparent',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: 'white',
opacity: item.isActive ? 1 : 0.7,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
fontSize: '16px',
width: '24px',
height: '24px',
zIndex: 100,
userSelect: 'none',
position: 'relative',
touchAction: 'none',
}}
> >
<div style={{ pointerEvents: 'none' }}> <div style={{ pointerEvents: 'none' }}>
{item.icon} {item.icon}
</div> </div>
</button> </button>
))} ))}
{isLocked && <span style={{ color: 'white' }}>🔒</span>} {isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div <div
style={{ className="cc-shape-content"
position: 'absolute', style={{ backgroundColor: shape.props.backgroundColor }}
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
backgroundColor: shape.props.backgroundColor,
}}
> >
{this.renderContent(shape)} {this.renderContent(shape)}
</div> </div>

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