refactor: add typed frontend exhibit registry
This commit is contained in:
+13
-19
@@ -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> }
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user