Adding working board
This commit is contained in:
+97
-80
@@ -1,118 +1,135 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
|
||||
import { folderIsOpen } from './boardDomain'
|
||||
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||
|
||||
export type ExhibitWidgetProps = {
|
||||
exhibit: Evidence
|
||||
documents: CaseDocument[]
|
||||
onOpenSource: (id: string) => void
|
||||
onToggleFolder: (id: string) => void
|
||||
onEditFolder: (id: string) => void
|
||||
onEditEvent: (id: string) => void
|
||||
onEditParty: (id: string) => void
|
||||
export type WidgetCommand =
|
||||
| { type: 'open-document'; documentId: string }
|
||||
| { type: 'toggle-folder'; folderId: 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
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
export type ExhibitWidgetDefinition = {
|
||||
visualType: Exclude<EvidenceType, 'evidence'>
|
||||
heading: (exhibit: Evidence, documents: CaseDocument[]) => string
|
||||
connectionPoint: (exhibit: Evidence) => { x: number; y: number }
|
||||
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
|
||||
Component: ComponentType<ExhibitWidgetProps>
|
||||
}
|
||||
|
||||
function FolderWidget({ exhibit, documents, onOpenSource, onToggleFolder, onEditFolder }: ExhibitWidgetProps) {
|
||||
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
|
||||
const documents = relationsFrom(context, exhibit.id, 'contains').flatMap(relation => {
|
||||
const document = context.exhibits.find(candidate => candidate.id === relation.toExhibitId)
|
||||
return document?.type === 'document' ? [document] : []
|
||||
})
|
||||
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>
|
||||
{!exhibit.isOpen && <div className="folder-documents">{documents.slice(0,3).map(document => <button key={document.id} onClick={() => context.dispatch({ type:'open-document',documentId:document.id })} title={document.title}><FileText size={12}/><span>{document.title}</span>{document.publishedAt && <time>{document.publishedAt.slice(0,10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
|
||||
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'toggle-folder',folderId:exhibit.id })}>{exhibit.isOpen ? <Folder size={12}/> : <FolderOpen size={12}/>} {exhibit.isOpen ? 'CLOSE' : 'OPEN'}</button><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={12}/> EDIT</button></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
|
||||
function NoteWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||
if (exhibit.type !== 'note') return null
|
||||
const source = relationsFrom(context, exhibit.id, 'source')[0]
|
||||
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>}
|
||||
{source && <button onClick={() => context.dispatch({ type:'open-document',documentId:source.toExhibitId })}><BookOpen size={13}/> VIEW SOURCE</button>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
|
||||
const supportCount = exhibit.supportingEvidenceIds?.length || 0
|
||||
function EventWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||
if (exhibit.type !== 'event') return null
|
||||
const supportCount = relationsFrom(context,exhibit.id,'supports').length
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
|
||||
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
||||
function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) {
|
||||
function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||
if (exhibit.type !== 'party') return null
|
||||
const person = exhibit.partyKind === 'person'
|
||||
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>
|
||||
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>
|
||||
<p>{exhibit.content || 'No dossier summary yet.'}</p>
|
||||
{(exhibit.aliases?.length || 0) > 0 && <div className="party-aliases">AKA · {exhibit.aliases!.join(' · ')}</div>}
|
||||
<div className="event-actions"><span>{exhibit.relatedEvidenceIds?.length || 0} ASSOCIATED EXHIBITS</span><button onClick={() => onEditParty(exhibit.id)}><Pencil size={12}/> EDIT DOSSIER</button></div>
|
||||
{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>
|
||||
</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,
|
||||
function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
|
||||
if (exhibit.type !== 'document') return null
|
||||
return <strong>{exhibit.title}</strong>
|
||||
}
|
||||
|
||||
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: EventWidget },
|
||||
party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget },
|
||||
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 },
|
||||
}
|
||||
|
||||
export function exhibitWidget(type: EvidenceType) {
|
||||
return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note
|
||||
}
|
||||
export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
|
||||
|
||||
type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
|
||||
export type DocumentWidgetDefinition = {
|
||||
label: string
|
||||
Preview: ComponentType<DocumentWidgetProps>
|
||||
Asset: ComponentType<DocumentWidgetProps>
|
||||
}
|
||||
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 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>
|
||||
}
|
||||
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>
|
||||
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>
|
||||
}
|
||||
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: 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'),
|
||||
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'),
|
||||
}
|
||||
export function documentWidget(type:SourceFileType) { return documentWidgetRegistry[type] || documentWidgetRegistry.file }
|
||||
|
||||
export function documentWidget(type: SourceFileType) {
|
||||
return documentWidgetRegistry[type] || documentWidgetRegistry.file
|
||||
}
|
||||
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') }
|
||||
|
||||
Reference in New Issue
Block a user