2026-08-14 14:20:41 +02:00
import type { ComponentType } from 'react'
2026-08-18 15:37:46 +02:00
import { BookOpen , Building2 , CalendarClock , FileText , Image as ImageIcon , Pencil , UserRound } from 'lucide-react'
2026-08-17 09:24:53 +02:00
import type { CaseDocument , DocumentExhibit , Evidence , Exhibit , ExhibitRelation , ExhibitType , SourceFileType , TemporalFact } from './types'
2026-08-14 14:20:41 +02:00
2026-08-17 09:24:53 +02:00
export type WidgetCommand =
| { type : 'open-document' ; documentId : string }
| { type : 'edit-folder' ; folderId : string }
| { type : 'edit-event' ; eventId : string }
| { type : 'edit-party' ; partyId : string }
| { type : 'edit-document' ; documentId : string }
| { type : 'update-memory-cue' ; documentId : string ; cue : string }
export type ExhibitWidgetContext = {
exhibits : Exhibit []
relations : ExhibitRelation []
dispatch : ( command : WidgetCommand ) => void
2026-08-14 14:20:41 +02:00
}
2026-08-17 09:24:53 +02:00
export type ExhibitWidgetProps = { exhibit : Exhibit ; context : ExhibitWidgetContext }
export type WidgetCapabilities = { movable : boolean ; resizable : boolean ; connectable : boolean ; discardable : boolean ; dockable : boolean }
export type ConnectionPort = { id : string ; x : number ; y : number }
2026-08-14 14:20:41 +02:00
export type ExhibitWidgetDefinition = {
2026-08-17 09:24:53 +02:00
modelKind : 'exhibit'
visualType : ExhibitType
shell : 'card' | 'document'
defaultSize : { width : number ; height : number }
capabilities : WidgetCapabilities
heading : ( exhibit : Exhibit , context : ExhibitWidgetContext ) => string
connectionPorts : ( exhibit : Exhibit ) => ConnectionPort []
temporalFacts : ( exhibit : Exhibit ) => TemporalFact []
searchText : ( exhibit : Exhibit ) => string
2026-08-14 14:20:41 +02:00
Component : ComponentType < ExhibitWidgetProps >
}
2026-08-17 09:24:53 +02:00
const relationsFrom = ( context : ExhibitWidgetContext , exhibitId : string , type : ExhibitRelation [ 'type' ]) => context . relations
. filter ( relation => relation . type === type && relation . fromExhibitId === exhibitId )
. sort (( a , b ) => a . sortOrder - b . sortOrder )
function FolderWidget ({ exhibit , context } : ExhibitWidgetProps ) {
if ( exhibit . type !== 'folder' ) return null
2026-08-14 14:20:41 +02:00
return < div className = "card-content" >< h3 >{ exhibit . title }</ h3 >< p >{ exhibit . content }</ p >
2026-08-18 15:37:46 +02:00
< div className = "folder-actions" >< button onClick = {() => context . dispatch ({ type : 'edit-folder' , folderId :exhibit.id })}>< Pencil size = { 15 }/> EDIT </ button ></ div >
2026-08-14 14:20:41 +02:00
</ div >
}
2026-08-17 09:24:53 +02:00
function NoteWidget ({ exhibit , context } : ExhibitWidgetProps ) {
if ( exhibit . type !== 'note' ) return null
const source = relationsFrom ( context , exhibit . id , 'source' )[ 0 ]
2026-08-14 14:20:41 +02:00
return < div className = "card-content" >< h3 >{ exhibit . title }</ h3 >< p >{ exhibit . content }</ p >
2026-08-17 09:24:53 +02:00
{ source && < button onClick = {() => context . dispatch ({ type : 'open-document' , documentId :source.toExhibitId })}>< BookOpen size = { 13 }/> VIEW SOURCE </ button >}
2026-08-14 14:20:41 +02:00
</ div >
}
2026-08-17 09:24:53 +02:00
function EventWidget ({ exhibit , context } : ExhibitWidgetProps ) {
if ( exhibit . type !== 'event' ) return null
const supportCount = relationsFrom ( context , exhibit . id , 'supports' ). length
2026-08-14 14:31:52 +02:00
return < div className = "card-content" >< h3 >{ exhibit . title }</ h3 >< p >{ exhibit . content }</ p >
2026-08-14 19:57:22 +02:00
< time >{ exhibit . eventDate ? new Date ( exhibit . eventDate ). toLocaleString () : 'UNDATED' }</ time >
2026-08-17 09:24:53 +02:00
< div className = "event-actions" >< span >{ supportCount } SUPPORTING EXHIBIT { supportCount === 1 ? '' : 'S' }</ span >< button onClick = {() => context . dispatch ({ type : 'edit-event' , eventId :exhibit.id })}>< CalendarClock size = { 12 }/> EDIT EVENT </ button ></ div >
2026-08-14 14:31:52 +02:00
</ div >
}
2026-08-17 09:24:53 +02:00
function PartyWidget ({ exhibit , context } : ExhibitWidgetProps ) {
if ( exhibit . type !== 'party' ) return null
2026-08-14 14:44:58 +02:00
const person = exhibit . partyKind === 'person'
2026-08-17 09:24:53 +02:00
const evidenceCount = relationsFrom ( context , exhibit . id , 'concerns' ). length
return < div className = "card-content" >< div className = "party-identity" >{ person ? < UserRound size = { 28 }/> : < Building2 size = { 28 }/>}< div >< h3 >{ exhibit . title }</ h3 >< small >{ person ? 'PERSON' : ( exhibit . organizationKind || 'ORGANIZATION' ). replaceAll ( '_' , ' ' ). toUpperCase ()}</ small ></ div ></ div >
2026-08-14 14:44:58 +02:00
< p >{ exhibit . content || 'No dossier summary yet.' }</ p >
2026-08-17 09:24:53 +02:00
{ exhibit . aliases . length > 0 && < div className = "party-aliases" > AKA · { exhibit . aliases . join ( ' · ' )}</ div >}
< div className = "event-actions" >< span >{ evidenceCount } ASSOCIATED EXHIBITS </ span >< button onClick = {() => context . dispatch ({ type : 'edit-party' , partyId :exhibit.id })}>< Pencil size = { 12 }/> EDIT DOSSIER </ button ></ div >
2026-08-14 14:44:58 +02:00
</ div >
}
2026-08-17 09:24:53 +02:00
function DocumentWidget ({ exhibit } : ExhibitWidgetProps ) {
if ( exhibit . type !== 'document' ) return null
return < strong >{ exhibit . title }</ strong >
2026-08-14 14:20:41 +02:00
}
2026-08-17 09:24:53 +02:00
const standardPorts = ( exhibit : Exhibit ) => [{ id : 'centre' , x :exhibit.x + exhibit . width / 2 , y :exhibit.y + exhibit . height / 2 }]
const notePorts = ( exhibit : Exhibit ) => [{ id : 'knot' , x :exhibit.x + exhibit . width / 2 , y :exhibit.y + 12 }]
const standardCapabilities : WidgetCapabilities = { movable :true , resizable :false , connectable :true , discardable :true , dockable :false }
const searchable = ( exhibit : Exhibit ) => exhibit . type === 'document'
? [ exhibit . title ,... exhibit . body ,... Object . values ( exhibit . metadata )]. join ( '\n' ). toLocaleLowerCase ()
: [ exhibit . title , exhibit . content ]. join ( '\n' ). toLocaleLowerCase ()
export const exhibitWidgetRegistry : Record < ExhibitType , ExhibitWidgetDefinition > = {
folder : { modelKind : 'exhibit' , visualType : 'folder' , shell : 'card' , defaultSize : { width :260 , height :166 }, capabilities :standardCapabilities ,
heading : ( exhibit , context ) => `EVIDENCE FOLDER / ${ relationsFrom ( context , exhibit . id , 'contains' ). length } ` , connectionPorts :standardPorts , temporalFacts : () => [], searchText :searchable , Component :FolderWidget },
document : { modelKind : 'exhibit' , visualType : 'document' , shell : 'document' , defaultSize : { width :174 , height :145 }, capabilities :standardCapabilities ,
heading :exhibit => exhibit . type === 'document' ? documentWidget ( exhibit . fileType ). label . toUpperCase () : 'DOCUMENT' , connectionPorts :standardPorts ,
temporalFacts :exhibit => exhibit . type === 'document' ? [
...( exhibit . publishedAt ? [{ id : ` ${ exhibit . id } :published` , exhibitId :exhibit.id , kind : 'published' as const , start :exhibit.publishedAt , label :exhibit.title }] : []),
...( exhibit . capturedAt ? [{ id : ` ${ exhibit . id } :captured` , exhibitId :exhibit.id , kind : 'captured' as const , start :exhibit.capturedAt , label : ` ${ exhibit . title } captured` }] : []),
... exhibit . regions . flatMap ( region => region . date ? [{ id : ` ${ exhibit . id } :region: ${ region . id } ` , exhibitId :exhibit.id , kind : 'region_date' as const , start :region.date , label :region.label }] : []),
] : [], searchText :searchable , Component :DocumentWidget },
note : { modelKind : 'exhibit' , visualType : 'note' , shell : 'card' , defaultSize : { width :108 , height :154 }, capabilities :standardCapabilities ,
heading : () => 'INVESTIGATOR / NOTE' , connectionPorts :notePorts , temporalFacts : () => [], searchText :searchable , Component :NoteWidget },
event : { modelKind : 'exhibit' , visualType : 'event' , shell : 'card' , defaultSize : { width :270 , height :174 }, capabilities :standardCapabilities ,
heading : () => 'EVENT / THIS HAPPENED' , connectionPorts :standardPorts , temporalFacts :exhibit => exhibit . type === 'event' && exhibit . eventDate
? [{ id : ` ${ exhibit . id } :occurred` , exhibitId :exhibit.id , kind : 'occurred' , start :exhibit.eventDate , label :exhibit.content }] : [], searchText :searchable , Component :EventWidget },
party : { modelKind : 'exhibit' , visualType : 'party' , shell : 'card' , defaultSize : { width :280 , height :190 }, capabilities :standardCapabilities ,
heading :exhibit => exhibit . type === 'party' && exhibit . partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER' , connectionPorts :standardPorts , temporalFacts : () => [], searchText :searchable , Component :PartyWidget },
2026-08-14 14:20:41 +02:00
}
2026-08-17 09:24:53 +02:00
export function exhibitWidget ( type : ExhibitType ) { return exhibitWidgetRegistry [ type ] }
2026-08-14 14:20:41 +02:00
2026-08-14 18:30:31 +02:00
type DocumentWidgetProps = { document : CaseDocument ; source : string ; onMemoryCue ?: ( cue : string ) => void }
2026-08-17 09:24:53 +02:00
export type DocumentWidgetDefinition = { label :string ; Preview :ComponentType < DocumentWidgetProps >; Asset :ComponentType < DocumentWidgetProps > }
2026-08-14 14:20:41 +02:00
2026-08-17 09:24:53 +02:00
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 . fileType . toUpperCase ()}</ small ></ div > }
function TextPreview ({ document , onMemoryCue } : DocumentWidgetProps ) {
const excerpt = document . body . filter ( Boolean ). slice ( 0 , 2 ). join ( ' ' )
return < div className = "text-document-preview" >< p className = "text-source-excerpt" >{ excerpt || document . title }</ p >< textarea aria-label = { `Memory cue for ${ document . title } ` } maxLength = { 48 } placeholder = "WRITE A MEMORY CUE…" value = { document . metadata . memory_cue || '' } onPointerDown = { event => event . stopPropagation ()} onClick = { event => event . stopPropagation ()} onDoubleClick = { event => event . stopPropagation ()} onChange = { event => onMemoryCue ? .( event . target . value )}/></ div >
2026-08-14 14:20:41 +02:00
}
2026-08-17 09:24:53 +02:00
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 > }
2026-08-14 14:20:41 +02:00
2026-08-17 09:24:53 +02:00
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 :TextPreview , 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' ),
2026-08-14 14:20:41 +02:00
}
2026-08-17 09:24:53 +02:00
export function documentWidget ( type : SourceFileType ) { return documentWidgetRegistry [ type ] || documentWidgetRegistry . file }
2026-08-14 14:20:41 +02:00
2026-08-17 09:24:53 +02:00
export function documentExhibits ( exhibits :Exhibit []) : DocumentExhibit [] { return exhibits . filter (( exhibit ) : exhibit is DocumentExhibit => exhibit . type === 'document' ) }
export function evidenceExhibits ( exhibits :Exhibit []) : Evidence [] { return exhibits . filter (( exhibit ) : exhibit is Evidence => exhibit . type !== 'document' ) }