refactor: add typed frontend exhibit registry

This commit is contained in:
2026-08-14 14:20:41 +02:00
parent c8a870549d
commit edfa4c866c
6 changed files with 129 additions and 23 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+13 -19
View File
@@ -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,
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{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 <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
</svg>
{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 <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${ev.type} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
{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] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
onPointerDown={e => { 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) }}>
<header><span>{ev.type === 'note' ? 'INVESTIGATOR / NOTE' : ev.type === 'folder' ? `EVIDENCE FOLDER / ${containedDocuments.length}` : ev.type.toUpperCase()}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<div className="card-content"><h3>{ev.title}</h3><p>{ev.content}</p>
{ev.eventDate && <time>{ev.eventDate.replaceAll('-', ' / ')}</time>}
{ev.type === 'folder' && !folderIsOpen(ev) && <div className="folder-documents">{containedDocuments.slice(0, 3).map(document => <button key={document.id} onClick={() => onOpenSource(document.id)} title={document.title}><FileText size={12}/><span>{document.title}</span>{(document.publishedAt || document.date) && <time>{(document.publishedAt || document.date).slice(0, 10)}</time>}</button>)}{containedDocuments.length > 3 && <small>+ {containedDocuments.length - 3} MORE FILES</small>}</div>}
{ev.type === 'folder' && <div className="folder-actions"><button onClick={() => toggleFolder(ev.id)}>{folderIsOpen(ev) ? <Folder size={12}/> : <FolderOpen size={12}/>} {folderIsOpen(ev) ? 'CLOSE' : 'OPEN'}</button><button onClick={() => onEditFolder(ev.id)}><Pencil size={12}/> EDIT</button></div>}
{ev.type !== 'folder' && ev.sourceDocumentId && <button onClick={() => onOpenSource(ev.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}</div>
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder}/>
</article>})}
{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 <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
{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; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
onPointerDown={event => { 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)}>
<header><span>{document.fileType.replaceAll('_', ' ').toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview">{document.fileType === 'image' && document.assetId ? <img draggable={false} src={`/api/assets/${encodeURIComponent(document.assetId)}`} alt=""/> : <div><ImageIcon size={35}/><small>{document.kind}</small></div>}</div>
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source}/></div>
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
<div className="source-file-actions"><button onClick={() => onOpenSource(document.id)}><BookOpen size={12}/> OPEN</button><button onClick={() => onEditFile(document.id)}><Pencil size={12}/> METADATA</button></div>
</article> })}
@@ -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 <img className="document-image" src={source} alt={doc.fileName || doc.title}/>
if (doc.mimeType === 'application/pdf' || doc.mimeType?.startsWith('text/')) return <iframe className="document-frame" src={source} title={doc.fileName || doc.title} sandbox="allow-same-origin"/>
return <div className="unsupported-file"><FileText size={42}/><b>{doc.fileName || doc.title}</b><span>{doc.mimeType || 'Unknown file type'} · {doc.fileSize ? `${Math.ceil(doc.fileSize / 1024)} KB` : ''}</span><a href={source} download={doc.fileName}>DOWNLOAD ORIGINAL</a></div>
const Asset = documentWidget(doc.fileType).Asset
return <Asset document={doc} source={source}/>
}
function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select a card, choose Connect, then select its target.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import type { EvidenceType, SourceFileType } from './types'
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
describe('frontend exhibit registry', () => {
it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => {
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event']
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort())
expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder'))
expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED')
})
it('registers every normalized document type with an explicit renderer', () => {
const types: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
expect(Object.keys(documentWidgetRegistry).sort()).toEqual(types.sort())
for (const type of types) {
expect(documentWidget(type).label).toBeTruthy()
expect(documentWidget(type).Preview).toBeTypeOf('function')
expect(documentWidget(type).Asset).toBeTypeOf('function')
}
})
})
+90
View File
@@ -0,0 +1,90 @@
import type { ComponentType } from 'react'
import { BookOpen, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil } from 'lucide-react'
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
import { folderIsOpen } from './boardDomain'
export type ExhibitWidgetProps = {
exhibit: Evidence
documents: CaseDocument[]
onOpenSource: (id: string) => void
onToggleFolder: (id: string) => void
onEditFolder: (id: string) => void
}
export type ExhibitWidgetDefinition = {
visualType: Exclude<EvidenceType, 'evidence'>
heading: (exhibit: Evidence, documents: CaseDocument[]) => string
connectionPoint: (exhibit: Evidence) => { x: number; y: number }
Component: ComponentType<ExhibitWidgetProps>
}
function FolderWidget({ exhibit, documents, onOpenSource, onToggleFolder, onEditFolder }: ExhibitWidgetProps) {
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{!folderIsOpen(exhibit) && <div className="folder-documents">{documents.slice(0, 3).map(document => <button key={document.id} onClick={() => onOpenSource(document.id)} title={document.title}><FileText size={12}/><span>{document.title}</span>{(document.publishedAt || document.date) && <time>{(document.publishedAt || document.date).slice(0, 10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
<div className="folder-actions"><button onClick={() => onToggleFolder(exhibit.id)}>{folderIsOpen(exhibit) ? <Folder size={12}/> : <FolderOpen size={12}/>} {folderIsOpen(exhibit) ? 'CLOSE' : 'OPEN'}</button><button onClick={() => onEditFolder(exhibit.id)}><Pencil size={12}/> EDIT</button></div>
</div>
}
function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{exhibit.eventDate && <time>{exhibit.eventDate.replaceAll('-', ' / ')}</time>}
{exhibit.sourceDocumentId && <button onClick={() => onOpenSource(exhibit.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}
</div>
}
const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 })
const folderDefinition: ExhibitWidgetDefinition = {
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`,
connectionPoint: standardPoint, Component: FolderWidget,
}
export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition> = {
folder: folderDefinition,
evidence: folderDefinition,
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget },
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: StandardWidget },
}
export function exhibitWidget(type: EvidenceType) {
return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note
}
type DocumentWidgetProps = { document: CaseDocument; source: string }
export type DocumentWidgetDefinition = {
label: string
Preview: ComponentType<DocumentWidgetProps>
Asset: ComponentType<DocumentWidgetProps>
}
function ImagePreview({ document, source }: DocumentWidgetProps) {
return document.assetId ? <img draggable={false} src={source} alt=""/> : <GenericPreview document={document} source={source}/>
}
function GenericPreview({ document }: DocumentWidgetProps) {
return <div><ImageIcon size={35}/><small>{document.kind}</small></div>
}
function ImageAsset({ document, source }: DocumentWidgetProps) {
return <img className="document-image" src={source} alt={document.fileName || document.title}/>
}
function FrameAsset({ document, source }: DocumentWidgetProps) {
return <iframe className="document-frame" src={source} title={document.fileName || document.title} sandbox="allow-same-origin"/>
}
function GenericAsset({ document, source }: DocumentWidgetProps) {
return <div className="unsupported-file"><FileText size={42}/><b>{document.fileName || document.title}</b><span>{document.mimeType || 'Unknown file type'} · {document.fileSize ? `${Math.ceil(document.fileSize / 1024)} KB` : ''}</span><a href={source} download={document.fileName}>DOWNLOAD ORIGINAL</a></div>
}
const genericDocument = (label: string): DocumentWidgetDefinition => ({ label, Preview: GenericPreview, Asset: GenericAsset })
export const documentWidgetRegistry: Record<SourceFileType, DocumentWidgetDefinition> = {
image: { label: 'Image', Preview: ImagePreview, Asset: ImageAsset },
pdf: { label: 'PDF', Preview: GenericPreview, Asset: FrameAsset },
text: { label: 'Text document', Preview: GenericPreview, Asset: FrameAsset },
web_capture: genericDocument('Web capture'),
email: genericDocument('Email'),
article: genericDocument('Article'),
filing: genericDocument('Company filing'),
price_list: genericDocument('Price list'),
file: genericDocument('Generic file'),
}
export function documentWidget(type: SourceFileType) {
return documentWidgetRegistry[type] || documentWidgetRegistry.file
}