Compare commits

...
Author SHA1 Message Date
kcar 34ea1e3f08 G1 surface homework in lesson planner UI 2026-07-24 19:20:43 +01:00
kcarandClaude Opus 4.8 c346493a34 WS-2 R3: containers/preamble as background backdrops, tick-N, no false overlap flag
app-ci-deploy / test-build-deploy (push) Has been cancelled
From KC's 2nd review:
- z-order: boundaries, preamble bands and container frames drawn behind; figures,
  responses and parts on top (a figure's description tooltip is no longer blocked
  by the preamble box). Preamble + containers are pointerEvents:none.
- the 'large box around all content' is gone: a container renders as a small
  corner tab (label + total marks), not an enclosing box; intermediate part-groups
  keep a faint dashed nesting frame. Main-question extent is shown by its boundaries.
- MC 'tick N' count (select_n) surfaced next to the option boxes.
- fixed the spurious 'overlapping shapes' review flag on containers (they legitimately
  enclose their children; only genuine partial overlaps between non-containers flag now).
tsc clean; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 13:13:46 +00:00
kcarandClaude Opus 4.8 81e1b5ed25 WS-2 R2: render answer sub-structure on response boxes (components/MC boxes/units)
app-ci-deploy / test-build-deploy (push) Has been cancelled
From KC's review — the canvas now draws the detail the spike detected:
- numbered sub-answer bands ('suggest two' → dashed dividers + 1/2 labels),
- per-option MC selection boxes (drawn from meta.boxes rel coords; filled tint
  when detected marked),
- the final-answer line's expected quantity + unit pill.
Region meta (components/boxes/final_answer_line + figure name/desc) is carried
through load→shape props (metaJson)→render, and round-trips on save via
exam_response_areas.meta. tsc clean; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 12:57:01 +00:00
kcarandClaude Opus 4.8 e0e36225fe WS-2 items 4+5: draw structure on canvas + preserve N-deep tree on save
app-ci-deploy / test-build-deploy (push) Has been cancelled
Round-trip fix (item 5): serializeCanvasShapes no longer re-derives the question
tree from boundary pairs (which flattened 3-deep → 2-tier). The whole tree is now
carried as 'part' shapes that hold parentId + isContainer + depth; save preserves
the real parent_id chains (persisted parent wins; geometry is only a fallback for
manually-drawn shapes). Boundary-pair synthesis is kept but attaches to a covering
container box when one exists. New test locks a 3-deep Q→part→subpart round-trip.

Draw structure (item 4): shapesFromTemplate now draws containers too (were skipped),
each with nesting depth. examCanvasShapes renders container frames (transparent,
depth-hued border), PRINTS MARKS on part/container boxes (◆N pill; were never
drawn), and NAMES context figures (figure name as label + description tooltip +
'→ Q' tether pill; was an anonymous purple box). Context name/description round-trip
via exam_response_areas.meta.

Threads new shape props through the tldraw util + setup page + types. Adds meta to
ExamResponseArea + replace payload. tsc: no new errors; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 12:13:13 +00:00
kcar dbab592ce8 Merge P4 digital-text viewer
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-03 02:09:34 +00:00
kcarandClaude Opus 4.8 0c46582ec3 P4 app: Digital text viewer + extraction audit chip on setup page
'Digital text' button opens the digital-replica markdown (stem text, parts, marks,
answer-space placeholders, spec refs) in a dialog with download. A 'Marks N/M' chip
surfaces the extraction cover-total reconciliation (green=ok / amber=under-read) from
extraction_meta.audit. examRepository.getDigitalText + DigitalTextResponse type.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 02:09:34 +00:00
kcar e6ecc723cc Merge exam-canvas form badge
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 23:55:43 +00:00
kcarandClaude Opus 4.8 0f44dc614c Exam canvas: show recognised response FORM on each ghost
The extraction service types every answer space (multiple choice, draw-on, table,
ruled lines…), but the canvas rendered them all as a generic blue 'Response' box.
Add a small form badge (bottom-right) so the recognition is legible at a glance;
context regions show their type too. Colour-by-kind is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:55:43 +00:00
kcar 1bb2011e58 Merge exam-bank corpus coverage UI
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:35:35 +00:00
kcarandClaude Opus 4.8 f4be133069 Exam bank: corpus coverage dashboard (/exam-marker/corpus)
New "Exam bank" page displaying the state of the collected past-paper corpus:
per-specification coverage of question papers / mark schemes / examiner reports,
with global + filtered rollups, board/level/subject filters, a "sciences only"
default, and an expandable per-session table (QP/MS/ER present ✓/–). Reads
GET /api/exam/corpus. Dashboard gets an "Exam bank" entry point.

examRepository.getCorpus + corpus types. tsc clean on the changed files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:35:35 +00:00
kcar d47e0205c9 Merge mode-3 bank crop thumbnails
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:19:08 +00:00
kcarandClaude Opus 4.8 11d19778d6 Mode-3: crop thumbnails on question bank cards
Render a preview per bank question, client-side, from the source PDF cropped to
the question's bounds — makes the bank visually browsable like the spike cockpit.

bankCrops.ts: renders each template's PDF once (cached) via the existing pdf.js
loader (already at the 780px coordinate space the bounds live in), then crops the
page image to (bounds.x, bounds.y - pageTop, w, h) → a small PNG data-URL; decoded
page images are cached too. QuestionBankPage: a lazy <QuestionCrop> at the top of
each card (spinner → image, or "no preview" when a question has no bounds/PDF).

No API/infra change. tsc clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:19:08 +00:00
kcar a80bdea140 Merge mode-3 question bank UI
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:09:28 +00:00
kcarandClaude Opus 4.8 905626f494 Mode-3: Question bank page + custom-paper assembly UI
New /exam-marker/bank page: browse leaf questions across your papers, filter by
spec point (the spec-planning surface) and subject via facet chips, select
questions into a basket, and "Create paper" → POST /custom-papers → opens the new
template in setup. Dashboard gets a "Question bank" entry point.

examRepository.getBank + createCustomPaper; BankQuestion/BankResponse/custom-paper
types. tsc clean. Pairs with the mode-3 API endpoints (already merged).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:09:28 +00:00
kcar b52f6c54ba Merge fx-5-scan-ingestion
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 21:48:32 +00:00
kcar e6a2480818 Merge fx-8-response-area-attach 2026-07-02 21:48:32 +00:00
kcar 2b33aa80eb Merge fx-4-confirm-dismiss 2026-07-02 21:48:32 +00:00
CC WorkerandClaude Opus 4.8 7a1dd4dccc FX-5: scan-ingestion UI on the marking page
The marking flow read student_submissions but there was no way to create them —
print→scan→mark could not be completed in-app. Add an "Upload scans" control to
the marking queue that posts PDFs to the existing POST /batches/{id}/scans and
refreshes the queue.

- examRepository.uploadScan(batchId, file, {studentId?, matchingMethod?}): multipart
  upload (axios sets the boundary), returns the created/updated StudentSubmission.
- ExamMarkingPage: "Upload scans" button + hidden multi-file PDF input; uploads each
  with matching_method='ordered' (fills the roster in order; unmatched scans become
  reconcilable submissions), then reloads the queue with a success/failure summary.

No API or schema change (endpoint already existed). tsc clean; behaviour is covered
by the cc-browser-tester card (FX-V).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:44:04 +00:00
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 f72ea7189c FX-4: confirm/dismiss UI for AI ghost suggestions
The S5-7 review layer could surface unconfirmed AI shapes but had no way to
resolve them (confirmed was never set true), so the only "dismiss" was deletion
and the unconfirmed count never dropped. Add the resolution loop:

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 21:20:57 +00:00
CC Worker ef13a124dd Merge S5-7 G6 review wiring
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/pages/exam/setup/ExamTemplateSetupPage.tsx
#	src/pages/exam/setup/examCanvasShapes.tsx
#	src/utils/exam-canvas/model.ts
2026-06-07 20:06:45 +00:00
CC Worker ffa0ad85ac Merge S4-11: marking flow + class results + same-origin API proxy fix
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:04:07 +00:00
kcarandClaude Opus 4.8 92f9dfef82 S5-7: basic G6 review wiring (dashed/translucent AI shapes, confidence, flags)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 20:56:57 +01:00
kcar 824031f2c0 feat(exam): add auto-map PDF canvas refresh
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:46:41 +01:00
kcar 7db852aaff [verified] route app-dev API through same-origin proxy 2026-06-07 20:43:20 +01:00
CC Worker afc0371dd9 Merge S5-6 layout/provenance types + repository (App)
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/utils/exam-canvas/model.ts
2026-06-07 19:22:24 +00:00
kcar 7326b9f3be [verified] fix app-dev api proxy and build env 2026-06-07 20:20:42 +01:00
kcar cd8ac38d39 Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results 2026-06-07 20:13:43 +01:00
kcar 469bcc0517 [verified] align app exam layout payloads 2026-06-07 20:05:49 +01:00
kcar e899af303d Make exam boundaries page-width lines
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:01:54 +01:00
kcar 7a01b3e8f6 Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results
# Conflicts:
#	src/AppRoutes.tsx
#	src/pages/exam/index.ts
#	src/services/exam/examRepository.ts
#	src/types/exam.types.ts
2026-06-07 19:58:38 +01:00
kcar bff91a4b17 feat(exam): S4-11 marking flow and class results 2026-06-07 19:50:20 +01:00
CC WorkerandClaude Sonnet 4.6 66f35b8ae4 fix(exam): compact top bar, collapsible guide panel
app-ci-deploy / test-build-deploy (push) Has been cancelled
Top bar reduced to single-line height: Back becomes an icon button, caption
line removed (detail lives in the guide), Save button size=small. Guide
panel defaults collapsed and toggles via a ? icon button at bottom-right
so it doesn't occupy permanent canvas real estate.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

tsc before: 177 (master)
tsc after:  152 (reduction of 25 errors, well below 160 acceptable threshold)
2026-06-01 02:29:38 +00:00
CC Worker 1505adb154 feat(ux): R4 error boundaries — wrap Tldraw in ErrorBoundary (J1)
- Added import for ErrorBoundary from src/components/ErrorBoundary.tsx
- Wrapped {store && <Tldraw>} block in ErrorBoundary with 'Canvas failed to load — Reload' fallback
- ErrorBoundary.tsx already existed with full error/reload UI
- Timetable pages (J2) already have loading states via timetableStore loading flags
- tsc: 177 (master baseline) → 173 on this branch (no regression)
2026-06-01 02:20:34 +00:00
CC Worker ade0be97f6 feat(auth): R4 auth reliability — fix PKCE race condition + RequireAuth loading skeleton 2026-06-01 02:16:05 +00:00
CC Worker 96db086ecb fix: resolve duplicate identifier in cc-graph-shapes, useMemo import in CCGraphNavPanel 2026-06-01 01:36:37 +00:00
CC Worker 6610abbd72 fix: resolve duplicate identifier in cc-graph-shapes 2026-06-01 01:35:11 +00:00
CC Worker 489c2208c5 fix: add DarkModeSync to propagate tldraw dark mode to document root
tldraw v3.6 sets tl-theme__dark class on .tl-container, not data-color-mode
on document.documentElement. DarkModeSync lives inside TLDrawProvider and
mirrors the active color scheme to [data-color-mode] on <html>, activating
--cc-* token overrides in cc-design-system.css globally.
2026-06-01 00:47:24 +00:00
CC Worker 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
73 changed files with 6855 additions and 2236 deletions
+3
View File
@@ -47,3 +47,6 @@ docker-compose.override.yml
# Local environment variants
.env.dev
.env.prod
# Playwright test artifacts
test-results/
+61 -7
View File
@@ -7,18 +7,45 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
COPY . .
# Vite bakes VITE_* values at build time, so compose must choose the env file
# during image build, not only at container runtime.
ARG ENV_FILE=.env
COPY ${ENV_FILE} .env
# Run build with production mode
RUN npm run build -- --mode production
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
ARG VITE_API_BASE
ARG VITE_API_URL
ARG VITE_APP_NAME
ARG VITE_APP_HMR_URL
ARG VITE_DEV
ARG VITE_FRONTEND_SITE_URL
ARG VITE_SEARCH_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_SUPABASE_URL
ARG VITE_SUPER_ADMIN_EMAIL
ARG VITE_TLSYNC_URL
ARG VITE_WHISPERLIVE_URL
# Run build with production mode. Keep these as build-step environment values
# rather than final-image ENV entries; Vite still embeds the public client config
# into the static bundle, but nginx image metadata does not need them.
RUN VITE_API_BASE="${VITE_API_BASE}" \
VITE_API_URL="${VITE_API_URL}" \
VITE_APP_NAME="${VITE_APP_NAME}" \
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
VITE_DEV="${VITE_DEV}" \
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
npm run build -- --mode production
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
# Create a simple nginx configuration
RUN echo 'server { \
listen 3000; \
@@ -28,6 +55,33 @@ RUN echo 'server { \
expires -1; \
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
} \
location = /health { \
proxy_pass http://192.168.0.64:18000/health; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location /__ccapi/ { \
proxy_pass http://192.168.0.64:18000/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location /__supabase/ { \
proxy_pass http://192.168.0.94:8000/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_http_version 1.1; \
} \
location /api/ { \
proxy_pass http://192.168.0.64:18000/api/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location / { \
try_files $uri $uri/ /index.html; \
expires 30d; \
+22 -1
View File
@@ -16,7 +16,28 @@ services:
context: .
dockerfile: Dockerfile
args:
ENV_FILE: .env.dev
# app-dev is served by nginx on the app host; browser API calls must stay
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
# strips that prefix before forwarding, preserving mixed backend routes
# such as /api/exam, /me/bootstrap, and /database/timetable.
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
# leading-slash URL is resolved against window.location.origin in
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
VITE_API_BASE: /__ccapi
VITE_API_URL: /__ccapi
VITE_SUPABASE_URL: /__supabase
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
VITE_DEV: ${VITE_DEV:-false}
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
env_file:
- .env.dev
ports:
-1
View File
@@ -84,7 +84,6 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"react-refresh": "^0.14.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^1.6.0"
-1
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-import': {},
},
+51 -10
View File
@@ -1,5 +1,8 @@
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { useMemo, useEffect } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { useTLDraw } from './contexts/TLDrawContext';
import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext';
@@ -8,21 +11,59 @@ import AppRoutes from './AppRoutes';
import { ErrorBoundary } from './components/ErrorBoundary';
import React from 'react';
const App = React.memo(() => (
<ErrorBoundary>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
/**
* Syncs tldraw's color scheme to data-color-mode on <html>.
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
* to the document root so --cc-* token overrides apply globally.
*/
const DarkModeSync: React.FC = () => {
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
useEffect(() => {
const scheme = tldrawPreferences?.colorScheme ?? 'system';
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
return null;
};
const App = React.memo(() => {
const prefersDarkMode =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const appTheme = useMemo(() => {
// App is outside TLDrawProvider so tldrawPreferences is always null here.
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
// updating data-color-mode once tldraw preferences are loaded.
const mode = prefersDarkMode ? 'dark' : 'light';
return createTheme({ palette: { mode } });
}, [prefersDarkMode]);
return (
<ErrorBoundary>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AuthProvider>
<UserProvider>
<TLDrawProvider>
<AppRoutes />
<DarkModeSync />
<ThemeProvider theme={appTheme}>
<CssBaseline />
<AppRoutes />
</ThemeProvider>
</TLDrawProvider>
</UserProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</ErrorBoundary>
));
</BrowserRouter>
</ErrorBoundary>
);
});
App.displayName = import.meta.env.VITE_APP_NAME;
+28 -1
View File
@@ -31,7 +31,14 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => <div>Exam Marker</div> }));
vi.mock('./pages/exam', () => ({
ExamDashboardPage: () => <div>Exam Marker</div>,
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
MarkSchemePage: () => <div>Mark Scheme editor</div>,
ExamMarkingPage: () => <div>Exam Marking</div>,
ExamResultsPage: () => <div>Exam Results</div>,
ResultsWidget: () => <div>Results Widget</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
@@ -46,6 +53,7 @@ vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
}));
vi.mock('./pages/timetable', () => ({
TimetablePage: () => <div>Timetable</div>,
TimetableListPage: () => <div>Timetable List</div>,
ClassesPage: () => <div>Classes</div>,
LessonPage: () => <div>Lesson</div>,
TaughtLessonsPage: () => <div>Taught Lessons</div>,
@@ -121,3 +129,22 @@ describe('/admin route authorization', () => {
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();
});
});
+40 -7
View File
@@ -7,7 +7,8 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage, CorpusPage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
@@ -30,6 +31,7 @@ import DashboardPage from './pages/user/dashboardPage';
// Timetable Module Pages
import {
TimetablePage,
TimetableListPage,
ClassesPage,
LessonPage,
TaughtLessonsPage,
@@ -45,8 +47,6 @@ import {
} from './pages/timetable';
const FullContextRoutes: React.FC = () => {
// Only block on Supabase profile being ready — Neo4j contexts initialize in the background.
// Individual pages handle their own Neo4j loading states.
const { isInitialized: isUserInitialized } = useUser();
if (!isUserInitialized) {
@@ -68,6 +68,31 @@ const FullContextRoutes: React.FC = () => {
return <Outlet />;
};
const RequireAuth: React.FC = () => {
const { user, isAuthResolving } = useAuth();
if (isAuthResolving) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}
>
<CircularProgress />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
};
const AppRoutes: React.FC = () => {
const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation();
@@ -136,13 +161,15 @@ const AppRoutes: React.FC = () => {
/>
{/* Authentication only routes - only render if all contexts are initialized */}
{user && (
<Route element={<RequireAuth />}>
<Route element={<FullContextRoutes />}>
{/* Timetable Module Routes */}
<Route path="/timetable" element={<TimetablePage />} />
<Route path="/timetable" element={<TimetableListPage />} />
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
<Route path="/classes" element={<ClassesPage />} />
<Route path="/my-classes" element={<MyClassesPage />} />
<Route path="/classes/:classId" element={<ClassDetailPage />} />
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
<Route path="/student-lessons" element={<StudentLessonsPage />} />
<Route path="/lesson-plans" element={<LessonPlansPage />} />
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
@@ -156,7 +183,13 @@ const AppRoutes: React.FC = () => {
{/* Existing Routes */}
<Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<CCExamMarker />} />
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
<Route path="/exam-marker/corpus" element={<ErrorBoundary><CorpusPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></ErrorBoundary>} />
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
@@ -167,7 +200,7 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
)}
</Route>
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
+11 -2
View File
@@ -18,6 +18,7 @@ export interface AuthContextType {
signOut: () => Promise<void>;
clearError: () => void;
bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
}
export const AuthContext = createContext<AuthContextType>({
@@ -29,7 +30,8 @@ export const AuthContext = createContext<AuthContextType>({
signIn: async () => {},
signOut: async () => {},
clearError: () => {},
bootstrapData: null
bootstrapData: null,
isAuthResolving: false
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
const [isAuthResolving, setIsAuthResolving] = useState(false);
const persistSession = useCallback((session: Session | null) => {
@@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (event === 'SIGNED_IN') {
persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
@@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null);
}
setLoading(false);
setIsAuthResolving(false);
return;
}
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
persistSession(session ?? null);
setIsAuthResolving(!!session?.user);
if (session?.user) {
try {
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
@@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAccessToken(null);
}
setLoading(false);
setIsAuthResolving(false);
return;
}
if (event === 'SIGNED_OUT') {
persistSession(null);
setIsAuthResolving(false);
setUser(null);
setUserRole(null);
setAccessToken(null);
@@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
signIn,
signOut,
clearError,
bootstrapData
bootstrapData,
isAuthResolving
}}
>
{children}
+2
View File
@@ -23,6 +23,7 @@ export type LogCategory =
| 'neo-institute-context'
| 'auth-service'
| 'graph-service'
| 'graph-sidebar'
| 'registration-service'
| 'provisioning-service'
| 'snapshot-service'
@@ -111,6 +112,7 @@ export type LogCategory =
| 'user-context'
| 'neo-user-context'
| 'neo-institute-context'
| 'cc-live-transcription'
| 'search-service';
interface LogConfig {
+4 -5
View File
@@ -1,7 +1,6 @@
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
@import './tailwind-compat.css';
/* Base styles */
html,
@@ -152,10 +151,10 @@ body {
.login-role-header {
font-weight: 600 !important;
color: #1976d2 !important;
color: var(--cc-action-primary) !important;
margin-bottom: 24px !important;
padding-bottom: 8px !important;
border-bottom: 2px solid #1976d2 !important;
border-bottom: 2px solid var(--cc-action-primary) !important;
width: fit-content !important;
}
+11
View File
@@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
</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 ? (
+23 -7
View File
@@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
import { EmailCredentials } from '../../services/auth/authService';
import { logger } from '../../debugConfig';
const getLoginErrorMessage = (error: unknown): string => {
const status = (error as { status?: number } | null)?.status;
const code = (error as { code?: string } | null)?.code;
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
return 'Please confirm your email before logging in';
}
if (status === 401 || code === 'invalid_grant') {
return 'Invalid email or password';
}
return error instanceof Error ? error.message : 'Login failed';
};
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { user, signIn } = useAuth();
const { user, signIn, isAuthResolving } = useAuth();
const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user
hasUser: !!user,
isAuthResolving
});
useEffect(() => {
if (user) {
if (!isAuthResolving && user) {
navigate('/dashboard');
}
}, [user, navigate]);
}, [user, isAuthResolving, navigate]);
const handleLogin = async (credentials: EmailCredentials) => {
try {
setError(null);
await signIn(credentials.email, credentials.password);
navigate('/dashboard');
} catch (error) {
logger.error('login-page', '❌ Login failed', error);
setError(error instanceof Error ? error.message : 'Login failed');
const message = getLoginErrorMessage(error);
setError(message);
throw error;
}
};
if (user) {
if (user && !isAuthResolving) {
return null;
}
+6 -1
View File
@@ -31,6 +31,7 @@ import {
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Upload,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon,
Description as FileIcon,
@@ -39,8 +40,12 @@ import {
PlayArrow as ProcessIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon
Info as InfoIcon,
InsertDriveFile,
} from '@mui/icons-material';
const FolderOpen = FolderOpenIcon;
const Refresh = RefreshIcon;
const Info = InfoIcon;
import { supabase } from '../../supabaseClient';
import {
pickDirectory,
+156
View File
@@ -0,0 +1,156 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, CircularProgress, Container,
Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckIcon from '@mui/icons-material/Check';
import RemoveIcon from '@mui/icons-material/Remove';
import { examRepository } from '../../services/exam/examRepository';
import type { CorpusResponse, CorpusSpec, CorpusDocType } from '../../types/exam.types';
const DOCS: CorpusDocType[] = ['QP', 'MS', 'ER'];
const DOC_LABEL: Record<CorpusDocType, string> = { QP: 'Question papers', MS: 'Mark schemes', ER: 'Examiner reports' };
const SCIENCES = new Set(['Biology', 'Chemistry', 'Physics']);
const CorpusPage: React.FC = () => {
const navigate = useNavigate();
const [corpus, setCorpus] = useState<CorpusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [board, setBoard] = useState<string | null>(null);
const [level, setLevel] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [sciencesOnly, setSciencesOnly] = useState(true);
const load = useCallback(async () => {
setLoading(true); setError(null);
try { setCorpus(await examRepository.getCorpus()); }
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
finally { setLoading(false); }
}, []);
useEffect(() => { void load(); }, [load]);
const allSpecs = useMemo<CorpusSpec[]>(
() => (corpus?.boards ?? []).flatMap((b) => b.specs),
[corpus],
);
const boards = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.board))).sort(), [allSpecs]);
const levels = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.level))).sort(), [allSpecs]);
const subjects = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.subject))).sort(), [allSpecs]);
const specs = useMemo(() => allSpecs.filter((s) =>
(!board || s.board === board) && (!level || s.level === level) && (!subject || s.subject === subject)
&& (!sciencesOnly || SCIENCES.has(s.subject))
).sort((a, b) => (a.board + a.level + a.subject + a.spec_code).localeCompare(b.board + b.level + b.subject + b.spec_code)),
[allSpecs, board, level, subject, sciencesOnly]);
const filteredTotals = useMemo(() => specs.reduce((acc, s) => {
acc.specs += 1; acc.papers += s.n_papers;
DOCS.forEach((d) => { acc[d] += s.counts[d] ?? 0; });
return acc;
}, { specs: 0, papers: 0, QP: 0, MS: 0, ER: 0 } as Record<string, number>), [specs]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>Exam Marker</Button>
<Typography variant="h4" component="h1" fontWeight={700}>Exam bank</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The collected past-paper corpus question papers, mark schemes and examiner reports per specification.
</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (
<>
{/* Summary of the current selection */}
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
<Chip color="default" label={`${filteredTotals.specs} specifications`} />
<Chip color="default" label={`${filteredTotals.papers} papers`} />
<Chip color="primary" variant="outlined" label={`${filteredTotals.QP} question papers`} />
<Chip color="success" variant="outlined" label={`${filteredTotals.MS} mark schemes`} />
<Chip color="warning" variant="outlined" label={`${filteredTotals.ER} examiner reports`} />
</Stack>
{/* Filters */}
<Stack spacing={0.75} sx={{ mb: 2 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip size="small" color={sciencesOnly ? 'secondary' : 'default'} variant={sciencesOnly ? 'filled' : 'outlined'} label="Sciences only" onClick={() => setSciencesOnly((v) => !v)} />
<Box sx={{ width: 12 }} />
<Typography variant="caption" color="text.secondary">Board:</Typography>
<Chip size="small" label="all" variant={board ? 'outlined' : 'filled'} color={board ? 'default' : 'primary'} onClick={() => setBoard(null)} />
{boards.map((b) => <Chip key={b} size="small" label={b} variant={board === b ? 'filled' : 'outlined'} color={board === b ? 'primary' : 'default'} onClick={() => setBoard(b)} />)}
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary">Level:</Typography>
<Chip size="small" label="all" variant={level ? 'outlined' : 'filled'} color={level ? 'default' : 'primary'} onClick={() => setLevel(null)} />
{levels.map((l) => <Chip key={l} size="small" label={l} variant={level === l ? 'filled' : 'outlined'} color={level === l ? 'primary' : 'default'} onClick={() => setLevel(l)} />)}
{!sciencesOnly && (<>
<Box sx={{ width: 12 }} />
<Typography variant="caption" color="text.secondary">Subject:</Typography>
<Chip size="small" label="all" variant={subject ? 'outlined' : 'filled'} color={subject ? 'default' : 'primary'} onClick={() => setSubject(null)} />
{subjects.map((s) => <Chip key={s} size="small" label={s} variant={subject === s ? 'filled' : 'outlined'} color={subject === s ? 'primary' : 'default'} onClick={() => setSubject(s)} />)}
</>)}
</Stack>
</Stack>
{specs.length === 0 ? (
<Alert severity="info">No specifications match the current filters.</Alert>
) : specs.map((s) => (
<Accordion key={s.spec_code} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', width: '100%' }}>
<Typography sx={{ fontWeight: 700, minWidth: 260 }}>
{s.level} {s.subject} <Typography component="span" color="text.secondary" variant="body2">· {s.spec_code}</Typography>
</Typography>
<Chip size="small" label={`${s.n_papers} papers`} />
<Box sx={{ flex: 1 }} />
{DOCS.map((d) => (
<Chip key={d} size="small" variant="outlined"
color={s.counts[d] ? (d === 'QP' ? 'primary' : d === 'MS' ? 'success' : 'warning') : 'default'}
label={`${d} ${s.counts[d] ?? 0}`} />
))}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Session</TableCell>
<TableCell>Paper</TableCell>
<TableCell>Tier</TableCell>
{DOCS.map((d) => <TableCell key={d} align="center">{DOC_LABEL[d]}</TableCell>)}
</TableRow>
</TableHead>
<TableBody>
{s.papers.map((p, i) => (
<TableRow key={`${p.paper_code}-${p.session}-${i}`} hover>
<TableCell>{p.session ?? '—'}</TableCell>
<TableCell>{p.paper_code ?? '—'}</TableCell>
<TableCell>{p.tier ?? '—'}</TableCell>
{DOCS.map((d) => (
<TableCell key={d} align="center">
{p.docs[d] ? <CheckIcon fontSize="small" color="success" /> : <RemoveIcon fontSize="small" color="disabled" />}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</Box>
</AccordionDetails>
</Accordion>
))}
</>
)}
</Container>
);
};
export default CorpusPage;
+395
View File
@@ -0,0 +1,395 @@
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 PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
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>
<Stack direction="row" spacing={1}>
<Button variant="outlined" startIcon={<LibraryBooksIcon />} onClick={() => navigate('/exam-marker/corpus')}>
Exam bank
</Button>
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
Question bank
</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
</Stack>
</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;
+280
View File
@@ -0,0 +1,280 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { v5 as uuidv5 } from 'uuid';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
Divider,
List,
ListItemButton,
ListItemText,
Stack,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import TableChartIcon from '@mui/icons-material/TableChart';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
}
const ExamMarkingPage: React.FC = () => {
const { batchId } = useParams<{ batchId: string }>();
const navigate = useNavigate();
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [marks, setMarks] = useState<Record<string, string>>({});
const [comments, setComments] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
setLoading(true);
setError(null);
try {
const nextQueue = await examRepository.getBatchQueue(batchId);
setQueue(nextQueue);
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [batchId]);
useEffect(() => {
void load();
}, [load]);
const markableQuestions = useMemo(
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
[template],
);
const onScanFiles = useCallback(async (files: FileList | null) => {
if (!batchId || !files || files.length === 0) return;
setUploading(true); setError(null); setMessage(null);
let ok = 0;
const failures: string[] = [];
for (const file of Array.from(files)) {
try {
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
// become an unmatched submission the teacher can reconcile.
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
ok += 1;
} catch (e) {
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
await load();
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
if (failures.length) setError(failures.join(' · '));
}, [batchId, load]);
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
const saveSelected = async () => {
if (!batchId || !selected) return;
setSaving(true);
setError(null);
setMessage(null);
try {
const writes = markableQuestions
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
for (const { q, raw, comment } of writes) {
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
throw new Error(`Invalid mark for ${q.label}`);
}
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
submission_id: selected.id,
question_id: q.id,
awarded_marks: awarded,
comment: comment?.trim() || undefined,
confirmed: true,
});
}
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
setMarks({});
setComments({});
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const chooseStudent = (submission: StudentSubmission) => {
setSelectedId(submission.id);
setMarks({});
setComments({});
setMessage(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
}
if (error && !queue) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Alert severity="error">{error}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
Back to Exam Marker
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 3 }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
<Typography variant="body2" color="text.secondary">
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
</Typography>
</Box>
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
Results
</Button>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
{markableQuestions.length === 0 && (
<Alert severity="warning">
This template has no markable parts yet. Add parts in template setup before entering marks.
</Alert>
)}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
<Typography variant="h6">Marking queue</Typography>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon fontSize="small" />}
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? 'Uploading…' : 'Upload scans'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
hidden
onChange={(e) => { void onScanFiles(e.target.files); }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
</Stack>
<List dense disablePadding>
{(queue?.submissions ?? []).map((submission) => (
<ListItemButton
key={submission.id}
selected={submission.id === selectedId}
onClick={() => chooseStudent(submission)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemText
primary={submission.student_name || submission.student_id || 'Unknown student'}
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
/>
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
</ListItemButton>
))}
</List>
</CardContent>
</Card>
<Card variant="outlined" sx={{ flex: 1 }}>
<CardContent>
{selected ? (
<Stack spacing={2}>
<Box>
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
</Box>
<Divider />
{markableQuestions.map((q: ExamQuestion) => (
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
<Box sx={{ minWidth: 150 }}>
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
</Box>
<TextField
label="Mark"
type="number"
size="small"
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
value={marks[q.id] ?? ''}
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
sx={{ width: { xs: '100%', sm: 120 } }}
/>
<TextField
label="Comment"
size="small"
value={comments[q.id] ?? ''}
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
sx={{ flex: 1 }}
/>
</Stack>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
{saving ? 'Saving…' : 'Save marks'}
</Button>
</Box>
</Stack>
) : (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
)}
</CardContent>
</Card>
</Stack>
</Stack>
</Container>
);
};
export default ExamMarkingPage;
+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;
+207
View File
@@ -0,0 +1,207 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert, Box, Button, Card, CardActionArea, CardContent, Chip, CircularProgress, Container,
Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import { examRepository } from '../../services/exam/examRepository';
import type { BankQuestion, BankResponse } from '../../types/exam.types';
import { cropQuestion } from './bankCrops';
const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => {
const [src, setSrc] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let alive = true;
cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds })
.then((d) => { if (alive) { setSrc(d); setDone(true); } })
.catch(() => { if (alive) setDone(true); });
return () => { alive = false; };
}, [q.template_id, q.page, q.bounds]);
if (src) {
return <Box component="img" src={src} alt="" loading="lazy" sx={{ width: '100%', maxHeight: 150, objectFit: 'cover', objectPosition: 'top', display: 'block', bgcolor: '#fff', borderBottom: 1, borderColor: 'divider' }} />;
}
return (
<Box sx={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: 1, borderColor: 'divider', color: 'text.disabled', fontSize: 12 }}>
{done ? 'no preview' : <CircularProgress size={16} />}
</Box>
);
};
const QuestionBankPage: React.FC = () => {
const navigate = useNavigate();
const [bank, setBank] = useState<BankResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [specRef, setSpecRef] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [title, setTitle] = useState('');
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
setLoading(true); setError(null);
try {
setBank(await examRepository.getBank({ specRef: specRef ?? undefined, subject: subject ?? undefined }));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [specRef, subject]);
useEffect(() => { void load(); }, [load]);
const toggle = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectedList = useMemo(() => Array.from(selected), [selected]);
const selectedQuestions = useMemo(
() => (bank?.questions ?? []).filter((q) => selected.has(q.id)),
[bank, selected],
);
const totalMarks = selectedQuestions.reduce((s, q) => s + (q.max_marks ?? 0), 0);
// facet lists (sorted) with counts
const specFacets = Object.entries(bank?.facets.spec_ref ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
const subjectFacets = Object.entries(bank?.facets.subject ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
const create = async () => {
setCreating(true); setError(null);
try {
const subj = subject ?? selectedQuestions[0]?.paper.subject ?? undefined;
const res = await examRepository.createCustomPaper({
title: title.trim() || `Custom paper — ${new Date().toISOString().slice(0, 10)}`,
subject: subj ?? undefined,
question_ids: selectedList,
});
navigate(`/exam-marker/${res.id}/setup`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setCreating(false);
setDialogOpen(false);
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Box>
<Typography variant="h4" component="h1" fontWeight={700}>Question bank</Typography>
<Typography variant="body2" color="text.secondary">
Plan by spec point, then assemble a custom paper from questions across your papers.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<PlaylistAddCheckIcon />}
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{/* Filters */}
<Stack spacing={1} sx={{ mb: 2 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Spec point:</Typography>
<Chip size="small" label="all" color={specRef ? 'default' : 'primary'} onClick={() => setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
{specFacets.map(([ref, count]) => (
<Chip key={ref} size="small" label={`${ref} · ${count}`} color={specRef === ref ? 'primary' : 'default'} variant={specRef === ref ? 'filled' : 'outlined'} onClick={() => setSpecRef(ref)} />
))}
</Stack>
{subjectFacets.length > 1 && (
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Subject:</Typography>
<Chip size="small" label="all" color={subject ? 'default' : 'secondary'} onClick={() => setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
{subjectFacets.map(([s, count]) => (
<Chip key={s} size="small" label={`${s} · ${count}`} color={subject === s ? 'secondary' : 'default'} variant={subject === s ? 'filled' : 'outlined'} onClick={() => setSubject(s)} sx={{ textTransform: 'capitalize' }} />
))}
</Stack>
)}
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (bank?.questions.length ?? 0) === 0 ? (
<Alert severity="info">
No questions match yet. Questions appear here once your templates have parts with spec points
(set them in the mark-scheme editor). Auto-map + confirm on a paper to populate it.
</Alert>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1.5 }}>
{(bank?.questions ?? []).map((q: BankQuestion) => {
const on = selected.has(q.id);
return (
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
<CardActionArea onClick={() => toggle(q.id)}>
<QuestionCrop q={q} />
<CardContent sx={{ pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
{on ? <CheckCircleIcon color="primary" fontSize="small" /> : <RadioButtonUncheckedIcon color="disabled" fontSize="small" />}
</Box>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 0.75 }}>
<Chip size="small" label={`${q.max_marks ?? 0} marks`} />
{q.answer_type && <Chip size="small" variant="outlined" label={q.answer_type} />}
{q.spec_ref && <Chip size="small" color="info" label={q.spec_ref} />}
</Stack>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', textTransform: 'capitalize' }}>
{[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
})}
</Box>
</>
)}
<Dialog open={dialogOpen} onClose={() => !creating && setDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Create custom paper</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new
template you can open in setup and use for marking.
</Typography>
<TextField
autoFocus
fullWidth
label="Paper title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} disabled={creating}>Cancel</Button>
<Button variant="contained" onClick={create} disabled={creating} startIcon={creating ? <CircularProgress size={16} color="inherit" /> : undefined}>
{creating ? 'Creating…' : 'Create & open'}
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default QuestionBankPage;
+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;
+68
View File
@@ -0,0 +1,68 @@
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
// once (cached) and reuses it for all of that template's questions. No API/infra change.
import { examRepository } from '../../services/exam/examRepository';
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
const imageCache = new Map<string, Promise<HTMLImageElement>>();
function templateRender(templateId: string): Promise<PdfPageImage[]> {
let p = renderCache.get(templateId);
if (!p) {
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
renderCache.set(templateId, p);
}
return p;
}
function loadImage(src: string): Promise<HTMLImageElement> {
let p = imageCache.get(src);
if (!p) {
p = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image decode failed'));
img.src = src;
});
imageCache.set(src, p);
}
return p;
}
export interface CropInput {
templateId: string;
page: number | null;
bounds: Record<string, number> | null;
}
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
const { templateId, page, bounds } = input;
if (!bounds || !page) return null;
const pages = await templateRender(templateId);
const pageImg = pages.find((pg) => pg.pageNumber === page);
if (!pageImg) return null;
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
let yTop = 0;
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
const sx = Math.max(0, Math.round(bounds.x ?? 0));
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
if (sw <= 1 || sh <= 1) return null;
const img = await loadImage(pageImg.src);
const scale = Math.min(1, maxWidth / sw);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(sw * scale));
canvas.height = Math.max(1, Math.round(sh * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
+8
View File
@@ -0,0 +1,8 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
export { default as QuestionBankPage } from './QuestionBankPage';
export { default as CorpusPage } from './CorpusPage';
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,633 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import DescriptionIcon from '@mui/icons-material/Description'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import CheckIcon from '@mui/icons-material/Check'
import CloseIcon from '@mui/icons-material/Close'
import DoneAllIcon from '@mui/icons-material/DoneAll'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository'
import type { AutoMapJobStatus, DigitalTextResponse, ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
const TOOLS = [
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const },
{ id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const },
{ id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const },
{ id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const },
{ id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const },
{ id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const },
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
]
const PAGE_START_X = 0
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
let y = 0
return pages.map((page) => {
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
y += page.height
return geometry
})
}
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
editor.setCameraOptions({
constraints: {
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
padding: { x: 64, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
isLocked: false,
})
}
function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
return { conflict: false, message: detail ?? err.message }
}
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
}
function reviewSummary(template: ExamTemplateDetail | null) {
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
return rows.reduce((acc, row) => {
if (row.source === 'ai') acc.ai += 1
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
return acc
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
}
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
// (the template-derived reviewSummary only reflects the last *saved* state).
function reviewFromShapes(editor: Editor | null) {
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
if (!editor) return acc
for (const shape of editor.getCurrentPageShapes()) {
if (!shapeTypeToKind(shape.type)) continue
const p = (shape as ExamCanvasTLShape).props
if (p.source === 'ai') acc.ai += 1
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
}
return acc
}
function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
}
function domainIdForShape(shape: ExamCanvasTLShape): string {
const fromProps = shape.props.domainId
if (isUuid(fromProps)) return fromProps
const fromShapeId = stripShapePrefix(shape.id)
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
}
function ensureDomainIds(editor: Editor) {
const updates = editor.getCurrentPageShapes()
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
.filter((shape) => !isUuid(shape.props.domainId))
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
if (updates.length) editor.updateShapes(updates)
}
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
const kind = shapeTypeToKind(shape.type)
if (!kind) return null
const s = shape as ExamCanvasTLShape
return {
id: domainIdForShape(s),
kind,
x: Number(s.x ?? 0),
y: Number(s.y ?? 0),
w: Number(s.props.w ?? 1),
h: Number(s.props.h ?? 1),
label: s.props.label,
maxMarks: s.props.maxMarks,
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
contextType: s.props.contextType,
questionId: s.props.questionId ?? null,
parentId: s.props.parentId ?? null,
isContainer: s.props.isContainer ?? false,
depth: s.props.depth,
description: s.props.description,
linkLabel: s.props.linkLabel,
meta: (() => { try { return s.props.metaJson ? JSON.parse(s.props.metaJson) : undefined } catch { return undefined } })(),
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 { /* */ }
}
// Draw order = z-order (tldraw stacks by creation). Backdrops (boundaries, preamble bands, container frames)
// go first/behind so the figures, responses and parts on top stay hoverable and legible.
function zPriority(m: ExamCanvasShapeModel): number {
if (m.kind === 'boundary') return 0
if (m.kind === 'context' && m.contextType === 'preamble') return 1
if (m.kind === 'part' && m.isContainer) return 2
if (m.kind === 'part') return 3
if (m.kind === 'context') return 5 // figures/tables on top so their description tooltip is reachable
return 4 // responses + other regions
}
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
const ordered = [...models].sort((a, b) => zPriority(a) - zPriority(b))
editor.createShapes(ordered.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, parentId: m.parentId ?? undefined, isContainer: m.isContainer ?? undefined, depth: m.depth ?? undefined, description: m.description ?? undefined, linkLabel: m.linkLabel ?? undefined, metaJson: m.meta ? JSON.stringify(m.meta) : undefined, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
})))
}
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!pages.length) return
const geometries = pageGeometryFromImages(pages)
editor.createShapes(geometries.map((geometry) => {
const page = pages[geometry.pageNumber - 1]
return {
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
type: PDF_PAGE_SHAPE_TYPE,
x: geometry.x,
y: geometry.y,
isLocked: true,
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
} as any
}))
// z-order is enforced by the caller via bringDomainShapesToFront
}
function seedGuide(editor: Editor) {
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
if (current.length) return
editor.createShapes([
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
])
}
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
}
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
if (!status) return 'Auto-map running'
if (status.status === 'queued') return 'Auto-map queued'
if (status.status === 'running') return 'Auto-map running'
if (status.status === 'completed') return 'Auto-map complete'
if (status.status === 'failed') return 'Auto-map failed'
return `Auto-map ${status.status}`
}
const ExamTemplateSetupInner: React.FC = () => {
const { templateId } = useParams<{ templateId: string }>()
const navigate = useNavigate()
const theme = useTheme()
const editorRef = useRef<Editor | null>(null)
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
const [autoMapBusy, setAutoMapBusy] = useState(false)
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
const [digitalText, setDigitalText] = useState<DigitalTextResponse | null>(null)
const [digitalOpen, setDigitalOpen] = useState(false)
const [digitalBusy, setDigitalBusy] = useState(false)
const autoMapPollRef = useRef<number | null>(null)
const openDigitalText = useCallback(async () => {
if (!template) return
setDigitalBusy(true); setDigitalOpen(true)
try {
setDigitalText(await examRepository.getDigitalText(template.id))
} catch (e) {
setDigitalText(null)
setError(e instanceof Error ? e.message : 'No digital text yet — run auto-map first.')
setDigitalOpen(false)
} finally {
setDigitalBusy(false)
}
}, [template])
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
setTemplate(detail)
const editor = editorRef.current
if (editor) {
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
loadShapes(editor, shapes)
if (!shapes.length) seedGuide(editor)
bringDomainShapesToFront(editor)
}
setDirty(false)
}, [])
const acceptShapes = useCallback((shapes: TLShape[]) => {
const editor = editorRef.current
if (!editor) return
const updates = shapes
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
}, [refreshReview])
const selectedDomainShapes = useCallback((): TLShape[] => {
const editor = editorRef.current
if (!editor) return []
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
}, [])
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
const rejectSelected = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const ids = selectedDomainShapes().map((s) => s.id)
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
}, [refreshReview, selectedDomainShapes])
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
// saved-template summary only for the brief moment before shapes have mounted).
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
const load = useCallback(async () => {
if (!templateId) return
setLoading(true); setError(null); setConflict(null)
try {
const detail = await examRepository.getTemplate(templateId)
setTemplate(detail)
let pages: PdfPageImage[] = []
setPdfStatus('loading')
setPdfError(null)
try {
const bytes = await examRepository.getTemplateSourcePdf(templateId)
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
const newPage = partialPages[partialPages.length - 1]
const allGeometries = pageGeometryFromImages(partialPages)
pageGeometriesRef.current = allGeometries
const ed = editorRef.current
if (ed) {
const geometry = allGeometries[partialPages.length - 1]
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
bringDomainShapesToFront(ed)
}
applyDocViewConstraints(ed, partialPages)
}
setPdfStatus('ready')
})
setPdfStatus(pages.length ? 'ready' : 'missing')
} catch (pdfErr) {
const pdfMsg = apiMessage(pdfErr).message
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
setPdfError(pdfMsg)
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
}
const geometries = pageGeometryFromImages(pages)
pageGeometriesRef.current = geometries
const editor = editorRef.current
if (editor) {
syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail, geometries))
bringDomainShapesToFront(editor)
applyDocViewConstraints(editor, pages)
editor.resetZoom()
}
setDirty(false)
} catch (e) {
const msg = apiMessage(e).message
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
setError(msg)
} finally {
setLoading(false)
}
}, [templateId])
useEffect(() => { void load() }, [load])
useEffect(() => () => {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
}, [])
const pollAutoMapStatus = useCallback(async (jobId: string) => {
if (!templateId) return
try {
const status = await examRepository.getAutoMapStatus(templateId, jobId)
setAutoMapStatus(status)
if (status.status === 'completed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
const detail = status.template ?? await examRepository.getTemplate(templateId)
applyTemplateToCanvas(detail)
return
}
if (status.status === 'failed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
return
}
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
} catch (e) {
const msg = apiMessage(e).message
setAutoMapBusy(false)
setError(msg)
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
}
}, [applyTemplateToCanvas, templateId])
const autoMapFromPdf = useCallback(async () => {
if (!templateId || autoMapBusy) return
let queued = false
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
try {
const result = await examRepository.autoMapTemplate(templateId)
if (isAutoMapAccepted(result)) {
queued = true
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
await pollAutoMapStatus(result.job_id)
return
}
setAutoMapStatus(null)
applyTemplateToCanvas(result)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
} finally {
if (!queued) setAutoMapBusy(false)
}
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
const save = useCallback(async () => {
const editor = editorRef.current
if (!editor || !templateId || !template) return
setSaving(true); setError(null); setConflict(null)
try {
ensureDomainIds(editor)
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved)
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
setDirty(false)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
} finally {
setSaving(false)
}
}, [template, templateId])
const layoutSummary = useMemo(() => {
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
}, [template?.layout])
const toolButtons = useMemo(() => TOOLS.map((tool) => (
<Tooltip title={tool.tip} key={tool.id} placement="right">
<Button
size="small"
variant={activeTool === tool.id ? 'contained' : 'outlined'}
color={tool.color}
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
onClick={() => {
const editor = editorRef.current
if (!editor) return
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
setActiveTool(tool.id)
}}
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
>
{tool.label}
</Button>
</Tooltip>
)), [activeTool])
return (
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
{/* Top bar — single compact line */}
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
<Tooltip title="Back to exam marker">
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem />
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
{(() => {
const a = (template as (ExamTemplateDetail & { extraction_meta?: { audit?: { status?: string; cover_total?: number; detected_total?: number } } }) | null)?.extraction_meta?.audit
return a?.status ? <Tooltip title="Extraction cover-total reconciliation (detected vs printed maximum)"><Chip size="small" color={a.status === 'ok' ? 'success' : 'warning'} label={`Marks ${a.detected_total ?? '?'}/${a.cover_total ?? '?'}`} /></Tooltip> : null
})()}
<Button size="small" variant="outlined" startIcon={digitalBusy ? <CircularProgress size={14} /> : <DescriptionIcon fontSize="small" />} onClick={openDigitalText} disabled={digitalBusy || !template}>Digital text</Button>
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper>
{/* Body row */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left tool sidebar */}
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
<Stack spacing={1}>{toolButtons}</Stack>
</Paper>
{/* Canvas area */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
<Tldraw
shapeUtils={examCanvasShapeUtils as any}
tools={examCanvasTools as any}
hideUi
inferDarkMode={theme.palette.mode === 'dark'}
autoFocus
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
// update below can't recurse.
editor.store.listen((entry) => {
try {
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
for (const rec of Object.values(entry.changes.updated)) {
const [from, to] = rec as [any, any]
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
}
if (updates.length) editor.updateShapes(updates as any)
} catch { /* non-fatal: implicit-confirm is best-effort */ }
}, { scope: 'document', source: 'user' })
applyDocViewConstraints(editor, [])
editor.resetZoom()
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
// (Previously `else seedGuide` fired on mount while `template` was still null during
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
if (template) {
const s = shapesFromTemplate(template, pageGeometriesRef.current)
loadShapes(editor, s)
if (!s.length) seedGuide(editor)
}
bringDomainShapesToFront(editor)
}}
/>
</Box>
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
{review.unconfirmed > 0 && (
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
</Tooltip>
<Tooltip title="Reject (delete) the selected shapes">
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
</Tooltip>
<Tooltip title="Accept every AI suggestion on the page">
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
</Tooltip>
</Paper>
)}
{/* Guide toggle */}
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
</IconButton>
</Tooltip>
{/* Guide panel — collapsible */}
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
</Typography>
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
</Alert>
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
const p = canvasShapePalette[kind]
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
})}
</Stack>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
</Typography>
</Paper>
</Collapse>
{/* Conflict alert */}
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
</Box>
</Box>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
<Dialog open={digitalOpen} onClose={() => setDigitalOpen(false)} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon fontSize="small" />
Digital text
{digitalText && <Typography component="span" variant="body2" color="text.secondary"> {digitalText.title} · {digitalText.n_questions}Q · {digitalText.total_marks} marks</Typography>}
<Box sx={{ flex: 1 }} />
{digitalText && (
<Tooltip title="Download markdown">
<IconButton size="small" onClick={() => {
const blob = new Blob([digitalText.markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob); const a = document.createElement('a')
a.href = url; a.download = `${digitalText.slug}.md`; a.click(); URL.revokeObjectURL(url)
}}><SaveIcon fontSize="small" /></IconButton>
</Tooltip>
)}
</DialogTitle>
<DialogContent dividers>
{digitalBusy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : digitalText ? (
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, m: 0 }}>{digitalText.markdown}</Box>
) : (
<Typography variant="body2" color="text.secondary">No digital text available.</Typography>
)}
</DialogContent>
</Dialog>
<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
+370
View File
@@ -0,0 +1,370 @@
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
parentId?: string | null
isContainer?: boolean
depth?: number
description?: string
linkLabel?: string
metaJson?: string
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' },
}
// Human-legible names for the extraction service's response forms — so the canvas SHOWS what was
// recognised (multiple-choice, draw-on, table…), not just a generic "Response" box.
const RESPONSE_FORM_LABEL: Record<string, string> = {
lines: 'ruled lines',
'answer-box': 'answer box',
working: 'working space',
diagram: 'draw-on',
'tick-boxes': 'multiple choice',
table: 'table',
blanks: 'fill-in',
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
`
function confidenceLabel(confidence: number | null | undefined) {
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
}
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
function ghostOpacity(confidence: number | null | undefined) {
if (typeof confidence !== 'number') return 0.72
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
}
function reviewFlags(shape: ExamCanvasTLShape): string[] {
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
}
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
const bits = [base]
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
const confidence = confidenceLabel(shape.props.confidence)
if (confidence) bits.push(`confidence ${confidence}`)
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
const flags = reviewFlags(shape)
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
return bits.join(' • ')
}
function renderBoundaryLine(shape: ExamCanvasTLShape) {
const p = canvasShapePalette.boundary
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
const isAi = shape.props.source === 'ai'
const isGhost = isAi && shape.props.confirmed === false
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
</svg>
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
</HTMLContainer>
)
}
// Distinct look for a container box (main question / intermediate part). It frames its children rather than
// filling over them: transparent interior + a coloured border whose hue shifts by nesting depth, so
// question ⊃ part ⊃ subpart reads as nested frames on the paper.
const CONTAINER_DEPTH_STROKE = ['#4f46e5', '#0d9488', '#b45309']
function containerStroke(depth: number) { return CONTAINER_DEPTH_STROKE[Math.min(depth, CONTAINER_DEPTH_STROKE.length - 1)] }
function marksPill(maxMarks: number | undefined) {
if (typeof maxMarks !== 'number' || maxMarks <= 0) return null
return (
<span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={`${maxMarks} mark${maxMarks === 1 ? '' : 's'}`}>
<span aria-hidden="true" style={{ opacity: 0.7 }}></span>{maxMarks}
</span>
)
}
function parseMeta(shape: ExamCanvasTLShape): Record<string, any> | null {
try { return shape.props.metaJson ? JSON.parse(shape.props.metaJson) : null } catch { return null }
}
// Draw the recognised answer sub-structure INSIDE a response box, using the box-relative [0,1] coords the
// contract now carries: numbered sub-answer bands ("suggest two" → 1, 2), per-option MC selection boxes, and
// the final-answer line's expected quantity/unit.
function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode {
const m = parseMeta(shape)
if (!m) return null
const pct = (v: any, d = 0) => `${Math.max(0, Math.min(1, Number(v ?? d))) * 100}%`
const els: React.ReactNode[] = []
const comps: any[] = Array.isArray(m.components) ? m.components : []
comps.forEach((c, i) => {
if (i === 0) return // first band starts at the box top; a divider there is noise
els.push(<div key={`cd${i}`} style={{ position: 'absolute', left: 0, right: 0, top: pct(c.rel_y0), borderTop: '1px dashed var(--exam-stroke)', opacity: 0.45 }} />)
})
comps.forEach((c, i) => els.push(
<span key={`cl${i}`} className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: `calc(${pct(c.rel_y0)} + 2px)`, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>{c.label ?? i + 1}</span>
))
const boxes: any[] = Array.isArray(m.boxes) ? m.boxes : []
boxes.forEach((b, i) => {
const rel = b.rel || {}
const filled = Number(b.fill ?? 0) > 0.35
els.push(<div key={`bx${i}`} title={`option ${i + 1}${filled ? ' (marked)' : ''}`} style={{ position: 'absolute', left: pct(rel.x0), top: pct(rel.y0), width: pct(Number(rel.x1) - Number(rel.x0)), height: pct(Number(rel.y1) - Number(rel.y0)), border: '1.5px solid var(--exam-stroke)', borderRadius: 3, background: filled ? 'var(--exam-stroke)' : 'transparent', opacity: filled ? 0.5 : 0.85, boxSizing: 'border-box' }} />)
})
// "tick N" instruction for multiple-choice (select_n = how many boxes to tick)
const selectN = Number(m.select_n ?? 0)
if (selectN > 0 && boxes.length) {
els.push(<span key="tickn" className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: 2, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>tick {selectN}</span>)
}
const fal = m.final_answer_line
if (fal && (fal.unit || fal.quantity)) {
els.push(<span key="fal" className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 4, top: `calc(${pct(fal.rel_y, 0.9)} - 8px)`, maxWidth: 'calc(100% - 8px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 10, fontWeight: 800, borderRadius: 6, padding: '0 5px', opacity: 0.95 }} title="expected final answer">{[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}</span>)
}
return els.length ? <>{els}</> : null
}
// A container question is a BACKDROP, not a big box over the content: the main question (depth 0) relies on
// its start/end boundary rules for extent and shows only a small corner tab (label + total marks); an
// intermediate part-group (depth ≥ 1) also gets a faint dashed frame to convey nesting. Non-interactive so it
// never blocks a hover on the figures/responses on top.
function renderContainerTab(shape: ExamCanvasTLShape) {
const depth = shape.props.depth ?? 0
const stroke = containerStroke(depth)
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
const marks = shape.props.maxMarks
const title = provenanceTitle(shape, depth === 0 ? 'Question (container)' : 'Part group (container)')
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none', overflow: 'visible' }}>
<style>{shapeCss}</style>
{depth > 0 && <div style={{ position: 'absolute', inset: 0, border: `1px dashed ${stroke}`, borderRadius: 12, opacity: 0.3, boxSizing: 'border-box' }} />}
<span className="exam-canvas-shape__pill" title={title} style={{ position: 'absolute', left: 0, top: -3, display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 900, borderRadius: 8, padding: '2px 7px', color: stroke, border: `1.5px solid ${stroke}`, background: 'rgba(255,255,255,0.92)', whiteSpace: 'nowrap' }}>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (depth === 0 ? '▣' : '▢')}</span>
{shape.props.label}
{typeof marks === 'number' && marks > 0 && <span style={{ opacity: 0.85 }}>· {marks}m</span>}
</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 isContainer = shape.props.isContainer === true && kind === 'part'
if (isContainer) return renderContainerTab(shape)
const isPreamble = kind === 'context' && shape.props.contextType === 'preamble'
const depth = shape.props.depth ?? 0
const stroke = isContainer ? containerStroke(depth) : p.stroke
const darkStroke = isContainer ? containerStroke(depth) : p.darkStroke
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
const roleLabel = isContainer ? (depth === 0 ? 'Question: container' : 'Part: container') : `${p.label}: ${p.role}`
const title = shape.props.description ? `${provenanceTitle(shape, roleLabel)}${shape.props.description}` : provenanceTitle(shape, roleLabel)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: isPreamble ? 'none' : 'all' }}>
<style>{shapeCss}</style>
<div
className={`exam-canvas-shape exam-canvas-shape--${kind}${isContainer ? ' exam-canvas-shape--container' : ''}`}
style={{
'--exam-light-stroke': stroke,
'--exam-light-fill': p.fill,
'--exam-dark-stroke': darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isContainer ? 2.5 : 1.5}px ${isContainer ? 'solid' : (isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid')} var(--exam-stroke)`,
borderRadius: isContainer ? 14 : 10,
background: isContainer ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
padding: 8, boxShadow: isContainer ? 'none' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
} as React.CSSProperties}
aria-label={title}
title={title}
>
{kind === 'response' && renderResponseSubstructure(shape)}
<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, maxWidth: 'calc(100% - 60px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', zIndex: 1 }}>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (isContainer ? (depth === 0 ? '▣' : '▢') : p.icon)}</span>
{shape.props.label || p.label}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
{marksPill(shape.props.maxMarks)}
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{!confidence && !marksPill(shape.props.maxMarks) && shape.props.questionId && kind !== 'context' && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
</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>}
{kind === 'response' && shape.props.responseForm && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{RESPONSE_FORM_LABEL[shape.props.responseForm] ?? shape.props.responseForm}</span>}
{/* Context region: a visible tether to its owning question + the figure kind. */}
{kind === 'context' && shape.props.linkLabel && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.95 }}> {shape.props.linkLabel}</span>}
{kind === 'context' && shape.props.contextType && shape.props.contextType !== 'generic' && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{shape.props.contextType}</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), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: T.optional(T.string), metaJson: 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
}
+18 -5
View File
@@ -9,6 +9,7 @@ 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';
@@ -131,10 +132,20 @@ const ClassDetailPage: React.FC = () => {
setLoading(true);
setError(null);
try {
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
if (clsRes.id) setCls(clsRes);
if (clsRes.id) {
setCls({
...clsRes,
class_code: clsRes.class_code || clsRes.code,
year_group: clsRes.year_group || clsRes.school_year,
teachers: clsRes.teachers || [],
students: clsRes.students || [],
enrollment_requests: clsRes.enrollment_requests || [],
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
});
}
else setError(clsRes.detail || 'Class not found');
const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
@@ -174,20 +185,20 @@ const ClassDetailPage: React.FC = () => {
const handleAddStudent = async (studentId: string) => {
setActionError(null);
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
await apiDelete(`/classes/${classId}/students/${studentId}`);
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
@@ -254,6 +265,8 @@ const ClassDetailPage: React.FC = () => {
</Alert>
)}
<ResultsWidget classId={cls.id} className={cls.name} />
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} />
+22 -2
View File
@@ -39,6 +39,7 @@ interface LessonPlan {
year_group: string | null;
duration_minutes: number | null;
context_notes: string | null;
homework: string | null;
objectives: Objective[];
activities: Activity[];
is_public: boolean;
@@ -88,6 +89,7 @@ const LessonPlanDetailPage: React.FC = () => {
const [yearGroup, setYearGroup] = useState('');
const [duration, setDuration] = useState('');
const [contextNotes, setContextNotes] = useState('');
const [homework, setHomework] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [objectives, setObjectives] = useState<Objective[]>([]);
const [activities, setActivities] = useState<Activity[]>([]);
@@ -110,6 +112,7 @@ const LessonPlanDetailPage: React.FC = () => {
setYearGroup(data.year_group || '');
setDuration(data.duration_minutes?.toString() || '');
setContextNotes(data.context_notes || '');
setHomework(data.homework || '');
setIsPublic(data.is_public || false);
setObjectives(data.objectives || []);
setActivities(data.activities || []);
@@ -128,7 +131,7 @@ const LessonPlanDetailPage: React.FC = () => {
// ── 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[] }>
overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; homework: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }>
) => {
if (!accessToken || !planId) return;
setSaving(true);
@@ -143,6 +146,7 @@ const LessonPlanDetailPage: React.FC = () => {
year_group: yearGroup || null,
duration_minutes: duration ? parseInt(duration, 10) : null,
context_notes: contextNotes || null,
homework: homework || '',
is_public: isPublic,
objectives,
activities,
@@ -156,7 +160,7 @@ const LessonPlanDetailPage: React.FC = () => {
} finally {
setSaving(false);
}
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, isPublic, objectives, activities]);
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, homework, isPublic, objectives, activities]);
const scheduleAutoSave = useCallback(() => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
@@ -359,6 +363,22 @@ const LessonPlanDetailPage: React.FC = () => {
/>
</Box>
{/* Homework */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 0.75 }}>
Homework
</Typography>
<TextField
value={homework}
onChange={e => { setHomework(e.target.value); scheduleAutoSave(); }}
multiline
minRows={2}
fullWidth
size="small"
placeholder="Homework to carry with this plan when assigned to a taught lesson…"
/>
</Box>
<Divider sx={{ mb: 3 }} />
{/* Objectives */}
+2
View File
@@ -21,6 +21,7 @@ interface PlanSummary {
duration_minutes: number | null;
objectives: { id: string; text: string; bloom_level?: string }[];
activities: { id: string; section: string; title: string }[];
homework: string | null;
is_public: boolean;
created_at: string;
updated_at: string;
@@ -144,6 +145,7 @@ function PlanCard({ plan, onOpen, onDelete }: {
</Box>
<Typography variant="caption" color="text.secondary">
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
{plan.homework ? ' · homework set' : ''}
</Typography>
</CardContent>
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
+20 -10
View File
@@ -3,9 +3,19 @@ import { Link } from 'react-router-dom';
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
import { useUser } from '../../contexts/UserContext';
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
import { CircularProgress } from '@mui/material';
import { logger } from '../../debugConfig';
const MyClassesSkeleton = () => (
<div className="flex justify-center items-center h-64">
<CircularProgress />
</div>
);
const MyClassesPage: React.FC = () => {
const { profile } = useUser();
const { selectedInstituteId } = useNeoInstitute();
const {
myClasses,
classesLoading: myClassesLoading,
@@ -14,8 +24,16 @@ const MyClassesPage: React.FC = () => {
} = useTimetableStore();
useEffect(() => {
fetchMyClasses();
}, [fetchMyClasses]);
fetchMyClasses().catch((error) =>
logger.warn('my-classes', 'Fetch my classes failed', {
message: error instanceof Error ? error.message : String(error),
})
);
}, [fetchMyClasses, selectedInstituteId]);
if (myClassesLoading) {
return <MyClassesSkeleton />;
}
const getRoleLabel = (role: string) => {
switch (role) {
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
}
};
if (myClassesLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (myClassesError) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -22,6 +22,7 @@ interface Lesson {
week_cycle: string;
day_of_week: string;
status: string;
homework: string | null;
teacher_name: string | null;
}
@@ -95,6 +96,11 @@ function LessonCard({ lesson }: { lesson: Lesson }) {
sx={{ height: 16, fontSize: '0.65rem' }}
/>
</Box>
{lesson.homework && (
<Typography variant="caption" sx={{ color: 'primary.main', fontSize: '0.68rem', fontWeight: 600 }}>
Homework: {lesson.homework.length > 80 ? lesson.homework.slice(0, 80) + '…' : lesson.homework}
</Typography>
)}
{lesson.teacher_name && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
{lesson.teacher_name}
+21 -3
View File
@@ -30,6 +30,7 @@ interface Lesson {
day_of_week: string;
status: string;
lesson_plan: Record<string, any>;
homework: string | null;
notes: string | null;
whiteboard_room_id: string | null;
}
@@ -76,16 +77,18 @@ const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error'
interface EditDialogProps {
lesson: Lesson | null;
onClose: () => void;
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
onSave: (id: string, updates: { homework?: string; notes?: string; status?: string }) => Promise<void>;
}
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
const [homework, setHomework] = useState('');
const [notes, setNotes] = useState('');
const [status, setStatus] = useState('planned');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (lesson) {
setHomework(lesson.homework || '');
setNotes(lesson.notes || '');
setStatus(lesson.status || 'planned');
}
@@ -94,7 +97,7 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
const handleSave = async () => {
if (!lesson) return;
setSaving(true);
await onSave(lesson.id, { notes, status });
await onSave(lesson.id, { homework, notes, status });
setSaving(false);
onClose();
};
@@ -127,6 +130,16 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
<MenuItem value="substituted">Substituted</MenuItem>
</Select>
</FormControl>
<TextField
label="Homework"
value={homework}
onChange={e => setHomework(e.target.value)}
multiline
minRows={2}
fullWidth
size="small"
placeholder="Homework for this lesson…"
/>
<TextField
label="Notes"
value={notes}
@@ -209,6 +222,11 @@ function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
sx={{ height: 16, fontSize: '0.65rem' }}
/>
</Box>
{lesson.homework && (
<Typography variant="caption" sx={{ color: 'primary.main', fontSize: '0.68rem', fontWeight: 600, mt: 0.25 }}>
Homework: {lesson.homework.length > 80 ? lesson.homework.slice(0, 80) + '…' : lesson.homework}
</Typography>
)}
{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}
@@ -287,7 +305,7 @@ const TaughtLessonsPage: React.FC = () => {
}
};
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
const handleSaveLesson = async (id: string, updates: { homework?: string; notes?: string; status?: string }) => {
if (!accessToken) return;
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
method: 'PATCH',
+76
View File
@@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
import useTimetableStore from '../../stores/timetableStore';
import { format, parseISO } from 'date-fns';
const TimetableListPage: React.FC = () => {
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
useEffect(() => {
fetchTimetables();
}, [fetchTimetables]);
if (timetablesLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
const showEmpty = timetablesError || timetables.length === 0;
return (
<div className="container mx-auto px-4 py-6 max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
</div>
</div>
{showEmpty ? (
<div className="text-center py-16">
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
<p className="mt-2 text-gray-500">
Timetables are created from a class. Go to{' '}
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
to get started.
</p>
</div>
) : (
<div className="space-y-3">
{timetables.map((timetable) => (
<Link
key={timetable.id}
to={`/timetable/${timetable.id}`}
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
>
<CalendarToday className="text-blue-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
<p className="text-sm text-gray-500">
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
{timetable.end_date
? ` ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
: ' (ongoing)'}
</p>
</div>
{timetable.recurrence_rule && (
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
Recurring
</span>
)}
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
</Link>
))}
</div>
)}
</div>
);
};
export default TimetableListPage;
+1
View File
@@ -3,6 +3,7 @@
*/
export { default as TimetablePage } from './TimetablePage';
export { default as TimetableListPage } from './TimetableListPage';
export { default as ClassesPage } from './ClassesPage';
export { default as LessonPage } from './LessonPage';
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
@@ -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[];
}
+92 -281
View File
@@ -7,7 +7,7 @@ import {
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore,
TLStoreWithStatus
createTLStore,
} from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useAuth } from '../../contexts/AuthContext';
@@ -15,7 +15,6 @@ import { useUser } from '../../contexts/UserContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
import { ErrorBoundary } from '../../components/ErrorBoundary';
interface LoadingState {
status: 'ready' | 'loading' | 'error';
@@ -63,13 +60,12 @@ export default function SinglePlayerPage() {
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
const storeRef = useRef<TLStore | null>(null);
const [storeReady, setStoreReady] = useState(false);
// TLDraw user preferences
const tldrawUser = useTldrawUser({
@@ -85,248 +81,97 @@ export default function SinglePlayerPage() {
setUserPreferences: setTldrawPreferences
});
// Initialize store — runs as soon as user is ready, no editor needed for store creation
useEffect(() => {
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
const [canvasPhase, setCanvasPhase] = useState<
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
>('idle');
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
// the store alive for the component lifetime avoids all disposal race conditions.
useEffect(() => {
if (!user) return;
setCanvasPhase('store-init');
const store = createTLStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils,
});
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
const snapSvc = new NavigationSnapshotService(store, undefined);
if (accessToken) snapSvc.setAccessToken(accessToken);
snapshotServiceRef.current = snapSvc;
let debounce: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = store.listen(() => {
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
}, 2000);
});
const initializeStoreAndSnapshot = async () => {
try {
setLoadingState({ status: 'loading', error: '' });
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
});
logger.debug('single-player-page', '✅ TLStore created');
storeRef.current = store;
setStoreReady(true);
setCanvasPhase('ready');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
if (accessToken) snapshotService.setAccessToken(accessToken);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
const nodeStoragePath = getNodeStoragePath(context.node);
if (nodeStoragePath) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: null,
node: context.node,
node_storage_path: nodeStoragePath,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath,
accessToken || '',
newStore,
setLoadingState,
undefined,
editorRef.current || undefined
);
// Wire auto-save: set the current path on the service instance
snapshotService.setCurrentNodePath(nodeStoragePath);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
node: context.node
});
}
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save with debouncing (only after initial load is complete)
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
let isAutoSaving = false;
newStore.listen(() => {
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
// Skip if already saving
if (isAutoSaving) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
return;
}
// Clear existing timeout
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
// Debounce auto-save to prevent excessive saves
autoSaveTimeout = setTimeout(async () => {
if (isAutoSaving) return; // Double-check
isAutoSaving = true;
try {
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
await snapshotServiceRef.current?.forceSaveCurrentNode();
} catch (error) {
logger.error('single-player-page', '❌ Auto-save failed', error);
} finally {
isAutoSaving = false;
}
}, 2000); // Increased to 2 seconds debounce
}
});
// 5. Update store state
setStore(newStore);
setLoadingState({ status: 'ready', error: '' });
logger.info('single-player-page', '✅ Store initialization complete');
// 6. Handle cleanup
return () => {
logger.debug('single-player-page', '🧹 Starting cleanup');
if (snapshotServiceRef.current) {
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Final save failed', error);
});
snapshotServiceRef.current.clearCurrentNode();
snapshotServiceRef.current = null;
}
newStore.dispose();
setStore(undefined);
logger.debug('single-player-page', '🧹 Cleanup complete');
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
logger.error('single-player-page', '❌ Store initialization failed', error);
setLoadingState({ status: 'error', error: errorMessage });
return undefined;
}
return () => {
unsubscribe?.();
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
snapshotServiceRef.current?.clearCurrentNode();
snapshotServiceRef.current = null;
setStoreReady(false);
setCanvasPhase('idle');
store.dispose();
storeRef.current = null;
};
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
initializeStoreAndSnapshot();
}, [user, context.node]);
// Handle initial node placement
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
hasNode: !!context.node,
hasEditor: !!editorRef.current,
hasStore: !!store,
isInitialLoad
});
return;
}
if (!user || !storeRef.current) return;
// Debug: Log the actual node structure
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
node: context.node,
nodeKeys: Object.keys(context.node),
hasId: !!context.node.id,
hasStoragePath: !!context.node.node_storage_path,
hasData: !!context.node.data,
dataKeys: context.node.data ? Object.keys(context.node.data) : null
});
const store = storeRef.current;
let cancelled = false;
// Validate that the node has required properties
const nodeStoragePath = getNodeStoragePath(context.node);
if (!context.node.id || !nodeStoragePath) {
logger.error('single-player-page', '❌ Node missing required properties', {
nodeId: context.node.id,
hasStoragePath: !!nodeStoragePath,
node: context.node
});
setLoadingState({
status: 'error',
error: 'Node is missing required information'
});
const loadNodeSnapshot = async () => {
if (!context.node?.node_storage_path) {
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.clearCurrentNode();
setCanvasPhase('ready');
return;
}
setCanvasPhase('snapshot-loading');
try {
setLoadingState({ status: 'loading', error: '' });
if (context.node.type !== 'workspace') {
try {
const nodeData = await loadNodeData(context.node);
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
} catch (shapeErr) {
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr });
}
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.node_storage_path,
accessToken || '',
store,
setLoadingState,
undefined,
editorRef.current || undefined
);
if (!cancelled) {
// Repair store in case snapshot overwrote TLINSTANCE
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
setCanvasPhase('ready');
}
setIsInitialLoad(false);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to place initial node', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to place initial node'
});
} catch (e) {
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
loadNodeSnapshot();
return () => { cancelled = true; };
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
// Handle navigation changes
useEffect(() => {
const handleNodeChange = async () => {
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
return;
}
// We can safely assert these types because we've checked for null above
const editor = editorRef.current as Editor;
const snapshotService = snapshotServiceRef.current;
const currentNode = context.node;
try {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
node_storage_path: currentNode.node_storage_path,
isInitialLoad
});
// Get the previous node from navigation history
const previousNode = context.history.currentIndex > 0
? context.history.nodes[context.history.currentIndex - 1]
: null;
// Handle navigation in snapshot service (load/save snapshot)
await snapshotService.handleNavigationStart(previousNode, currentNode);
if (currentNode.type !== 'workspace') {
try {
const nodeData = await loadNodeData(currentNode);
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
} catch (shapeErr) {
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr });
}
}
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to load node data', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load node data'
});
}
};
handleNodeChange();
}, [context.node, context.history, store]);
// Handle navigation changes — save previous snapshot, load next one.
// No automatic node shape placement: the canvas shows only persisted state.
// Inject auth and trigger initial context when token is ready
useEffect(() => {
@@ -485,7 +330,7 @@ export default function SinglePlayerPage() {
top: `${HEADER_HEIGHT}px`,
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
{(loadingState.status === 'loading' || !storeReady) && (
<div style={{
position: 'absolute',
top: 0,
@@ -513,9 +358,15 @@ export default function SinglePlayerPage() {
</Alert>
</Snackbar>
{store && <Tldraw
<ErrorBoundary fallback={
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
</div>
}>
{storeReady && storeRef.current && <Tldraw
user={tldrawUser}
store={store}
store={storeRef.current}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
@@ -541,64 +392,24 @@ export default function SinglePlayerPage() {
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
// Update snapshot service with editor reference
if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor);
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
}
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
presentationMode,
isEditorReady: true
});
} catch (error) {
logger.error('single-player-page', '❌ Error in onMount', error);
}
}}
/>}
</ErrorBoundary>
</div>
);
}
// Helper function to safely extract node_storage_path from different node structures
const getNodeStoragePath = (node: NavigationNode): string | null => {
// Try direct access first
if (node.node_storage_path) {
return node.node_storage_path;
}
// Try nested under data
if (node.data?.node_storage_path) {
return node.data.node_storage_path;
}
// Try other possible locations
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
return node.data.storage_path;
}
return null;
};
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
if (!node?.id) throw new Error('Node parameter is required');
const nodeStoragePath = getNodeStoragePath(node);
if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
const theme = getThemeFromLabel(node.type);
return {
title: node.label || node.type || '',
w: 500,
h: 350,
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
uuid_string: node.id,
node_storage_path: nodeStoragePath,
};
};
+6
View File
@@ -85,6 +85,12 @@ const DashboardPage: React.FC = () => {
>
Open workspace
</Button>
<Button
variant="outlined"
onClick={() => navigate('/exam-marker')}
>
Exam Marker
</Button>
<Button
variant="outlined"
onClick={() => navigate('/calendar')}
+351
View File
@@ -0,0 +1,351 @@
/**
* 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,
BankResponse,
CorpusResponse,
DigitalTextResponse,
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
CreateCustomPaperPayload,
CreateCustomPaperResult,
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate,
ExamTemplateDetail,
ExamTemplateLayout,
MarkingBatch,
MarkUpsertPayload,
Neo4jSyncResult,
PatchQuestionPayload,
SpecPoint,
StudentSubmission,
TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`;
async function authHeaders(): Promise<Record<string, string>> {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No authentication token available');
}
return { Authorization: `Bearer ${session.access_token}` };
}
function newUuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
const v = c === 'x' ? r : (r % 4) + 8;
return v.toString(16);
});
}
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
return {
id: idMap?.get(q.id) ?? q.id,
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
label: q.label,
order: q.order,
max_marks: q.max_marks,
answer_type: q.answer_type,
mcq_options: q.mcq_options,
mark_scheme: q.mark_scheme ?? {},
is_container: q.is_container,
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
source: q.source ?? 'manual',
confirmed: q.confirmed ?? true,
confidence: q.confidence ?? null,
derivation: q.derivation ?? null,
};
}
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : r.id,
question_id: idMap?.get(r.question_id) ?? r.question_id,
page: r.page,
bounds: r.bounds,
kind: r.kind,
response_form: r.response_form,
context_type: r.context_type ?? null,
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
mark_subtype: r.mark_subtype ?? null,
derivation: r.derivation ?? null,
};
}
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : b.id,
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
label: b.label,
page_index: b.page_index,
y: b.y,
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
confidence: b.confidence ?? null,
derivation: b.derivation ?? null,
};
}
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
return {
id: duplicate ? newUuid() : layout.id,
page_index: layout.page_index,
role: layout.role ?? null,
margin_left: layout.margin_left ?? null,
margin_right: layout.margin_right ?? null,
margin_top: layout.margin_top ?? null,
margin_bottom: layout.margin_bottom ?? null,
margins_enabled: layout.margins_enabled ?? true,
source: layout.source ?? 'manual',
confirmed: layout.confirmed ?? true,
confidence: layout.confidence ?? null,
derivation: layout.derivation ?? null,
meta: layout.meta ?? {},
};
}
async function replaceTemplate(
templateId: string,
detail: ExamTemplateDetail,
meta?: UpdateTemplateMetaPayload,
duplicateIds = false,
): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const idMap = new Map<string, string>();
if (duplicateIds) {
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
}
const res = await axios.put<ExamTemplateDetail>(
`${EXAM_BASE}/templates/${templateId}`,
{
meta,
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
},
{ headers },
);
return res.data;
}
export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders();
const res = await axios.get<{ templates: ExamTemplate[] }>(
`${EXAM_BASE}/templates`,
{ headers, params: { include_archived: includeArchived } },
);
return res.data.templates ?? [];
},
async getDigitalText(templateId: string): Promise<DigitalTextResponse> {
const headers = await authHeaders();
const res = await axios.get<DigitalTextResponse>(`${EXAM_BASE}/templates/${templateId}/digital-text`, { headers });
return res.data;
},
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
return res.data;
},
async autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
const headers = await authHeaders();
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
return res.data;
},
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
const headers = await authHeaders();
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
return res.data;
},
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
headers,
responseType: 'arraybuffer',
});
return res.data;
},
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data;
},
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
return res.data;
},
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
const detail = await this.getTemplate(templateId);
const created = await this.createTemplate({
title,
subject: detail.subject ?? undefined,
exam_id: detail.exam_id ?? undefined,
exam_code: detail.exam_code ?? undefined,
source_file_id: detail.source_file_id ?? undefined,
page_count: detail.page_count ?? undefined,
institute_id: detail.institute_id,
});
try {
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
} catch (error) {
try {
await this.archiveTemplate(created.id);
} catch (archiveError) {
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
templateId: created.id,
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
});
}
throw error;
}
},
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
return res.data;
},
async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
},
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
const headers = await authHeaders();
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
return res.data;
},
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
const headers = await authHeaders();
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
{ headers, params: search ? { q: search } : undefined },
);
if (Array.isArray(res.data)) return res.data;
return res.data.points ?? [];
},
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
const headers = await authHeaders();
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
return res.data;
},
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
const headers = await authHeaders();
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
return res.data;
},
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
const headers = await authHeaders();
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
headers,
params: {
include_archived: params.includeArchived ?? false,
template_id: params.templateId,
},
});
return res.data.batches ?? [];
},
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
return res.data;
},
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
return res.data;
},
async getBatchCsv(batchId: string): Promise<string> {
const headers = await authHeaders();
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
return res.data;
},
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
const headers = await authHeaders();
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
return res.data;
},
async getCorpus(): Promise<CorpusResponse> {
const headers = await authHeaders();
const res = await axios.get<CorpusResponse>(`${EXAM_BASE}/corpus`, { headers });
return res.data;
},
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
const headers = await authHeaders();
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
headers,
params: { spec_ref: params.specRef, subject: params.subject },
});
return res.data;
},
async createCustomPaper(payload: CreateCustomPaperPayload): Promise<CreateCustomPaperResult> {
const headers = await authHeaders();
const res = await axios.post<CreateCustomPaperResult>(`${EXAM_BASE}/custom-papers`, payload, { headers });
return res.data;
},
async uploadScan(
batchId: string,
file: File,
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
): Promise<StudentSubmission> {
const headers = await authHeaders();
const form = new FormData();
form.append('file', file);
form.append('matching_method', opts.matchingMethod ?? 'ordered');
if (opts.studentId) form.append('student_id', opts.studentId);
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
return res.data;
},
};
export default examRepository;
+17 -11
View File
@@ -19,12 +19,17 @@ export class SharedStoreService {
private lastSaveTime: number = Date.now();
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
private config: AutoSaveConfig;
private isDirty = false;
private dirtyListener: (() => void) | null = null;
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
this.config = {
...DEFAULT_CONFIG,
...config
};
this.dirtyListener = store.listen(() => {
this.isDirty = true;
});
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
}
@@ -52,18 +57,16 @@ export class SharedStoreService {
}
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
const currentSnapshot = getSnapshot(this.store);
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
if (!this.isDirty) {
return;
}
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
await this.saveSnapshot(currentSnapshot, setLoadingState);
this.lastSaveTime = now;
} else {
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
}
this.isDirty = false;
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
this.lastSaveTime = now;
}
}
@@ -109,7 +112,10 @@ export class SharedStoreService {
public clear(): void {
this.stopAutoSave();
this.dirtyListener?.();
this.dirtyListener = null;
this.store.clear();
this.isDirty = false;
logger.debug('shared-store-service', '🧹 Store cleared');
}
}
+25 -6
View File
@@ -1,5 +1,5 @@
// External imports
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService';
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
return;
}
// Sanitize session.currentPageId against pages actually in the document
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
const session = snap.session as { currentPageId?: string } | undefined;
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
currentPageId: session.currentPageId, availablePages: pageIds
});
session.currentPageId = pageIds[0];
}
const snapshotCopy = {
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snap.document,
session: snap.session,
};
const targetStore = editor ? editor.store : store;
try {
if (editor) {
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
} else {
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
loadSnapshot(targetStore, snapshotCopy as any);
// Post-load validation: ensure instance.currentPageId is still valid
const instance = targetStore.get(TLINSTANCE_ID);
if (instance) {
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
const pageValid = pages.some((p) => p.id === instance.currentPageId);
if (!pageValid) {
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
targetStore.clear();
}
}
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} catch (err) {
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
if (isSchemaMigration) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', { error: msg });
} else {
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
targetStore.clear();
}
}
+13
View File
@@ -1,3 +1,16 @@
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
import { expect } from 'vitest';
if (typeof globalThis.matchMedia !== 'function') {
vi.stubGlobal('matchMedia', () => ({
matches: false,
media: '',
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
} as unknown as MediaQueryList))
}
+9 -1
View File
@@ -1,9 +1,17 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { logger } from './debugConfig';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
// resolved against the current browser origin so supabase-js receives an
// absolute URL while every request stays same-origin (no CORS) and routes
// through the app host's /__supabase nginx proxy to the backend Supabase.
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
? `${window.location.origin}${rawSupabaseUrl}`
: rawSupabaseUrl;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase configuration');
}
+233
View File
@@ -0,0 +1,233 @@
/*
* tailwind-compat.css
* Minimal CSS shim replacing the Tailwind build pipeline.
* Covers only the utility classes actually used in this codebase.
* Generated from grep audit of 8 affected files (B3 Tailwind removal sprint).
* Over time, replace these with --cc-* tokens or MUI sx props and remove entries.
*/
/* ── Layout ─────────────────────────────────────────────────────────────── */
.flex { display: flex; }
.grid { display: grid; }
.inline-flex { display: inline-flex; }
.inline-block { display: inline-block; }
.block { display: block; }
.hidden { display: none; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1 1 0%; }
.flex-shrink-0 { flex-shrink: 0; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-start { justify-content: flex-start; }
/* ── Position ───────────────────────────────────────────────────────────── */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
/* ── Spacing — gap ──────────────────────────────────────────────────────── */
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
/* ── Spacing — padding ──────────────────────────────────────────────────── */
.p-0 { padding: 0; }
.p-2 { padding: 0.5rem; }
.p-4 { padding: 1rem; }
.p-5 { padding: 1.25rem; }
.p-6 { padding: 1.5rem; }
.p-12 { padding: 3rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.px-8 { padding-left: 2rem; padding-right: 2rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.py-12{ padding-top: 3rem; padding-bottom: 3rem; }
.py-16{ padding-top: 4rem; padding-bottom: 4rem; }
.pt-4 { padding-top: 1rem; }
.pl-10{ padding-left: 2.5rem; }
.pr-4 { padding-right: 1rem; }
/* ── Spacing — margin ───────────────────────────────────────────────────── */
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mt-8 { margin-top: 2rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-4 { margin-left: 1rem; }
.mr-1 { margin-right: 0.25rem; }
.mr-2 { margin-right: 0.5rem; }
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
/* ── Space-between children ─────────────────────────────────────────────── */
.space-x-2 > * + * { margin-left: 0.5rem; }
.space-y-3 > * + * { margin-top: 0.75rem; }
.space-y-4 > * + * { margin-top: 1rem; }
.space-y-8 > * + * { margin-top: 2rem; }
/* ── Sizing ─────────────────────────────────────────────────────────────── */
.w-0 { width: 0; }
.w-4 { width: 1rem; }
.w-8 { width: 2rem; }
.w-10 { width: 2.5rem; }
.w-12 { width: 3rem; }
.w-16 { width: 4rem; }
.w-full { width: 100%; }
.h-4 { height: 1rem; }
.h-8 { height: 2rem; }
.h-10 { height: 2.5rem; }
.h-12 { height: 3rem; }
.h-64 { height: 16rem; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100vh; }
.max-w-md { max-width: 28rem; }
.max-w-4xl { max-width: 56rem; }
.max-w-6xl { max-width: 72rem; }
.max-w-7xl { max-width: 80rem; }
/* ── Typography ─────────────────────────────────────────────────────────── */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.uppercase { text-transform: uppercase; }
.capitalize { text-transform: capitalize; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Colours — text ─────────────────────────────────────────────────────── */
.text-white { color: #ffffff; }
.text-gray-300 { color: #d1d5db; }
.text-gray-400 { color: #9ca3af; }
.text-gray-500 { color: #6b7280; }
.text-gray-600 { color: #4b5563; }
.text-gray-700 { color: #374151; }
.text-gray-900 { color: #111827; }
.text-blue-600 { color: #2563eb; }
.text-blue-700 { color: #1d4ed8; }
.text-red-600 { color: #dc2626; }
.text-red-700 { color: #b91c1c; }
.text-red-800 { color: #991b1b; }
.text-green-600 { color: #16a34a; }
.text-green-700 { color: #15803d; }
.text-purple-600 { color: #9333ea; }
.text-purple-700 { color: #7e22ce; }
.text-yellow-700 { color: #a16207; }
/* ── Colours — background ───────────────────────────────────────────────── */
.bg-white { background-color: #ffffff; }
.bg-transparent { background-color: transparent; }
.bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; }
.bg-gray-500 { background-color: #6b7280; }
.bg-blue-50 { background-color: #eff6ff; }
.bg-blue-100 { background-color: #dbeafe; }
.bg-blue-600 { background-color: #2563eb; }
.bg-red-50 { background-color: #fef2f2; }
.bg-red-100 { background-color: #fee2e2; }
.bg-red-600 { background-color: #dc2626; }
.bg-green-100 { background-color: #dcfce7; }
.bg-purple-100 { background-color: #f3e8ff; }
.bg-yellow-100 { background-color: #fef9c3; }
/* ── Borders ────────────────────────────────────────────────────────────── */
.border { border-width: 1px; border-style: solid; }
.border-t { border-top-width: 1px; border-top-style: solid; }
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.border-2 { border-width: 2px; border-style: solid; }
.border-transparent { border-color: transparent; }
.border-gray-100 { border-color: #f3f4f6; }
.border-gray-200 { border-color: #e5e7eb; }
.border-gray-300 { border-color: #d1d5db; }
.border-blue-500 { border-color: #3b82f6; }
.border-blue-600 { border-color: #2563eb; }
.border-red-200 { border-color: #fecaca; }
/* ── Border radius ──────────────────────────────────────────────────────── */
.rounded { border-radius: 0.25rem; }
.rounded-md { border-radius: 0.375rem; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-xl { border-radius: 0.75rem; }
.rounded-full { border-radius: 9999px; }
/* ── Shadows ────────────────────────────────────────────────────────────── */
.shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0/.1),0 1px 2px -1px rgb(0 0 0/.1); }
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0/.05); }
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0/.1),0 2px 4px -2px rgb(0 0 0/.1); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0/.1),0 4px 6px -4px rgb(0 0 0/.1); }
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1); }
/* ── Overflow / z-index ─────────────────────────────────────────────────── */
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.z-50 { z-index: 50; }
/* ── Misc ───────────────────────────────────────────────────────────────── */
.opacity-50 { opacity: 0.5; }
.opacity-75 { opacity: 0.75; }
.cursor-not-allowed { cursor: not-allowed; }
/* ── Pseudo-classes ─────────────────────────────────────────────────────── */
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
.hover\:bg-blue-200:hover { background-color: #bfdbfe; }
.hover\:bg-blue-700:hover { background-color: #1d4ed8; }
.hover\:bg-green-200:hover { background-color: #bbf7d0; }
.hover\:bg-red-200:hover { background-color: #fecaca; }
.hover\:bg-red-700:hover { background-color: #b91c1c; }
.hover\:text-gray-500:hover { color: #6b7280; }
.hover\:text-gray-700:hover { color: #374151; }
.hover\:text-gray-800:hover { color: #1f2937; }
.hover\:text-blue-600:hover { color: #2563eb; }
.hover\:text-red-800:hover { color: #991b1b; }
.hover\:border-blue-300:hover { border-color: #93c5fd; }
.focus\:outline-none:focus { outline: none; }
.focus\:ring-2:focus { box-shadow: 0 0 0 2px #3b82f6; }
.disabled\:opacity-50:disabled { opacity: 0.5; }
+415
View File
@@ -0,0 +1,415 @@
/**
* 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;
/** Analyse contract v2 (migration 78): primary command verb for a leaf part. */
command_word?: string | null;
/** Analyse contract v2 (migration 78): stem prose for the outline view. */
preamble?: Record<string, unknown> | 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;
/** Rich recognition payload (migration 75): figure name/description, OMR box geometry, unit/quantity, table… */
meta?: Record<string, unknown> | 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;
meta?: Record<string, unknown> | 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;
}
// ── Mode-3 question bank + custom-paper assembly ──────────────────────────────
export interface BankQuestion {
id: string;
template_id: string;
label: string | null;
max_marks: number | null;
answer_type: string | null;
spec_ref: string | null;
bounds: Record<string, number> | null;
page: number | null;
paper: { id: string | null; title: string | null; subject: string | null; exam_code: string | null };
}
export interface BankResponse {
questions: BankQuestion[];
n: number;
facets: { spec_ref: Record<string, number>; subject: Record<string, number> };
}
export interface CreateCustomPaperPayload {
title: string;
subject?: string;
question_ids: string[];
}
export interface CreateCustomPaperResult {
id: string;
title: string;
subject: string | null;
n_questions: number;
n_response_areas: number;
}
// ── Exam-bank corpus coverage (state of the collected bank) ───────────────────
export type CorpusDocType = 'QP' | 'MS' | 'ER';
export interface CorpusPaper {
paper_code: string | null;
session: string | null;
tier: string | null;
docs: Partial<Record<CorpusDocType, boolean>>;
exam_codes: Partial<Record<CorpusDocType, string>>;
}
export interface CorpusSpec {
spec_code: string;
subject: string;
level: string;
board: string;
first_teach: string | null;
n_papers: number;
counts: Record<CorpusDocType, number>;
papers: CorpusPaper[];
}
export interface CorpusBoard { board: string; n_specs: number; specs: CorpusSpec[] }
export interface CorpusResponse {
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
boards: CorpusBoard[];
}
// ── Digital-replica markdown (P4) ─────────────────────────────────────────────
export interface DigitalTextResponse {
slug: string;
title: string;
n_questions: number;
total_marks: number;
markdown: string;
questions: { label: string; marks: number | null; markdown: string }[];
}
+150
View File
@@ -0,0 +1,150 @@
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('preserves a 3-deep question → part → subpart chain across a round-trip save', () => {
const Q = '11111111-1111-4111-8111-111111111111' // container, depth 0
const P = '22222222-2222-4222-8222-222222222222' // container part, depth 1
const S = '33333333-3333-4333-8333-333333333333' // leaf subpart, depth 2
const detail: ExamTemplateDetail = {
...template,
questions: [
{ id: Q, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 40, y: 80, w: 700, h: 600 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
{ id: P, template_id: 'tpl-1', parent_id: Q, label: 'Q1(a)', order: 1, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 60, y: 120, w: 660, h: 500 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
{ id: S, template_id: 'tpl-1', parent_id: P, label: 'Q1(a)(i)', order: 2, max_marks: 3, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 80, y: 160, w: 620, h: 200 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
],
response_areas: [],
boundaries: [],
}
const shapes = shapesFromTemplate(detail)
// all three tiers are drawn (containers are no longer skipped), with depth + container flags
expect(shapes.filter((s) => s.kind === 'part')).toHaveLength(3)
expect(shapes.find((s) => s.id === S)).toMatchObject({ isContainer: false, depth: 2, parentId: P })
expect(shapes.find((s) => s.id === Q)).toMatchObject({ isContainer: true, depth: 0 })
const payload = serializeCanvasShapes(template, shapes)
const byId = new Map(payload.questions.map((q) => [q.id, q]))
expect(payload.questions).toHaveLength(3)
expect(byId.get(Q)?.parent_id ?? null).toBeNull()
expect(byId.get(P)?.parent_id).toBe(Q) // middle tier survives — not flattened away
expect(byId.get(S)?.parent_id).toBe(P) // 3-deep chain intact
expect(byId.get(Q)?.is_container).toBe(true)
expect(byId.get(S)?.is_container).toBe(false)
expect(byId.get(S)?.max_marks).toBe(3)
})
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
})
})
+297
View File
@@ -0,0 +1,297 @@
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
/** Tree parent (container question) id — preserved across a round-trip so N-deep structure survives a save. */
parentId?: string | null
/** True for a container question (main question / intermediate part) drawn as a nesting box. */
isContainer?: boolean
/** 0 = main question, 1 = part, 2 = subpart … drives nesting styling. */
depth?: number
/** Figure/table caption for a context region (shown as a tooltip). */
description?: string
/** Label of the owning question, for a context region's visible tether pill. */
linkLabel?: string
/** Rich recognition detail (exam_response_areas.meta): answer components, MC option boxes, final-answer
* line unit/quantity, figure name/description. Carried through so the canvas can draw sub-structure. */
meta?: Record<string, unknown> | 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))
// 'part' shapes carry the whole question tree: containers (isContainer) + leaf parts/subparts. They are the
// authoritative structure — the boundary-pair synthesis below is only a fallback for the manual workflow.
const structural = shapes.filter((s) => s.kind === 'part')
const containerShapes = structural.filter((s) => s.isContainer)
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 }> = []
// Stable question id per structural shape — prefer the persisted domain id so a re-save keeps join keys.
const idOf = new Map<string, string>()
for (const s of structural) idOf.set(s.id, isUuid(s.questionId) ? (s.questionId as string) : isUuid(s.id) ? s.id : newDomainId())
const emittedIds = new Set<string>(idOf.values())
// Boundary pairs: attach each pair to the container shape it spans (AI/structured path), else SYNTHESIZE a
// container question from the pair (pure manual authoring, no container box drawn). v1 always synthesized —
// which overwrote real containers and flattened the tree.
for (let i = 0; i < orderedBoundaries.length; i += 2) {
const top = orderedBoundaries[i]
const bottom = orderedBoundaries[i + 1]
if (!top || !bottom) break
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${bands.length + 1}`
// A container box whose vertical extent spans this start/end pair owns the boundary (structured path);
// otherwise the pair itself defines a container (manual path).
const pairTop = Math.min(top.y, bottom.y)
const pairBottom = Math.max(top.y, bottom.y)
const cover = containerShapes.find((c) => c.y <= pairTop + 4 && c.y + c.h >= pairBottom - 4)
let questionId: string
if (cover) {
questionId = idOf.get(cover.id) as string
} else {
questionId = isUuid(top.questionId) ? (top.questionId as string) : isUuid(bottom.questionId) ? (bottom.questionId as string) : newDomainId()
questions.push({ id: questionId, label, order: questions.length, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
emittedIds.add(questionId)
}
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) })
}
}
// Resolve a structural shape's parent: the persisted parent (if still emitted) wins — this is what keeps a
// 3-deep subpart→part→question chain intact across a save. Manually-drawn shapes with no persisted parent
// fall back to geometry: the deepest container box that contains them, else the boundary band around them.
const resolveParent = (s: ExamCanvasShapeModel): string | null => {
if (isUuid(s.parentId) && emittedIds.has(s.parentId as string)) return s.parentId as string
const containing = containerShapes
.filter((c) => c.id !== s.id && contains(bounds(c), bounds(s)))
.sort((a, b) => (b.depth ?? 0) - (a.depth ?? 0) || (a.w * a.h) - (b.w * b.h))
if (containing.length) return idOf.get(containing[0].id) ?? null
const band = bands.find((bd) => bandContains(bd.top, bd.bottom, s))
return band?.questionId ?? null
}
const partQuestionIds = new Map<string, string>()
const structuralSorted = [...structural].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || (a.y - b.y) || (a.x - b.x))
structuralSorted.forEach((part, index) => {
const qid = idOf.get(part.id) as string
partQuestionIds.set(part.id, qid)
questions.push({ id: qid, parent_id: resolveParent(part), label: part.label || (part.isContainer ? `Q${index + 1}` : `Part ${index + 1}`), order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.isContainer ? null : (part.answerType ?? 'written'), mcq_options: null, mark_scheme: {}, is_container: !!part.isContainer, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
})
const parts = structural
// 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'] = []
// Prefer the DEEPEST (smallest) containing structural box so a region lands on its leaf part, not the
// enclosing container. Leaf parts win over containers at equal containment.
const deepestContainer = (region: ExamCanvasShapeModel): ExamCanvasShapeModel | undefined =>
parts
.filter((part) => part.id !== region.id && contains(bounds(part), bounds(region)))
.sort((a, b) => Number(!!a.isContainer) - Number(!!b.isContainer) || (a.w * a.h) - (b.w * b.h))[0]
for (const region of regions) {
const containingPart = deepestContainer(region)
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
const nearestLeaf = parts.filter((p) => !p.isContainer).find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts.find((p) => !p.isContainer) ?? parts[0]
const questionId =
(containingPart && partQuestionIds.get(containingPart.id))
|| persisted
|| (nearestLeaf && partQuestionIds.get(nearestLeaf.id))
|| questions[0]?.id
if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind
// Carry rich recognition detail through the save so it survives a round-trip (the replace endpoint
// persists exam_response_areas.meta): answer components / MC boxes / final-answer for responses, and the
// figure name + caption for context regions.
const carried = (region.meta && typeof region.meta === 'object') ? region.meta as Record<string, unknown> : undefined
const meta = kind === 'context' && (region.label || region.description)
? { ...(carried ?? {}), name: region.label || carried?.name, description: region.description || carried?.description }
: carried
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, meta, 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]))
const depthOf = (id: string | null | undefined, guard = 0): number => {
const q = id ? questions.get(id) : undefined
return !q || !q.parent_id || guard > 8 ? 0 : 1 + depthOf(q.parent_id, guard + 1)
}
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 })
}
// Draw the WHOLE tree that has geometry: containers (main questions / intermediate parts) AND leaf parts,
// each carrying its parent id + depth so nesting renders and a save preserves the N-deep chain. A question
// with no bounds still can't be drawn (legacy data) — it falls back to boundary-band synthesis on save.
for (const q of detail.questions ?? []) {
if (!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.is_container ? undefined : ((q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written'), questionId: q.id, parentId: q.parent_id, isContainer: q.is_container, depth: depthOf(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)
const meta = ((r as { meta?: Record<string, unknown> }).meta) ?? {}
const name = typeof meta.name === 'string' ? meta.name : undefined
const description = typeof meta.description === 'string' ? meta.description : undefined
// A context region is NAMED by its figure/table (was an anonymous purple box). Response/other regions keep
// the "→ owning question" label so their link is legible.
const label = r.kind === 'context'
? (name || (r.context_type && r.context_type !== 'generic' ? r.context_type : 'Context'))
: (q ? `${q.label}` : r.kind)
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, description, linkLabel: q?.label, meta: (r as { meta?: Record<string, unknown> }).meta ?? undefined, 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')
// Containers are MEANT to enclose their children, so never flag them (or their children) for overlap;
// flag only genuine partial overlaps between non-container shapes where neither contains the other.
const samePageOverlap = !shape.isContainer && shape.kind !== 'boundary' && shapes.some((other, otherIndex) => otherIndex !== index && !other.isContainer && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && !contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))
if (samePageOverlap) flags.push('overlapping shapes')
return flags.length ? { ...shape, reviewFlags: flags } : shape
})
}
+13 -53
View File
@@ -1,7 +1,6 @@
import React from 'react'
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
import { CCBaseShape } from './cc-types'
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
import { logger } from '../../../debugConfig'
export interface ToolbarItem {
@@ -40,44 +39,32 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
component(shape: T) {
const {
props: { w, h, isLocked },
props: { w, h, isLocked, headerColor },
} = shape
const toolbarItems = this.getToolbarItems(shape)
return (
<HTMLContainer
id={shape.id}
className="cc-shape-container"
style={{
width: toDomPrecision(w),
height: toDomPrecision(h),
backgroundColor: shape.props.headerColor,
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
overflow: 'hidden',
position: 'relative',
}}
'--shape-header-color': headerColor,
} as React.CSSProperties}
>
{/* Header */}
<div
style={{
backgroundColor: shape.props.headerColor,
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: isLocked ? 'not-allowed' : 'move',
pointerEvents: 'all',
position: 'relative',
zIndex: 1,
}}
className="cc-shape-header"
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
>
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
<span className="shape-title">{shape.props.title}</span>
<div className="cc-shape-toolbar">
{toolbarItems.map((item) => (
<button
key={item.id}
title={item.label}
style={{ opacity: item.isActive ? 1 : 0.7 }}
onClick={(e) => {
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
e.preventDefault()
@@ -89,46 +76,19 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
e.preventDefault()
e.stopPropagation()
}}
style={{
background: 'transparent',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: 'white',
opacity: item.isActive ? 1 : 0.7,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
fontSize: '16px',
width: '24px',
height: '24px',
zIndex: 100,
userSelect: 'none',
position: 'relative',
touchAction: 'none',
}}
>
<div style={{ pointerEvents: 'none' }}>
{item.icon}
</div>
</button>
))}
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
{isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
</div>
</div>
{/* Content */}
<div
style={{
position: 'absolute',
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
backgroundColor: shape.props.backgroundColor,
}}
<div
className="cc-shape-content"
style={{ backgroundColor: shape.props.backgroundColor }}
>
{this.renderContent(shape)}
</div>
@@ -29,106 +29,112 @@ export class CCPlannedLessonNodeShapeUtil extends CCBaseShapeUtil<CCPlannedLesso
renderContent = (shape: CCPlannedLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
<NodeProperty
label="Subject Class"
value={shape.props.subject_class}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Year Group"
value={shape.props.year_group}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Subject"
value={shape.props.subject}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Teacher Code"
value={shape.props.teacher_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Planning Status"
value={shape.props.planning_status}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Homework"
value={shape.props.homework}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
label="Topic Code"
value={shape.props.topic_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Topic Name"
value={shape.props.topic_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Lesson Code"
value={shape.props.lesson_code}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Lesson Name"
value={shape.props.lesson_name}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Learning Statement Codes"
value={shape.props.learning_statement_codes}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Learning Statements"
value={shape.props.learning_statements}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Learning Resource Codes"
value={shape.props.learning_resource_codes}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Learning Resources"
value={shape.props.learning_resources}
labelStyle={styles.property.label}
@@ -137,4 +143,4 @@ export class CCPlannedLessonNodeShapeUtil extends CCBaseShapeUtil<CCPlannedLesso
</div>
)
}
}
}
@@ -17,10 +17,16 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCTimetableLessonNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: theme.headerColor,
headerColor: '',
school_db_name: '',
school_period_id: '',
subject_class: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
}
}
@@ -29,34 +35,34 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
renderContent = (shape: CCTimetableLessonNodeShape) => {
const styles = getNodeStyles(shape.type)
return (
<div style={styles.container}>
<NodeProperty
<NodeProperty
label="Subject Class"
value={shape.props.subject_class}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Date"
value={shape.props.date}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Start Time"
value={shape.props.start_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="End Time"
value={shape.props.end_time}
labelStyle={styles.property.label}
valueStyle={styles.property.value}
/>
<NodeProperty
<NodeProperty
label="Period Code"
value={shape.props.period_code}
labelStyle={styles.property.label}
@@ -65,4 +71,4 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
</div>
)
}
}
}
@@ -5,7 +5,8 @@ import { CCGraphShape, GraphShapeType } from './cc-graph-types'
// Helper function to create version IDs for a shape type
const createVersions = (shapeType: GraphShapeType) => {
return createShapePropsMigrationIds(shapeType, {
Initial: 1 // All shapes start at version 1 as required by TLDraw
Initial: 1, // All shapes start at version 1 as required by TLDraw
AddPlannedLessonHomework: 2,
})
}
@@ -21,6 +22,13 @@ const createMigrationSequence = (shapeType: GraphShapeType) => {
return props
},
},
...(shapeType === 'cc-planned-lesson-node' ? [{
id: versions.AddPlannedLessonHomework,
up: (props: CCGraphShape['props']) => ({
homework: '',
...props,
}),
}] : []),
],
})
}
@@ -199,6 +199,7 @@ export const ccGraphShapeProps = {
subject: T.string,
teacher_code: T.string,
planning_status: T.string,
homework: T.string,
topic_code: T.string,
topic_name: T.string,
lesson_code: T.string,
@@ -543,6 +544,8 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
start_time: '',
end_time: '',
period_code: '',
school_db_name: '',
school_period_id: '',
})
export const getDefaultCCPlannedLessonNodeProps = () => ({
@@ -558,6 +561,7 @@ export const getDefaultCCPlannedLessonNodeProps = () => ({
subject: '',
teacher_code: '',
planning_status: '',
homework: '',
topic_code: '',
topic_name: '',
lesson_code: '',
@@ -22,7 +22,6 @@ import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShape
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
@@ -31,12 +30,11 @@ import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademic
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
// Create a const object with all node types
export const NODE_SHAPE_TYPES = {
USER: CCUserNodeShapeUtil.type,
@@ -82,7 +80,7 @@ export const NODE_SHAPE_TYPES = {
export type NodeShapeType = typeof NODE_SHAPE_TYPES[keyof typeof NODE_SHAPE_TYPES];
// Define AllNodeShapes as a union type of all shape types
export type AllNodeShapes =
export type AllNodeShapes =
| CCUserNodeShape
| CCTeacherNodeShape
| CCStudentNodeShape
@@ -118,8 +116,7 @@ export type AllNodeShapes =
| CCTimetableLessonNodeShape
| CCPlannedLessonNodeShape
| CCDepartmentStructureNodeShape
| CCUserTeacherTimetableNodeShape
| CCTimetableLessonNodeShape;
| CCUserTeacherTimetableNodeShape;
// Export all shape utils in an object for easy access
export const ShapeUtils = {
@@ -159,17 +156,16 @@ export const ShapeUtils = {
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
} as const;
// Add a type guard to check if a shape is a valid node shape
export const isValidNodeShape = (shape: TLShape): shape is AllNodeShapes => {
return shape &&
typeof shape.type === 'string' &&
return shape &&
typeof shape.type === 'string' &&
Object.values(NODE_SHAPE_TYPES).includes(shape.type as NodeShapeType);
};
// Add a type guard to check if a type string is a valid node type
export const isValidNodeType = (type: string): type is NodeShapeType => {
return Object.values(NODE_SHAPE_TYPES).includes(type as NodeShapeType);
};
};
@@ -204,6 +204,7 @@ export type CCPlannedLessonNodeProps = CCGraphShapeProps & {
subject: string
teacher_code: string
planning_status: string
homework: string
topic_code: string
topic_name: string
lesson_code: string
@@ -0,0 +1,441 @@
/**
* Unit tests for CCLiveTranscriptionShapeUtil
*
* Intended production location:
* src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts
*
* Run from classroom-copilot-app root:
* npx vitest run transcription-shape
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Editor, createShapeId, createTLStore } from '@tldraw/tldraw'
import { CCLiveTranscriptionShapeUtil, type CCLiveTranscriptionShape, type TranscriptionSegment } from './CCLiveTranscriptionShapeUtil'
import { CCSlideShapeUtil } from '../cc-slideshow/CCSlideShapeUtil'
import { CCSlideShowShapeUtil } from '../cc-slideshow/CCSlideShowShapeUtil'
import { getDefaultCCLiveTranscriptionProps } from '../cc-props'
// ─── Mock TranscriptionManager ──────────────────────────────────────────────
// Must be hoisted before the module under test is imported in a real project.
// Here we patch the module directly since we control the import order.
// Declare symbols before vi.mock so hoisting/factory capture stays consistent.
const mockStartTranscription = () => {}
const mockStopTranscription = () => {}
vi.mock('./TranscriptionManager', () => {
const start = vi.fn()
const stop = vi.fn()
Object.assign(globalThis, { __mockStartTranscription: start, __mockStopTranscription: stop })
return {
TranscriptionManager: {
getManager: vi.fn().mockReturnValue({ startTranscription: start, stopTranscription: stop }),
},
}
})
const sm = () => (globalThis as unknown as Record<string, any>).__mockStartTranscription
const st = () => (globalThis as unknown as Record<string, any>).__mockStopTranscription
const clearMocks = () => { sm().mockClear(); st().mockClear() }
// Provide a minimal matchMedia so tldraw's UserPreferencesManager
// can call addEventListener/removeEventListener in jsdom.
if (typeof globalThis.matchMedia !== 'function') {
vi.stubGlobal('matchMedia', () => ({
matches: false,
media: '',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
} as unknown as MediaQueryList))
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Minimal shape utils needed to boot the Editor for transcription tests. */
const shapeUtils = [CCLiveTranscriptionShapeUtil, CCSlideShapeUtil, CCSlideShowShapeUtil]
function makeEditor(): Editor {
const store = createTLStore({ shapeUtils, bindingUtils: [] })
return new Editor({
shapeUtils,
bindingUtils: [],
tools: [],
store,
getContainer: () => document.body,
})
}
/** Create a transcription shape with optional prop overrides and return its id. */
function createTranscriptionShape(
editor: Editor,
overrides: Partial<CCLiveTranscriptionShape['props']> = {}
) {
const id = createShapeId()
editor.createShape<CCLiveTranscriptionShape>({
id,
type: 'cc-live-transcription',
x: 0,
y: 0,
props: {
...getDefaultCCLiveTranscriptionProps(),
...overrides,
} as CCLiveTranscriptionShape['props'],
})
return id
}
/** Typed getter for the shape record. */
function getShape(editor: Editor, id: ReturnType<typeof createShapeId>) {
return editor.getShape<CCLiveTranscriptionShape>(id)!
}
/** Build a finished TranscriptionSegment fixture. */
function makeSegment(text: string, overrides: Partial<TranscriptionSegment> = {}): TranscriptionSegment {
return {
id: crypto.randomUUID(),
text,
completed: true,
start: '0.000',
end: '1.000',
...overrides,
}
}
// ─── Test Suite ──────────────────────────────────────────────────────────────
describe('CCLiveTranscriptionShapeUtil', () => {
let editor: Editor
let util: CCLiveTranscriptionShapeUtil
beforeEach(() => {
editor = makeEditor()
util = editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription')
clearMocks()
})
afterEach(() => {
editor.dispose()
})
// ── getDefaultProps ─────────────────────────────────────────────────────
describe('getDefaultProps()', () => {
it('returns the expected default structure', () => {
const defaults = util.getDefaultProps()
expect(defaults).toMatchObject({
isRecording: false,
segments: [],
currentSegment: undefined,
lastProcessedSegment: undefined,
})
// Base props present
expect(typeof defaults.title).toBe('string')
expect(typeof defaults.w).toBe('number')
expect(typeof defaults.h).toBe('number')
expect(typeof defaults.headerColor).toBe('string')
expect(typeof defaults.backgroundColor).toBe('string')
expect(defaults.isLocked).toBe(false)
})
it('segments array is empty, not null or undefined', () => {
const { segments } = util.getDefaultProps()
expect(Array.isArray(segments)).toBe(true)
expect(segments).toHaveLength(0)
})
})
// ── updateText — unconfirmed (live partial) ─────────────────────────────
describe('updateText() — unconfirmed segment (isConfirmed = false)', () => {
it('updates only currentSegment, does not touch the segments array', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
const shape = getShape(editor, id)
expect(shape.props.segments).toHaveLength(0)
expect(shape.props.currentSegment).toMatchObject({
text: 'hello wor',
completed: false,
})
})
it('overwrites currentSegment when called again with new partial text', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
util.updateText(id, 'hello world', false, { start: 0, end: 1.2 })
const shape = getShape(editor, id)
expect(shape.props.currentSegment?.text).toBe('hello world')
expect(shape.props.segments).toHaveLength(0)
})
it('does NOT call updateShape when the partial text is unchanged', () => {
const id = createTranscriptionShape(editor, {
isRecording: true,
currentSegment: makeSegment('same text', { completed: false }),
})
const spy = vi.spyOn(editor, 'updateShape')
util.updateText(id, 'same text', false, { start: 0, end: 1.0 })
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
})
// ── updateText — confirmed (completed) segment ─────────────────────────
describe('updateText() — confirmed segment (isConfirmed = true)', () => {
it('appends to segments array when text is new', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'First sentence.', true, { start: 0, end: 2.1 })
const shape = getShape(editor, id)
expect(shape.props.segments).toHaveLength(1)
expect(shape.props.segments[0]).toMatchObject({
text: 'First sentence.',
completed: true,
start: '0.000',
end: '2.100',
})
})
it('updates lastProcessedSegment to the confirmed text', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'Second sentence.', true, { start: 2, end: 4 })
expect(getShape(editor, id).props.lastProcessedSegment).toBe('Second sentence.')
})
it('clears currentSegment when its text matches the confirmed text', () => {
const id = createTranscriptionShape(editor, {
isRecording: true,
currentSegment: makeSegment('Final words.', { completed: false }),
})
util.updateText(id, 'Final words.', true, { start: 5, end: 7 })
expect(getShape(editor, id).props.currentSegment).toBeUndefined()
})
it('preserves currentSegment when its text differs from the confirmed text', () => {
const id = createTranscriptionShape(editor, {
isRecording: true,
currentSegment: makeSegment('still typing...', { completed: false }),
})
util.updateText(id, 'Earlier sentence confirmed.', true, { start: 0, end: 3 })
expect(getShape(editor, id).props.currentSegment?.text).toBe('still typing...')
})
it('DEDUPLICATION — does not add a segment whose text is already in segments[]', () => {
const existingSegment = makeSegment('Duplicate text.')
const id = createTranscriptionShape(editor, {
isRecording: true,
segments: [existingSegment],
lastProcessedSegment: 'Duplicate text.',
})
util.updateText(id, 'Duplicate text.', true, { start: 10, end: 12 })
const shape = getShape(editor, id)
expect(shape.props.segments).toHaveLength(1)
expect(shape.props.segments[0].id).toBe(existingSegment.id)
})
it('DEDUPLICATION — blocks re-add even when lastProcessedSegment differs (isDuplicate text check)', () => {
const existingSegment = makeSegment('Already confirmed.')
const id = createTranscriptionShape(editor, {
isRecording: true,
segments: [existingSegment],
lastProcessedSegment: undefined,
})
util.updateText(id, 'Already confirmed.', true, { start: 15, end: 17 })
expect(getShape(editor, id).props.segments).toHaveLength(1)
})
it('accumulates multiple unique confirmed segments in order', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'Sentence one.', true, { start: 0, end: 2 })
util.updateText(id, 'Sentence two.', true, { start: 2, end: 4 })
util.updateText(id, 'Sentence three.', true, { start: 4, end: 6 })
const { segments } = getShape(editor, id).props
expect(segments).toHaveLength(3)
expect(segments.map((s) => s.text)).toEqual([
'Sentence one.',
'Sentence two.',
'Sentence three.',
])
})
it('assigns unique ids to each confirmed segment', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'Alpha.', true, { start: 0, end: 1 })
util.updateText(id, 'Beta.', true, { start: 1, end: 2 })
const { segments } = getShape(editor, id).props
expect(segments[0].id).not.toBe(segments[1].id)
})
it('formats numeric start/end timestamps to 3 decimal places', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'Timestamped.', true, { start: 1.1, end: 2.5678 })
const seg = getShape(editor, id).props.segments[0]
expect(seg.start).toBe('1.100')
expect(seg.end).toBe('2.568')
})
})
// ── toggleRecording ────────────────────────────────────────────────────
describe('toggleRecording() — via private accessor', () => {
// TypeScript private methods are still callable at runtime; we use
// bracket notation to bypass the access check cleanly in tests.
const toggle = (u: CCLiveTranscriptionShapeUtil, shape: CCLiveTranscriptionShape) =>
(u as unknown as { toggleRecording(s: CCLiveTranscriptionShape): void }).toggleRecording(shape)
it('STARTING — sets isRecording to true', () => {
const id = createTranscriptionShape(editor, { isRecording: false })
const shape = getShape(editor, id)
toggle(util, shape)
expect(getShape(editor, id).props.isRecording).toBe(true)
})
it('STARTING — resets segments to an empty array', () => {
const id = createTranscriptionShape(editor, {
isRecording: false,
segments: [makeSegment('old transcript'), makeSegment('more old text')],
})
toggle(util, getShape(editor, id))
expect(getShape(editor, id).props.segments).toHaveLength(0)
})
it('STARTING — clears currentSegment and lastProcessedSegment', () => {
const id = createTranscriptionShape(editor, {
isRecording: false,
currentSegment: makeSegment('partial', { completed: false }),
lastProcessedSegment: 'some prior segment',
})
toggle(util, getShape(editor, id))
const props = getShape(editor, id).props
expect(props.currentSegment).toBeUndefined()
expect(props.lastProcessedSegment).toBeUndefined()
})
it('STARTING — calls TranscriptionManager.startTranscription with the shape id', () => {
const id = createTranscriptionShape(editor, { isRecording: false })
const shape = getShape(editor, id)
toggle(util, shape)
expect(sm()).toHaveBeenCalledOnce()
expect(sm()).toHaveBeenCalledWith(id)
})
it('STOPPING — sets isRecording to false', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
const shape = getShape(editor, id)
toggle(util, shape)
expect(getShape(editor, id).props.isRecording).toBe(false)
})
it('STOPPING — preserves existing segments (does not wipe them)', () => {
const seg1 = makeSegment('Keep me.')
const seg2 = makeSegment('Keep me too.')
const id = createTranscriptionShape(editor, {
isRecording: true,
segments: [seg1, seg2],
})
toggle(util, getShape(editor, id))
const { segments } = getShape(editor, id).props
expect(segments).toHaveLength(2)
expect(segments[0].text).toBe('Keep me.')
expect(segments[1].text).toBe('Keep me too.')
})
it('STOPPING — calls TranscriptionManager.stopTranscription', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
toggle(util, getShape(editor, id))
expect(st()).toHaveBeenCalledOnce()
expect(sm()).not.toHaveBeenCalled()
})
it('start-stop cycle leaves shape in consistent state', () => {
const id = createTranscriptionShape(editor, { isRecording: false })
toggle(util, getShape(editor, id))
expect(getShape(editor, id).props.isRecording).toBe(true)
util.updateText(id, 'Live lesson note.', true, { start: 0, end: 3 })
toggle(util, getShape(editor, id))
const props = getShape(editor, id).props
expect(props.isRecording).toBe(false)
expect(props.segments).toHaveLength(1)
expect(props.segments[0].text).toBe('Live lesson note.')
})
})
// ── Edge cases ─────────────────────────────────────────────────────────
describe('updateText() — edge cases', () => {
it('does nothing if the shape id does not exist in the store', () => {
const ghostId = createShapeId('nonexistent')
expect(() => util.updateText(ghostId, 'text', true, { start: 0, end: 1 })).not.toThrow()
})
it('handles empty string text without crashing', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
expect(() => util.updateText(id, '', true, { start: 0, end: 0 })).not.toThrow()
})
it('handles string start/end metadata (passed as already-formatted strings)', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'String timestamps.', true, { start: '1.234' as unknown as number, end: '5.678' as unknown as number })
const seg = getShape(editor, id).props.segments[0]
expect(typeof seg.start).toBe('string')
expect(typeof seg.end).toBe('string')
})
it('handles missing metadata gracefully, falling back to "0.000"', () => {
const id = createTranscriptionShape(editor, { isRecording: true })
util.updateText(id, 'No timestamps.', true, undefined as unknown as { start: number; end: number })
const seg = getShape(editor, id).props.segments[0]
expect(seg.start).toBe('0.000')
expect(seg.end).toBe('0.000')
})
})
})
@@ -6,6 +6,7 @@ import { TranscriptionManager } from '../cc-transcription/TranscriptionManager';
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
import { ccShapeMigrations } from '../cc-migrations';
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
import { logger } from '../../../../debugConfig';
export interface TranscriptionSegment {
id: string
@@ -83,7 +84,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
height: '48px',
borderRadius: '50%',
border: 'none',
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
color: 'white',
fontSize: '24px',
cursor: 'pointer',
@@ -105,7 +106,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: isRecording ? '#f44336' : '#4CAF50'
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
}}>
{isRecording ? 'Recording...' : 'Ready'}
</div>
@@ -207,11 +208,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
}
private toggleRecording(shape: CCLiveTranscriptionShape) {
console.log('🎤 Toggle recording clicked');
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
const { id } = shape;
const { isRecording } = shape.props;
console.log('Current state:', { id, isRecording });
logger.debug('cc-live-transcription', 'Current state', { id, isRecording });
// When starting new recording, preserve existing props but reset segments
const newProps = !isRecording ? {
@@ -232,13 +233,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
});
const manager = TranscriptionManager.getManager(this.editor);
console.log('Got transcription manager');
logger.debug('cc-live-transcription', 'Got transcription manager');
if (!isRecording) {
console.log('Starting transcription...');
logger.debug('cc-live-transcription', 'Starting transcription...');
manager.startTranscription(id);
} else {
console.log('Stopping transcription...');
logger.debug('cc-live-transcription', 'Stopping transcription...');
manager.stopTranscription();
}
}
@@ -249,10 +250,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
isConfirmed: boolean,
metadata?: { start: string | number, end: string | number }
) {
console.log('📝 Updating text:', { id, text, isConfirmed, metadata });
logger.debug('cc-live-transcription', '📝 Updating text', { id, text, isConfirmed, metadata });
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
if (!shape) {
console.warn('❌ Shape not found for updating text:', id);
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
return;
}
@@ -308,7 +309,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
});
}
console.log('✅ Text updated');
logger.debug('cc-live-transcription', '✅ Text updated');
}
}
@@ -27,8 +27,8 @@ export class TranscriptionManager {
this.transcriptionService = new TranscriptionService();
// Set up callback for transcription updates
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
console.log('📝 Transcription update received:', { text, metadata });
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
if (!util) {
console.warn('❌ Shape util not found');
@@ -36,6 +36,10 @@ export class TranscriptionManager {
}
console.log('Found transcription util:', !!util);
const text = segments.map(s => s.text).join('') || '';
const metadata = segments.length ? { start: segments[0].start, end: segments[segments.length - 1].end } : { start: 0, end: 0 };
const isFinal = isLastLive;
// Check if text is stable (same output multiple times)
const isStable = text === this.lastText;
if (isStable) {
@@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
overflowY: 'auto',
maxHeight: '100%',
minWidth: '200px',
backgroundColor: '#f5f5f5',
borderLeft: '1px solid #ddd',
backgroundColor: 'var(--cc-navy-50)',
borderLeft: '1px solid var(--cc-border)',
},
title: {
margin: '0 0 10px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333',
color: 'var(--cc-text-primary)',
},
line: {
padding: '5px',
marginBottom: '5px',
borderRadius: '4px',
fontSize: '14px',
color: '#333',
backgroundColor: '#fff',
border: '1px solid #eee',
color: 'var(--cc-text-primary)',
backgroundColor: 'var(--cc-bg-canvas)',
border: '1px solid var(--cc-border-strong)',
},
activeLine: {
backgroundColor: '#e3f2fd',
border: '1px solid #2196f3',
color: '#1565c0',
backgroundColor: 'var(--cc-navy-50)',
border: '1px solid var(--cc-action-primary)',
color: 'var(--cc-action-primary)',
fontWeight: 'bold',
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
transform: 'scale(1.02)',
transition: 'all 0.2s ease-in-out',
},
timestamp: {
color: '#666',
color: 'var(--cc-text-tertiary)',
marginRight: '8px',
fontWeight: 'bold',
},
loading: {
color: '#666',
color: 'var(--cc-text-muted)',
fontStyle: 'italic',
},
},
@@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
},
button: {
padding: '8px 12px',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
backgroundColor: 'var(--cc-navy-50)',
border: '1px solid var(--cc-border)',
borderRadius: '4px',
cursor: 'pointer',
color: '#333',
color: 'var(--cc-text-primary)',
fontSize: '14px',
'&:hover': {
backgroundColor: '#e0e0e0',
backgroundColor: 'var(--cc-border-strong)',
},
},
},
+976
View File
@@ -0,0 +1,976 @@
/* ============================================================
CLASSROOM COPILOT DESIGN SYSTEM
Aesthetic Direction: Technical Editorial / Industrial Utilitarian
Philosophy: Drafting table discipline. Every element earns
its place. Tone contrast over shadow depth. Grid over flow.
============================================================ */
/* ─── TYPOGRAPHY IMPORTS ───────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
/* ─── ROOT TOKENS — LIGHT MODE ─────────────────────────────── */
:root {
/* Typography */
--cc-font-sans: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
--cc-font-mono: 'IBM Plex Mono', 'Fira Code', 'Cascadia Code', monospace;
--cc-text-xs: 0.6875rem; /* 11px */
--cc-text-sm: 0.75rem; /* 12px */
--cc-text-base: 0.875rem; /* 14px */
--cc-text-md: 1rem; /* 16px */
--cc-text-lg: 1.125rem; /* 18px */
--cc-text-xl: 1.25rem; /* 20px */
--cc-weight-normal: 400;
--cc-weight-medium: 500;
--cc-weight-semibold: 600;
--cc-leading-tight: 1.2;
--cc-leading-normal: 1.5;
--cc-leading-loose: 1.7;
--cc-tracking-tight: -0.01em;
--cc-tracking-normal: 0;
--cc-tracking-wide: 0.04em;
--cc-tracking-widest: 0.12em;
/* ── Color Primitives ── */
--cc-navy-950: #0D1117;
--cc-navy-900: #14191F;
--cc-navy-800: #1C2330;
--cc-navy-700: #263040;
--cc-navy-600: #344256;
--cc-navy-500: #435470;
--cc-navy-400: #5A6E8A;
--cc-navy-300: #7A8FA8;
--cc-navy-200: #A4B4C8;
--cc-navy-100: #D2DCE8;
--cc-navy-50: #EBF0F5;
--cc-parchment-950: #2A2519;
--cc-parchment-900: #3D3626;
--cc-parchment-800: #5C5238;
--cc-parchment-700: #7A6E4E;
--cc-parchment-600: #9A8C6A;
--cc-parchment-500: #B8A882;
--cc-parchment-400: #CEC0A0;
--cc-parchment-300: #DED2BA;
--cc-parchment-200: #EDE5D4;
--cc-parchment-100: #F5F2ED;
--cc-parchment-50: #FAF8F5;
--cc-amber-700: #8A5C1A;
--cc-amber-600: #A8711F;
--cc-amber-500: #C9882A;
--cc-amber-400: #D9A24E;
--cc-amber-300: #E6BC78;
--cc-amber-200: #F0D4A8;
--cc-amber-100: #F8EDDA;
--cc-sage-700: #2E3E36;
--cc-sage-600: #3A5046;
--cc-sage-500: #4A5E52;
--cc-sage-400: #607A6C;
--cc-sage-300: #7E9688;
--cc-sage-200: #A8BDB4;
--cc-sage-100: #D2DDD9;
--cc-crimson-600: #8B1A1A;
--cc-crimson-500: #A82828;
--cc-crimson-400: #C44040;
--cc-crimson-100: #F5DADA;
--cc-signal-green-600: #1A5C36;
--cc-signal-green-500: #246B42;
--cc-signal-green-400: #347A52;
--cc-signal-green-100: #D4EDE0;
/* ── Semantic Color Assignments — Light ── */
/* Surfaces */
--cc-bg-canvas: var(--cc-parchment-100);
--cc-bg-panel: var(--cc-parchment-50);
--cc-bg-panel-alt: #FFFFFF;
--cc-bg-shape: #FFFFFF;
--cc-bg-shape-hover: var(--cc-parchment-100);
--cc-bg-overlay: rgba(13, 17, 23, 0.48);
--cc-bg-code: var(--cc-navy-900);
/* Shape / card header tones */
--cc-header-default: var(--cc-navy-700);
--cc-header-accent: var(--cc-amber-500);
--cc-header-sage: var(--cc-sage-600);
--cc-header-dim: var(--cc-navy-500);
/* Text */
--cc-text-primary: var(--cc-navy-900);
--cc-text-secondary: var(--cc-navy-500);
--cc-text-tertiary: var(--cc-navy-300);
--cc-text-inverse: var(--cc-parchment-100);
--cc-text-accent: var(--cc-amber-500);
--cc-text-code: var(--cc-parchment-200);
--cc-text-link: var(--cc-amber-600);
/* Borders */
--cc-border: var(--cc-navy-100);
--cc-border-strong: var(--cc-navy-200);
--cc-border-accent: var(--cc-amber-500);
--cc-border-focus: var(--cc-amber-400);
--cc-border-error: var(--cc-crimson-500);
--cc-border-success: var(--cc-signal-green-500);
/* Interactive */
--cc-action-primary: var(--cc-amber-500);
--cc-action-primary-hover: var(--cc-amber-400);
--cc-action-primary-active: var(--cc-amber-600);
--cc-action-secondary: var(--cc-navy-700);
--cc-action-secondary-hover: var(--cc-navy-600);
--cc-action-danger: var(--cc-crimson-500);
--cc-action-success: var(--cc-signal-green-500);
/* States */
--cc-selected: var(--cc-amber-100);
--cc-hover: var(--cc-parchment-200);
--cc-active: var(--cc-parchment-300);
--cc-disabled: var(--cc-navy-100);
/* Recording / live indicator */
--cc-recording-active: var(--cc-crimson-500);
--cc-recording-idle: var(--cc-signal-green-500);
/* ── Geometry ── */
--cc-radius-none: 0;
--cc-radius-xs: 1px;
--cc-radius-sm: 2px; /* maximum for shapes */
--cc-radius-md: 4px; /* panels, menus */
--cc-radius-full: 9999px;
/* ── Spacing scale (8pt grid) ── */
--cc-space-0: 0;
--cc-space-px: 1px;
--cc-space-0_5: 2px;
--cc-space-1: 4px;
--cc-space-2: 8px;
--cc-space-3: 12px;
--cc-space-4: 16px;
--cc-space-5: 20px;
--cc-space-6: 24px;
--cc-space-8: 32px;
--cc-space-10: 40px;
--cc-space-12: 48px;
--cc-space-16: 64px;
/* ── Shape dimensions ── */
--cc-shape-header-h: 32px;
--cc-shape-header-pad: var(--cc-space-2);
--cc-shape-content-pad: var(--cc-space-2);
--cc-shape-min-w: 120px;
--cc-shape-min-h: 80px;
/* ── Panel dimensions ── */
--cc-panel-w: 280px;
--cc-panel-header-h: 44px;
--cc-panel-handle-w: 20px;
/* ── Borders ── */
--cc-border-width: 1px;
--cc-border-width-thick: 2px;
/* No blurry box shadows. Depth through tone. */
--cc-shadow-none: none;
--cc-shadow-edge: 1px 0 0 0 var(--cc-border);
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-200);
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-200);
/* ── Z-index system ── */
--cc-z-canvas: 0;
--cc-z-shape: 1;
--cc-z-panel: 200;
--cc-z-toolbar: 300;
--cc-z-menu: 400;
--cc-z-modal: 500;
--cc-z-toast: 600;
/* ── Motion ── */
--cc-transition-snap: 80ms linear;
--cc-transition-quick: 120ms ease-out;
--cc-transition-base: 200ms ease-out;
--cc-transition-slow: 320ms ease-out;
}
/* ─── DARK MODE TOKENS ──────────────────────────────────────── */
[data-color-mode="dark"] {
/* Surfaces */
--cc-bg-canvas: var(--cc-navy-950);
--cc-bg-panel: var(--cc-navy-900);
--cc-bg-panel-alt: var(--cc-navy-800);
--cc-bg-shape: var(--cc-navy-800);
--cc-bg-shape-hover: var(--cc-navy-700);
--cc-bg-overlay: rgba(13, 17, 23, 0.72);
--cc-bg-code: var(--cc-navy-950);
/* Shape headers get slightly brighter in dark */
--cc-header-default: var(--cc-navy-600);
--cc-header-accent: var(--cc-amber-600);
--cc-header-sage: var(--cc-sage-500);
--cc-header-dim: var(--cc-navy-700);
/* Text */
--cc-text-primary: var(--cc-parchment-100);
--cc-text-secondary: var(--cc-navy-200);
--cc-text-tertiary: var(--cc-navy-300);
--cc-text-inverse: var(--cc-navy-900);
--cc-text-accent: var(--cc-amber-400);
--cc-text-code: var(--cc-parchment-300);
--cc-text-link: var(--cc-amber-400);
/* Borders */
--cc-border: var(--cc-navy-700);
--cc-border-strong: var(--cc-navy-600);
--cc-border-accent: var(--cc-amber-500);
--cc-border-focus: var(--cc-amber-300);
--cc-border-error: var(--cc-crimson-400);
--cc-border-success: var(--cc-signal-green-400);
/* Interactive */
--cc-action-primary: var(--cc-amber-500);
--cc-action-primary-hover: var(--cc-amber-300);
--cc-action-primary-active: var(--cc-amber-600);
--cc-action-secondary: var(--cc-navy-400);
--cc-action-secondary-hover: var(--cc-navy-300);
/* States */
--cc-selected: rgba(201, 136, 42, 0.15);
--cc-hover: var(--cc-navy-800);
--cc-active: var(--cc-navy-700);
--cc-disabled: var(--cc-navy-700);
/* Shadows become tone dividers in dark */
--cc-shadow-edge: 1px 0 0 0 var(--cc-navy-700);
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-950);
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-950);
}
/* ─── BASE RESET (minimal — respects tldraw's own base) ────── */
.cc-root * {
box-sizing: border-box;
font-family: var(--cc-font-sans);
}
.cc-root code,
.cc-root pre,
.cc-mono {
font-family: var(--cc-font-mono);
}
/* ─── SHAPE CARD SYSTEM ─────────────────────────────────────── */
/*
.cc-shape-container
.cc-shape-header
.cc-shape-content
*/
.cc-shape-container {
display: grid;
grid-template-rows: var(--cc-shape-header-h) 1fr;
width: 100%;
height: 100%;
background: var(--cc-bg-shape);
border: var(--cc-border-width) solid var(--cc-border);
border-radius: var(--cc-radius-sm);
overflow: hidden;
/* Flat depth — no blur */
box-shadow: var(--cc-shadow-crisp);
transition: box-shadow var(--cc-transition-quick);
}
.cc-shape-container:hover {
box-shadow: var(--cc-shadow-raised);
}
.cc-shape-container--locked {
opacity: 0.72;
cursor: not-allowed;
}
/* Header strip — the primary identification band */
.cc-shape-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: 0 var(--cc-shape-header-pad);
height: var(--cc-shape-header-h);
background: var(--cc-header-default);
color: var(--cc-text-inverse);
/* Hairline rule below header, not shadow */
border-bottom: var(--cc-border-width) solid rgba(255,255,255,0.12);
cursor: move;
user-select: none;
}
/* Accent variant headers */
.cc-shape-header--accent { background: var(--cc-header-accent); color: var(--cc-navy-900); }
.cc-shape-header--sage { background: var(--cc-header-sage); }
.cc-shape-header--dim { background: var(--cc-header-dim); }
.cc-shape-header__title {
font-size: var(--cc-text-sm);
font-weight: var(--cc-weight-semibold);
letter-spacing: var(--cc-tracking-wide);
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cc-shape-header__actions {
display: flex;
align-items: center;
gap: var(--cc-space-1);
}
/* Toolbar buttons inside shape headers */
.cc-shape-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: var(--cc-radius-xs);
background: transparent;
color: inherit;
opacity: 0.65;
cursor: pointer;
font-size: 12px;
line-height: 1;
touch-action: none;
transition:
opacity var(--cc-transition-snap),
background var(--cc-transition-snap);
}
.cc-shape-btn:hover {
opacity: 1;
background: rgba(255,255,255,0.15);
}
.cc-shape-btn:active {
background: rgba(255,255,255,0.25);
transform: scale(0.92);
}
.cc-shape-btn--active {
opacity: 1;
background: rgba(255,255,255,0.2);
}
/* Content area */
.cc-shape-content {
padding: var(--cc-shape-content-pad);
overflow: auto;
background: var(--cc-bg-shape);
color: var(--cc-text-primary);
font-size: var(--cc-text-base);
line-height: var(--cc-leading-normal);
/* Hide scrollbar visually, keep functionality */
scrollbar-width: thin;
scrollbar-color: var(--cc-border-strong) transparent;
}
.cc-shape-content::-webkit-scrollbar {
width: 4px;
}
.cc-shape-content::-webkit-scrollbar-thumb {
background: var(--cc-border-strong);
border-radius: var(--cc-radius-xs);
}
.cc-shape-content::-webkit-scrollbar-track {
background: transparent;
}
/* ─── PANEL SYSTEM ──────────────────────────────────────────── */
/*
.cc-panel
.cc-panel-header
.cc-panel-content
*/
.cc-panel {
display: grid;
grid-template-rows: var(--cc-panel-header-h) 1fr;
width: var(--cc-panel-w);
height: 100%;
background: var(--cc-bg-panel);
border-right: var(--cc-border-width) solid var(--cc-border);
overflow: hidden;
position: absolute;
left: 0;
top: 0;
z-index: var(--cc-z-panel);
}
.cc-panel-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--cc-space-2);
padding: 0 var(--cc-space-2);
height: var(--cc-panel-header-h);
background: var(--cc-bg-panel);
border-bottom: var(--cc-border-width) solid var(--cc-border-strong);
/* Left accent stripe — the editorial mark */
box-shadow: inset 2px 0 0 0 var(--cc-border-accent);
}
.cc-panel-content {
overflow-y: auto;
padding: var(--cc-space-2);
scrollbar-width: thin;
scrollbar-color: var(--cc-border) transparent;
}
.cc-panel-content::-webkit-scrollbar {
width: 3px;
}
.cc-panel-content::-webkit-scrollbar-thumb {
background: var(--cc-border-strong);
}
/* Panel tab / type selector */
.cc-panel-selector {
display: flex;
align-items: center;
gap: var(--cc-space-1);
padding: var(--cc-space-1) var(--cc-space-2);
background: transparent;
border: var(--cc-border-width) solid var(--cc-border);
border-radius: var(--cc-radius-sm);
color: var(--cc-text-primary);
font-family: var(--cc-font-sans);
font-size: var(--cc-text-sm);
font-weight: var(--cc-weight-medium);
cursor: pointer;
transition:
border-color var(--cc-transition-quick),
background var(--cc-transition-quick);
white-space: nowrap;
}
.cc-panel-selector:hover {
border-color: var(--cc-border-strong);
background: var(--cc-hover);
}
.cc-panel-selector__icon {
width: 14px;
height: 14px;
opacity: 0.7;
flex-shrink: 0;
}
.cc-panel-selector__chevron {
margin-left: auto;
opacity: 0.5;
transition: transform var(--cc-transition-quick);
}
.cc-panel-selector[aria-expanded="true"] .cc-panel-selector__chevron {
transform: rotate(180deg);
}
/* Panel collapse handle */
.cc-panel-handle {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: var(--cc-panel-handle-w);
height: 40px;
background: var(--cc-bg-panel);
border: var(--cc-border-width) solid var(--cc-border);
border-left: none;
border-radius: 0 var(--cc-radius-md) var(--cc-radius-md) 0;
color: var(--cc-text-tertiary);
font-size: 10px;
cursor: pointer;
z-index: var(--cc-z-panel);
transition:
color var(--cc-transition-quick),
background var(--cc-transition-quick);
}
.cc-panel-handle:hover {
background: var(--cc-hover);
color: var(--cc-text-primary);
}
/* Pin button */
.cc-pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--cc-radius-sm);
background: transparent;
color: var(--cc-text-tertiary);
cursor: pointer;
transition:
color var(--cc-transition-quick),
background var(--cc-transition-quick);
}
.cc-pin-btn:hover {
background: var(--cc-hover);
color: var(--cc-text-primary);
}
.cc-pin-btn--active {
color: var(--cc-action-primary);
}
/* ─── PANEL MENU / DROPDOWN ─────────────────────────────────── */
.cc-menu {
position: absolute;
background: var(--cc-bg-panel-alt);
border: var(--cc-border-width) solid var(--cc-border-strong);
border-radius: var(--cc-radius-md);
padding: var(--cc-space-1) 0;
min-width: 200px;
z-index: var(--cc-z-menu);
/* Hard-edged drop — no blur */
box-shadow: 4px 4px 0 0 var(--cc-navy-200);
}
[data-color-mode="dark"] .cc-menu {
box-shadow: 4px 4px 0 0 var(--cc-navy-950);
}
.cc-menu-item {
display: grid;
grid-template-columns: 20px 1fr;
align-items: start;
gap: var(--cc-space-2);
padding: var(--cc-space-2) var(--cc-space-3);
color: var(--cc-text-primary);
cursor: pointer;
border: none;
background: transparent;
width: 100%;
text-align: left;
font-family: var(--cc-font-sans);
font-size: var(--cc-text-sm);
transition: background var(--cc-transition-snap);
}
.cc-menu-item:hover {
background: var(--cc-hover);
}
.cc-menu-item--selected {
background: var(--cc-selected);
color: var(--cc-text-accent);
}
.cc-menu-item__icon {
color: var(--cc-text-secondary);
transition: color var(--cc-transition-snap);
}
.cc-menu-item:hover .cc-menu-item__icon,
.cc-menu-item--selected .cc-menu-item__icon {
color: var(--cc-action-primary);
}
.cc-menu-item__label {
font-weight: var(--cc-weight-medium);
line-height: var(--cc-leading-tight);
}
.cc-menu-item__desc {
font-size: var(--cc-text-xs);
color: var(--cc-text-secondary);
line-height: var(--cc-leading-normal);
margin-top: 1px;
}
.cc-menu-divider {
height: var(--cc-border-width);
background: var(--cc-border);
margin: var(--cc-space-1) 0;
}
/* ─── BUTTONS ────────────────────────────────────────────────── */
.cc-btn {
display: inline-grid;
place-items: center;
padding: var(--cc-space-1) var(--cc-space-3);
border: var(--cc-border-width) solid transparent;
border-radius: var(--cc-radius-sm);
font-family: var(--cc-font-sans);
font-size: var(--cc-text-sm);
font-weight: var(--cc-weight-medium);
letter-spacing: var(--cc-tracking-wide);
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
transition:
background var(--cc-transition-quick),
border-color var(--cc-transition-quick),
transform var(--cc-transition-snap),
box-shadow var(--cc-transition-snap);
}
.cc-btn:active {
transform: translate(1px, 1px);
}
.cc-btn--primary {
background: var(--cc-action-primary);
color: var(--cc-navy-900);
border-color: var(--cc-amber-600);
box-shadow: 2px 2px 0 0 var(--cc-amber-700);
}
.cc-btn--primary:hover {
background: var(--cc-action-primary-hover);
box-shadow: 3px 3px 0 0 var(--cc-amber-700);
}
.cc-btn--primary:active {
box-shadow: none;
background: var(--cc-action-primary-active);
}
.cc-btn--secondary {
background: transparent;
color: var(--cc-text-primary);
border-color: var(--cc-border-strong);
}
.cc-btn--secondary:hover {
background: var(--cc-hover);
border-color: var(--cc-navy-300);
}
.cc-btn--danger {
background: transparent;
color: var(--cc-action-danger);
border-color: var(--cc-border-error);
}
.cc-btn--danger:hover {
background: var(--cc-crimson-100);
}
.cc-btn--icon {
width: 28px;
height: 28px;
padding: 0;
border-radius: var(--cc-radius-sm);
border-color: transparent;
background: transparent;
color: var(--cc-text-secondary);
}
.cc-btn--icon:hover {
background: var(--cc-hover);
color: var(--cc-text-primary);
}
/* ─── INPUT / FORM ELEMENTS ─────────────────────────────────── */
.cc-input {
display: block;
width: 100%;
padding: var(--cc-space-1) var(--cc-space-2);
background: var(--cc-bg-panel-alt);
border: var(--cc-border-width) solid var(--cc-border-strong);
border-radius: var(--cc-radius-sm);
color: var(--cc-text-primary);
font-family: var(--cc-font-sans);
font-size: var(--cc-text-base);
line-height: var(--cc-leading-normal);
transition: border-color var(--cc-transition-quick);
outline: none;
}
.cc-input::placeholder {
color: var(--cc-text-tertiary);
}
.cc-input:focus {
border-color: var(--cc-border-focus);
box-shadow: 0 0 0 2px rgba(201,136,42,0.18);
}
.cc-input:disabled {
background: var(--cc-disabled);
color: var(--cc-text-tertiary);
cursor: not-allowed;
}
/* ─── RECORDING / LIVE INDICATOR ────────────────────────────── */
.cc-record-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 20px;
transition:
background var(--cc-transition-quick),
transform var(--cc-transition-snap),
box-shadow var(--cc-transition-quick);
}
.cc-record-btn--idle {
background: var(--cc-recording-idle);
color: #fff;
box-shadow: 2px 2px 0 0 var(--cc-signal-green-600);
}
.cc-record-btn--idle:hover {
background: var(--cc-signal-green-400);
box-shadow: 3px 3px 0 0 var(--cc-signal-green-600);
}
.cc-record-btn--active {
background: var(--cc-recording-active);
color: #fff;
box-shadow: 2px 2px 0 0 var(--cc-crimson-600);
/* Pulsing border — CSS-only, no JS */
animation: cc-record-pulse 1.6s ease-in-out infinite;
}
@keyframes cc-record-pulse {
0%,100% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 0 rgba(168,40,40,0.4); }
50% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 6px rgba(168,40,40,0); }
}
.cc-record-btn--active:active {
transform: scale(0.94);
}
/* Status text accompanying record button */
.cc-record-status {
font-family: var(--cc-font-mono);
font-size: var(--cc-text-sm);
font-weight: var(--cc-weight-medium);
letter-spacing: var(--cc-tracking-wide);
text-transform: uppercase;
}
.cc-record-status--idle { color: var(--cc-recording-idle); }
.cc-record-status--active { color: var(--cc-recording-active); }
/* ─── TRANSCRIPT SEGMENTS ───────────────────────────────────── */
.cc-transcript-segment {
padding: var(--cc-space-2);
border-left: 2px solid var(--cc-border);
margin-bottom: var(--cc-space-1);
transition: border-color var(--cc-transition-quick);
}
.cc-transcript-segment:hover {
border-color: var(--cc-border-accent);
}
.cc-transcript-segment--current {
border-color: var(--cc-action-primary);
background: var(--cc-selected);
}
.cc-transcript-timestamp {
font-family: var(--cc-font-mono);
font-size: var(--cc-text-xs);
color: var(--cc-text-tertiary);
letter-spacing: var(--cc-tracking-wide);
margin-bottom: var(--cc-space-0_5);
}
.cc-transcript-text {
font-size: var(--cc-text-base);
color: var(--cc-text-primary);
line-height: var(--cc-leading-normal);
}
/* ─── GRAPH NODE CARDS ──────────────────────────────────────── */
.cc-graph-node {
display: grid;
grid-template-rows: var(--cc-shape-header-h) 1fr;
border: var(--cc-border-width) solid var(--cc-border);
border-radius: var(--cc-radius-sm);
overflow: hidden;
background: var(--cc-bg-shape);
box-shadow: var(--cc-shadow-crisp);
transition: box-shadow var(--cc-transition-quick);
}
.cc-graph-node:hover {
box-shadow: var(--cc-shadow-raised);
}
/* Node type label pill */
.cc-node-type-tag {
display: inline-block;
padding: 1px var(--cc-space-1);
border: var(--cc-border-width) solid currentColor;
border-radius: var(--cc-radius-xs);
font-family: var(--cc-font-mono);
font-size: var(--cc-text-xs);
font-weight: var(--cc-weight-medium);
letter-spacing: var(--cc-tracking-widest);
text-transform: uppercase;
opacity: 0.75;
}
/* ─── SECTION / DIVIDER TYPOGRAPHY ─────────────────────────── */
.cc-section-label {
font-size: var(--cc-text-xs);
font-weight: var(--cc-weight-semibold);
letter-spacing: var(--cc-tracking-widest);
text-transform: uppercase;
color: var(--cc-text-tertiary);
padding-bottom: var(--cc-space-1);
border-bottom: var(--cc-border-width) solid var(--cc-border);
margin-bottom: var(--cc-space-2);
}
.cc-divider {
height: var(--cc-border-width);
background: var(--cc-border);
margin: var(--cc-space-2) 0;
}
/* ─── LOADING STATES ─────────────────────────────────────────── */
.cc-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--cc-border-strong);
border-top-color: var(--cc-action-primary);
border-radius: 50%;
animation: cc-spin 0.7s linear infinite;
}
@keyframes cc-spin {
to { transform: rotate(360deg); }
}
.cc-skeleton {
background: linear-gradient(
90deg,
var(--cc-border) 25%,
var(--cc-hover) 50%,
var(--cc-border) 75%
);
background-size: 200% 100%;
animation: cc-shimmer 1.4s ease-in-out infinite;
border-radius: var(--cc-radius-xs);
}
@keyframes cc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ─── TOASTS / ALERTS ────────────────────────────────────────── */
.cc-alert {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: var(--cc-space-2);
padding: var(--cc-space-2) var(--cc-space-3);
border: var(--cc-border-width) solid;
border-radius: var(--cc-radius-sm);
font-size: var(--cc-text-sm);
}
.cc-alert--error {
background: var(--cc-crimson-100);
border-color: var(--cc-border-error);
color: var(--cc-crimson-600);
}
.cc-alert--success {
background: var(--cc-signal-green-100);
border-color: var(--cc-border-success);
color: var(--cc-signal-green-600);
}
[data-color-mode="dark"] .cc-alert--error {
background: rgba(168,40,40,0.12);
color: var(--cc-crimson-400);
}
[data-color-mode="dark"] .cc-alert--success {
background: rgba(36,107,66,0.12);
color: var(--cc-signal-green-400);
}
/* ─── UTILITY CLASSES ────────────────────────────────────────── */
.cc-font-mono { font-family: var(--cc-font-mono); }
.cc-text-xs { font-size: var(--cc-text-xs); }
.cc-text-sm { font-size: var(--cc-text-sm); }
.cc-text-base { font-size: var(--cc-text-base); }
.cc-text-primary { color: var(--cc-text-primary); }
.cc-text-secondary { color: var(--cc-text-secondary); }
.cc-text-tertiary { color: var(--cc-text-tertiary); }
.cc-text-accent { color: var(--cc-text-accent); }
.cc-uppercase { text-transform: uppercase; letter-spacing: var(--cc-tracking-wide); }
.cc-border-accent { border-left: 2px solid var(--cc-border-accent); }
.cc-border-top { border-top: var(--cc-border-width) solid var(--cc-border); }
.cc-border-bottom { border-bottom: var(--cc-border-width) solid var(--cc-border); }
.cc-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* ─── GRID LAYOUT HELPERS ────────────────────────────────────── */
.cc-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--cc-space-2); }
.cc-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--cc-space-2); }
.cc-stack { display: grid; gap: var(--cc-space-2); }
.cc-stack-sm { display: grid; gap: var(--cc-space-1); }
/* ─── FOCUS VISIBLE ──────────────────────────────────────────── */
:focus-visible {
outline: 2px solid var(--cc-border-focus);
outline-offset: 2px;
border-radius: var(--cc-radius-xs);
}
+11 -1
View File
@@ -1,2 +1,12 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap");
@import url("@tldraw/tldraw/tldraw.css");
@import url('./cc-design-system.css');
.tl-container {
--color-panel: var(--cc-bg-panel);
--color-text: var(--cc-text-primary);
--color-muted: var(--cc-border);
--color-muted-1: var(--cc-border-strong);
--color-background: var(--cc-bg-canvas);
--color-selected: var(--cc-selected);
--color-accent: var(--cc-action-primary);
}
@@ -1,18 +1,15 @@
import React, { useState } from 'react';
import { BasePanel } from './shared/BasePanel';
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
import { BaseContext, ViewContext } from '../../../../types/navigation';
interface CCPanelProps {
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onPinnedChange?: (pinned: boolean) => void;
}
export const CCPanel: React.FC<CCPanelProps> = ({
examMarkerProps,
export const CCPanel: React.FC<CCPanelProps> = ({
isExpanded,
isPinned,
onExpandedChange,
@@ -31,8 +28,7 @@ export const CCPanel: React.FC<CCPanelProps> = ({
};
return (
<BasePanel
examMarkerProps={examMarkerProps}
<BasePanel
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={handleExpandedChange}
@@ -1,41 +1,14 @@
import React, { useEffect, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import React, { useEffect, useRef, useMemo, useState } from 'react';
import { TldrawUiButton } from '@tldraw/tldraw';
import {
Button,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
styled,
ThemeProvider,
createTheme,
useMediaQuery
} from '@mui/material';
import {
PushPin as PushPinIcon,
PushPinOutlined as PushPinOutlinedIcon,
ExpandMore as ExpandMoreIcon,
Category as ShapesIcon,
Slideshow as SlidesIcon,
YouTube as YouTubeIcon,
AccountTree as GraphIcon,
Search as SearchIcon,
Navigation as NavigationIcon,
Save as NodeIcon,
Assignment as ExamIcon,
Mic as MicIcon
} from '@mui/icons-material';
import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel';
import { CCFilesPanel } from './CCFilesPanel';
import { CCCabinetsPanel } from './CCCabinetsPanel';
import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
import { CCSearchPanel } from './CCSearchPanel'
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
import { CCSearchPanel } from './CCSearchPanel';
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
import './panel.css';
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
@@ -56,16 +29,12 @@ export const PANEL_TYPES = {
{ id: 'graph', label: 'Graph', order: 60 },
{ id: 'search', label: 'Search', order: 70 },
],
examMarker: [
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
],
} as const;
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
interface BasePanelProps {
initialPanelType?: PanelType;
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
@@ -78,49 +47,95 @@ interface BasePanelProps {
onMenuOpenChange?: (open: boolean) => void;
}
const PanelTypeButton = styled(Button)(() => ({
textTransform: 'none',
padding: '6px 12px',
gap: '8px',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
border: '1px solid transparent',
transition: 'border-color 200ms ease',
justifyContent: 'space-between',
minWidth: '200px',
'&:hover': {
backgroundColor: 'var(--color-panel)',
borderColor: 'var(--color-text)',
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
color: 'inherit',
function getIconSvg(panelId: PanelType, size = '1.25rem') {
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
switch (panelId) {
case 'cabinets':
return (
<svg {...common}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
case 'transcription':
return (
<svg {...common}>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="6" y2="21" />
<line x1="16" y1="23" x2="14" y2="21" />
</svg>
);
case 'cc-shapes':
return (
<svg {...common}>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
);
case 'slides':
return (
<svg {...common}>
<rect x="2" y="3" width="13" height="18" rx="2" />
<path d="M15 8h4l3 3v7h-7V8z" />
</svg>
);
case 'youtube':
return (
<svg {...common}>
<rect x="2" y="4" width="20" height="16" rx="4" />
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none" />
</svg>
);
case 'graph':
return (
<svg {...common}>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
);
case 'search':
return (
<svg {...common}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
case 'navigation':
return (
<svg {...common}>
<polygon points="3 11 22 2 13 21 11 13 3 11" />
</svg>
);
case 'node-snapshot':
return (
<svg {...common}>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
);
default:
return (
<svg {...common}>
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
);
}
}));
}
const StyledMenuItem = styled(MenuItem)(() => ({
gap: '8px',
padding: '8px 16px',
transition: 'background-color 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
'& .MuiListItemIcon-root': {
color: 'var(--color-selected)',
}
},
'& .MuiListItemIcon-root': {
color: 'var(--color-text)',
minWidth: '32px',
transition: 'color 200ms ease',
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
}
}
}));
function isDarkMode() {
if (typeof window === 'undefined') return false;
return document.documentElement.dataset.colorMode === 'dark' ||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'files',
examMarkerProps,
isExpanded: controlledIsExpanded,
isPinned: controlledIsPinned,
onExpandedChange,
@@ -128,47 +143,37 @@ export const BasePanel: React.FC<BasePanelProps> = ({
isMenuOpen = false,
onMenuOpenChange = () => {},
}) => {
const location = useLocation();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
return createTheme({
palette: {
mode,
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setPrefersDarkMode(isDarkMode());
mq.addEventListener?.('change', handler);
return () => mq.removeEventListener?.('change', handler);
}, []);
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
? 'dark'
: 'light';
const availablePanels = PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
const [internalIsPinned, setInternalIsPinned] = useState(true);
const isExamMarkerRoute = location.pathname === '/exam-marker';
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
isExamMarkerRoute ? 'exam-marker' : initialPanelType
);
// Use controlled state if provided, otherwise use internal state
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
const isPinned = controlledIsPinned ?? internalIsPinned;
const handleExpandedChange = (expanded: boolean) => {
setInternalIsExpanded(expanded);
onExpandedChange?.(expanded);
};
const handlePinToggle = () => {
const newPinned = !isPinned;
setInternalIsPinned(newPinned);
@@ -178,92 +183,25 @@ export const BasePanel: React.FC<BasePanelProps> = ({
const panelRef = useRef<HTMLDivElement>(null);
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Don't close if pinned
if (isPinned) return;
// Check if click is outside panel
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
// Check if click is not on a panel-related element
const target = event.target as HTMLElement;
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
if (isClickOutside && !isPanelElement) {
handleExpandedChange(false);
}
};
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isExpanded, isPinned]);
const getIconForPanel = (panelId: PanelType) => {
switch (panelId) {
case 'cabinets':
return <NavigationIcon />;
case 'cc-shapes':
return <ShapesIcon />;
case 'transcription':
return <MicIcon />;
case 'slides':
return <SlidesIcon />;
case 'youtube':
return <YouTubeIcon />;
case 'graph':
return <GraphIcon />;
case 'search':
return <SearchIcon />;
case 'navigation':
return <NavigationIcon />;
case 'node-snapshot':
return <NodeIcon />;
case 'exam-marker':
return <ExamIcon />;
default:
return <ShapesIcon />;
}
};
const getDescriptionForPanel = (panelId: PanelType) => {
switch (panelId) {
case 'cabinets':
return 'Manage file cabinets';
case 'transcription':
return 'Record and transcribe lessons';
case 'cc-shapes':
return 'Add shapes and elements to your canvas';
case 'slides':
return 'Manage presentation slides';
case 'youtube':
return 'Embed YouTube videos';
case 'graph':
return 'View and manage graph connections';
case 'search':
return 'Search through your content';
case 'navigation':
return 'Navigate through different contexts';
case 'node-snapshot':
return 'Manage node snapshots';
case 'exam-marker':
return 'Mark and grade exams';
default:
return '';
}
};
const renderCurrentPanel = () => {
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
}
switch (currentPanelType) {
case 'cabinets':
return <CCCabinetsPanel />;
@@ -288,22 +226,15 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}
};
// Handle menu button click
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
onMenuOpenChange(true);
};
// Handle menu close
const handleMenuClose = () => {
setMenuAnchorEl(null);
onMenuOpenChange(false);
const toggleMenu = (open: boolean) => {
setMenuOpen(open);
onMenuOpenChange(open);
};
return (
<>
{!isExpanded && (
<div
<div
className="panel-handle"
onClick={() => handleExpandedChange(true)}
onTouchEnd={(e) => {
@@ -316,7 +247,7 @@ export const BasePanel: React.FC<BasePanelProps> = ({
)}
{isExpanded && (
<div
<div
ref={panelRef}
className="panel-root"
style={{
@@ -327,55 +258,60 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}}
>
<div className="panel-header">
<ThemeProvider theme={theme}>
<PanelTypeButton
onClick={handleMenuClick}
endIcon={<ExpandMoreIcon />}
startIcon={getIconForPanel(currentPanelType)}
>
<button
type="button"
className="cc-btn cc-btn--secondary cc-panel-selector"
onClick={() => toggleMenu(!menuOpen)}
>
<span className="cc-btn-start-icon">
{getIconSvg(currentPanelType)}
</span>
<span className="cc-btn-label">
{availablePanels.find(p => p.id === currentPanelType)?.label}
</PanelTypeButton>
</span>
<span className="cc-btn-end-icon">
<svg width="1.25rem" height="1.25rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
<Menu
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
PaperProps={{
elevation: 8,
sx: {
border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)',
}
}}
>
{menuOpen && (
<div className="cc-menu panel-type-menu" role="menu">
{[...availablePanels]
.sort((a, b) => a.order - b.order)
.map(type => (
<StyledMenuItem
key={type.id}
onClick={() => {
setCurrentPanelType(type.id as PanelType);
handleMenuClose();
}}
selected={currentPanelType === type.id}
>
<ListItemIcon>
{getIconForPanel(type.id as PanelType)}
</ListItemIcon>
<ListItemText
primary={type.label}
secondary={getDescriptionForPanel(type.id as PanelType)}
primaryTypographyProps={{
sx: { color: 'var(--color-text)' }
<button
key={type.id}
type="button"
role="menuitem"
className={`cc-menu-item ${type.id === currentPanelType ? 'cc-menu-item--active' : ''}`}
onClick={() => {
setCurrentPanelType(type.id as PanelType);
toggleMenu(false);
}}
secondaryTypographyProps={{
sx: { color: 'var(--color-text-secondary)' }
}}
/>
</StyledMenuItem>
))}
</Menu>
</ThemeProvider>
>
<span className="cc-menu-item-icon">
{getIconSvg(type.id as PanelType)}
</span>
<span className="cc-menu-item-text">
<span className="cc-menu-item-primary">{type.label}</span>
<span className="cc-menu-item-secondary">
{type.id === 'cabinets' && 'Manage file cabinets'}
{type.id === 'transcription' && 'Record and transcribe lessons'}
{type.id === 'cc-shapes' && 'Add shapes and elements to your canvas'}
{type.id === 'slides' && 'Manage presentation slides'}
{type.id === 'youtube' && 'Embed YouTube videos'}
{type.id === 'graph' && 'View and manage graph connections'}
{type.id === 'search' && 'Search through your content'}
{type.id === 'navigation' && 'Navigate through different contexts'}
{type.id === 'node-snapshot' && 'Manage node snapshots'}
</span>
</span>
</button>
))}
</div>
)}
<div className="panel-header-actions">
<TldrawUiButton
@@ -383,18 +319,38 @@ export const BasePanel: React.FC<BasePanelProps> = ({
onClick={handlePinToggle}
className="pin-button"
>
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
<span className="cc-icon-button">
<svg
width="1.25rem"
height="1.25rem"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{isPinned ? (
<>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
</>
) : (
<>
<circle cx="12" cy="12" r="10" />
<path d="M7.5 7.5l9 9" />
</>
)}
</svg>
</span>
</TldrawUiButton>
</div>
</div>
<ThemeProvider theme={theme}>
<div className="panel-content">
{renderCurrentPanel()}
</div>
</ThemeProvider>
</div>
)}
</>
);
};
};
@@ -1,495 +0,0 @@
import React, { useState } from 'react';
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
import { PDFDocument } from 'pdf-lib';
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
import { logger } from '../../../../../debugConfig';
interface CCExamMarkerPanelProps {
editor: Editor | null;
currentView: 'exam-and-markscheme' | 'student-responses';
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
currentStudentIndex: number;
totalStudents: number;
onPreviousStudent: () => void;
onNextStudent: () => void;
getCurrentPdf: () => Pdf | null;
}
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
editor,
currentView,
onViewChange,
currentStudentIndex,
totalStudents,
onPreviousStudent,
onNextStudent,
getCurrentPdf,
}) => {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void,
startPage?: number,
endPage?: number,
studentIndex?: number
) => {
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
name,
startPage,
endPage,
studentIndex,
currentView,
totalPages: pages.length
});
const pdfPages = pages.slice(startPage, endPage);
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
pdfPages: pdfPages.length,
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
});
const totalThings = pdfPages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const sourcePdf = await PDFDocument.load(source);
tickProgress();
// Create a new PDF document for the selected pages
const newPdf = await PDFDocument.create();
// Copy pages from source PDF
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
copiedPages.forEach(page => newPdf.addPage(page));
tickProgress();
// Store current page to restore later
const currentPageId = editor.getCurrentPageId();
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
// Switch to the correct page based on context
const targetPageId = (studentIndex !== undefined
? `page:student-response-${studentIndex}`
: currentView === 'exam-and-markscheme'
? 'page:exam-page'
: 'page:mark-scheme-page') as TLPageId;
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
editor.setCurrentPage(targetPageId);
// Get all shape IDs that are not page shapes (i.e., annotations)
const pageShapeIds = new Set(pages.map(page => page.shapeId));
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
totalShapes: editor.getCurrentPageShapeIds().size,
pageShapes: pageShapeIds.size,
annotationShapes: allShapeIds.length
});
// For each page, draw annotations on top
for (let i = 0; i < pdfPages.length; i++) {
const page = pdfPages[i];
const pdfPage = newPdf.getPages()[i];
const {bounds} = page;
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
bounds,
pageIndex: i,
globalPageIndex: (startPage || 0) + i
});
// Get shapes that intersect with this page using editor's bounds checking
const shapesInBounds = allShapeIds.filter((id) => {
const shape = editor.getShape(id);
if (!shape || shape.isLocked) return false;
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
const annotationManager = editor.annotationManager;
const annotationData = annotationManager.getAnnotationData(id);
if (!annotationData) return false;
// Filter by student index if provided
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
return false;
}
// For exam/markscheme view, only include those annotations
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
return false;
}
// For individual student exports, use the annotation's original page index
// For full exports, use the stored page index
const adjustedPageIndex = annotationData.pageIndex;
// Check if this shape belongs to this page index
if (adjustedPageIndex !== i) {
return false;
}
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
shapeId: id,
annotationData,
adjustedPageIndex,
currentPageIndex: i,
bounds: editor.getShapePageBounds(id)
});
return true;
});
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
shapesInBounds: shapesInBounds.length,
pageIndex: i,
globalPageIndex: (startPage || 0) + i
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
// Export the annotations as PNG
const exportedPng = await exportToBlob({
editor,
ids: shapesInBounds,
format: 'png',
opts: {
background: false,
// Create a new bounds that's relative to the current page
bounds: new TLBox(
bounds.x,
0, // Reset to 0 since we want annotations relative to current page
bounds.width,
bounds.height
),
padding: 0,
scale: 1
},
});
tickProgress();
// Draw the annotations on the PDF page
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
const pdfWidth = pdfPage.getWidth();
const pdfHeight = pdfPage.getHeight();
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfWidth,
height: pdfHeight,
});
tickProgress();
}
// Restore original page
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
editor.setCurrentPage(currentPageId);
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(
new Blob([pdfBytes], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
};
const handleExportCurrentView = async () => {
if (!editor) return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
setExportProgress(0);
try {
if (currentView === 'student-responses') {
// For student responses, we need to handle each student's annotations separately
const pagesPerStudent = currentPdf.pages.length / totalStudents;
let currentProgress = 0;
// Create a new PDF with all pages
const sourcePdf = await PDFDocument.load(currentPdf.source);
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
copiedPages.forEach(page => newPdf.addPage(page));
// For each student, export their annotations onto their pages
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
// Switch to the student's page to get their annotations
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
editor.setCurrentPage(targetPageId);
// Get all annotations for this student
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
// Process each page for this student
for (let i = 0; i < pagesPerStudent; i++) {
const pageIndex = startPage + i;
const page = currentPdf.pages[pageIndex];
const pdfPage = newPdf.getPages()[pageIndex];
// Get shapes for this page
const shapesInBounds = allShapeIds.filter((id) => {
const shape = editor.getShape(id);
if (!shape || shape.isLocked) return false;
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
const annotationManager = editor.annotationManager;
const annotationData = annotationManager.getAnnotationData(id);
if (!annotationData) return false;
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
});
if (shapesInBounds.length > 0) {
// Export and draw annotations
const exportedPng = await exportToBlob({
editor,
ids: shapesInBounds,
format: 'png',
opts: {
background: false,
bounds: new TLBox(
page.bounds.x,
0,
page.bounds.width,
page.bounds.height
),
padding: 0,
scale: 1
},
});
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
}
currentProgress++;
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
}
}
// Save the combined PDF
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = currentPdf.name;
a.click();
URL.revokeObjectURL(url);
} else {
// For exam/mark scheme view, use the original export logic
await exportPdf(editor, currentPdf, setExportProgress);
}
} finally {
setExportProgress(null);
}
};
const handleExportCurrentStudent = async () => {
if (!editor || currentView !== 'student-responses') return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
const pagesPerStudent = currentPdf.pages.length / totalStudents;
const startPage = currentStudentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
setExportProgress(0);
try {
await exportPdf(
editor,
{
...currentPdf,
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
},
setExportProgress,
Math.floor(startPage),
Math.floor(endPage),
currentStudentIndex
);
} finally {
setExportProgress(null);
}
};
const handleBatchExport = async () => {
if (!editor || currentView !== 'student-responses') return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
setExportProgress(0);
try {
const pagesPerStudent = currentPdf.pages.length / totalStudents;
let currentProgress = 0;
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
await exportPdf(
editor,
{
...currentPdf,
name: `Student_${studentIndex + 1}_Response.pdf`,
},
setExportProgress,
Math.floor(startPage),
Math.floor(endPage),
studentIndex
);
currentProgress++;
setExportProgress(currentProgress / totalStudents);
}
} finally {
setExportProgress(null);
}
};
return (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Exam Marker
</Typography>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
View Mode
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
onClick={() => onViewChange('exam-and-markscheme')}
>
Exam & Mark Scheme
</Button>
<Button
fullWidth
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
onClick={() => onViewChange('student-responses')}
>
Student Responses
</Button>
</Box>
</Box>
{currentView === 'student-responses' && (
<>
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Student Navigation
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
variant="outlined"
onClick={onPreviousStudent}
disabled={currentStudentIndex === 0}
>
Previous Student
</Button>
<Typography variant="body2" align="center">
Student {currentStudentIndex + 1} of {totalStudents}
</Typography>
<Button
fullWidth
variant="outlined"
onClick={onNextStudent}
disabled={currentStudentIndex === totalStudents - 1}
>
Next Student
</Button>
</Box>
</Box>
</>
)}
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Actions
</Typography>
<Stack spacing={1}>
<Button
fullWidth
variant="contained"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleExportCurrentView}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
</Button>
{currentView === 'student-responses' && (
<>
<Button
fullWidth
variant="outlined"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleExportCurrentStudent}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export Current Student'}
</Button>
<Button
fullWidth
variant="outlined"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleBatchExport}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export All as Separate Files'}
</Button>
</>
)}
</Stack>
</Box>
<Box sx={{ mt: 'auto' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Statistics
</Typography>
<Typography variant="body2">
Total Pages: {getCurrentPdf()?.pages.length || 0}
</Typography>
</Box>
</Box>
);
};
@@ -346,9 +346,10 @@ export const CCFilesPanelEnhanced: React.FC = () => {
ref={dirInputRef}
type="file"
style={{ display: 'none' }}
webkitdirectory=""
multiple
multiple
onChange={handleFallbackDirectorySelect}
// @ts-ignore WebKit directory picker attribute for folder upload
webkitdirectory=""
/>
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
import React, { useState, useEffect, useCallback, createContext, useContext, useMemo } from 'react';
import {
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
ToggleButtonGroup, ToggleButton,
@@ -518,7 +518,7 @@ export function CCGraphNavPanel() {
const data = await res.json();
setTree(data.tree);
} catch (err) {
logger.error('graph-nav-panel', 'Failed to load graph tree', err);
logger.error('navigation-panel', 'Failed to load graph tree', err);
setError('Failed to load navigation tree');
} finally {
setLoading(false);
@@ -650,12 +650,12 @@ export function CCGraphNavPanel() {
}, []);
const handleCalendarWizardComplete = useCallback(() => {
logger.info('graph-nav-panel', 'School calendar setup complete');
logger.info('navigation-panel', 'School calendar setup complete');
refreshAll();
}, [refreshAll]);
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
logger.info('navigation-panel', 'Teacher timetable setup complete', { timetableId });
refreshAll();
}, [refreshAll]);
@@ -1,7 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
import Save from '@mui/icons-material/Save';
import Reset from '@mui/icons-material/RestartAlt';
import { RestartAlt } from '@mui/icons-material';
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
import { useNavigationStore } from '../../../../../../stores/navigationStore';
import { useAuth } from '../../../../../../contexts/AuthContext';
@@ -268,4 +268,66 @@
text-align: center;
color: var(--color-text-2);
padding: 16px;
}
/* CC Base Shape Styles */
.cc-shape-container {
overflow: hidden;
position: relative;
border-radius: 4px;
box-shadow: 0 2px 4px var(--color-muted-1);
}
.cc-shape-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: not-allowed;
pointer-events: all;
position: relative;
z-index: 1;
background: var(--shape-header-color, var(--color-muted));
padding: 8px;
height: 32px;
}
.cc-shape-header .shape-title {
color: var(--cc-text-inverse);
font-weight: bold;
}
.cc-shape-toolbar {
display: flex;
gap: 4px;
align-items: center;
}
.cc-shape-toolbar button {
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: white;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
pointer-events: all;
font-size: 16px;
width: 24px;
height: 24px;
z-index: 100;
user-select: none;
position: relative;
touch-action: none;
}
.cc-shape-content {
position: absolute;
top: 32px;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
padding: 8px;
}
+14
View File
@@ -117,6 +117,20 @@ export default defineConfig(({ mode }) => {
build: {
outDir: 'dist',
sourcemap: mode === 'development',
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-tldraw': ['@tldraw/tldraw', '@tldraw/state', '@tldraw/store'],
'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
'vendor-supabase': ['@supabase/supabase-js'],
'vendor-calendar': ['@fullcalendar/core', '@fullcalendar/daygrid', '@fullcalendar/interaction',
'@fullcalendar/list', '@fullcalendar/multimonth', '@fullcalendar/react',
'@fullcalendar/timegrid'],
'vendor-xyflow': ['@xyflow/react'],
},
},
},
},
resolve: {