From edfa4c866c84f14c0d2291116f252fedd5531f24 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 14:20:41 +0200 Subject: [PATCH] refactor: add typed frontend exhibit registry --- README.md | 2 +- docs/TODO.md | 2 +- docs/exhibit-data-model.md | 4 +- src/App.tsx | 32 ++++++------- src/exhibitRegistry.test.ts | 22 +++++++++ src/exhibitRegistry.tsx | 90 +++++++++++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 src/exhibitRegistry.test.ts create mode 100644 src/exhibitRegistry.tsx diff --git a/README.md b/README.md index ce84b98..8fdc49b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Folder ownership is stored as a normalized membership. The contained document ex An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement. -The planned frontend widget registry will map an exhibit type, and optionally a document type, to its React visualization. Widgets do not own investigation-domain data. +The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data. ## Deploy at osint.glitch.university diff --git a/docs/TODO.md b/docs/TODO.md index a1a8b13..7257a2a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -32,7 +32,7 @@ This is the ordered implementation roadmap following the accepted exhibit model. - [x] Cut over the explicitly disposable POC database directly; no transitional data existed to backfill or compare. - [x] Implement template instantiation, version selection, reset, and “save level as template” as transactional clone operations. - [x] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers. -- [ ] Move the frontend to an exhibit/widget registry backed by the normalized API. +- [x] Move exhibit and document rendering to a typed frontend widget registry backed by the normalized API. - [x] Remove transitional `widgets`, `widget_relations`, and `playthrough_*` tables in the canonical cutover migration. ## Milestone 3: events and parties diff --git a/docs/exhibit-data-model.md b/docs/exhibit-data-model.md index 74986be..d92d992 100644 --- a/docs/exhibit-data-model.md +++ b/docs/exhibit-data-model.md @@ -1,6 +1,6 @@ # GUPI OSINT Board: canonical exhibit data model -Status: accepted design foundation; core schema implemented by migration 006. Template clone operations, parties, and the frontend exhibit registry remain roadmap work. +Status: accepted design foundation; the core schema, transactional template lifecycle, and frontend exhibit registry are implemented. Parties and richer event behavior remain roadmap work. ## Vocabulary @@ -319,4 +319,4 @@ The following are computed and must not become duplicate source-of-truth tables: ## Implemented cutover -Migration 006 made this the sole persistence model. Because the POC database contained no canonical or legacy content worth preserving, the cutover intentionally dropped the JSON case store, `widgets`, `widget_relations`, and `playthrough_*` tables without a backfill period. Template save, version selection, instantiation, and reset now use one transactional board-cloning service. The remaining architectural work is the frontend exhibit/widget registry and the planned exhibit families. +Migration 006 made this the sole persistence model. Because the POC database contained no canonical or legacy content worth preserving, the cutover intentionally dropped the JSON case store, `widgets`, `widget_relations`, and `playthrough_*` tables without a backfill period. Template save, version selection, instantiation, and reset use one transactional board-cloning service. Exhibit and document types resolve through the typed frontend registry; the remaining model work is the planned exhibit families and richer behavior. diff --git a/src/App.tsx b/src/App.tsx index 7cd5947..8de386c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,18 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react' +import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react' import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types' import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain' +import { documentWidget, exhibitWidget } from './exhibitRegistry' const BOARD_W = 2400 const BOARD_H = 1500 const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [ - { value: 'image', label: 'Image' }, { value: 'pdf', label: 'PDF' }, { value: 'web_capture', label: 'Web capture' }, - { value: 'email', label: 'Email' }, { value: 'article', label: 'Article' }, { value: 'filing', label: 'Company filing' }, - { value: 'price_list', label: 'Price list' }, { value: 'text', label: 'Text document' }, { value: 'file', label: 'Generic file' }, -] + 'image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file', +].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label })) function uid(_prefix: string) { return crypto.randomUUID() } function connectionPoint(item: Evidence) { - return item.type === 'note' ? { x: item.x + 54, y: item.y + 12 } : { x: item.x + item.width / 2, y: item.y + 68 } + return exhibitWidget(item.type).connectionPoint(item) } type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string } @@ -307,22 +306,18 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} - {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); return
{ const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag() }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}> -
{ev.type === 'note' ? 'INVESTIGATOR / NOTE' : ev.type === 'folder' ? `EVIDENCE FOLDER / ${containedDocuments.length}` : ev.type.toUpperCase()}{String(i + 1).padStart(3, '0')}
-

{ev.title}

{ev.content}

- {ev.eventDate && } - {ev.type === 'folder' && !folderIsOpen(ev) &&
{containedDocuments.slice(0, 3).map(document => )}{containedDocuments.length > 3 && + {containedDocuments.length - 3} MORE FILES}
} - {ev.type === 'folder' &&
} - {ev.type !== 'folder' && ev.sourceDocumentId && }
+
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
+
})} - {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; return
{ const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }} onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag() }} onDoubleClick={() => open && onOpenSource(document.id)}> -
{document.fileType.replaceAll('_', ' ').toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
-
{document.fileType === 'image' && document.assetId ? :
{document.kind}
}
+
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
+
{document.title}
})} @@ -454,9 +449,8 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum function DocumentAsset({ doc }: { doc: CaseDocument }) { const source = `/api/assets/${encodeURIComponent(doc.assetId!)}` - if (doc.mimeType?.startsWith('image/')) return {doc.fileName - if (doc.mimeType === 'application/pdf' || doc.mimeType?.startsWith('text/')) return