feat: add event narrative and evidence workflow
This commit is contained in:
@@ -93,7 +93,7 @@ The current POC defines four exhibit families and corresponding frontend widgets
|
||||
- **Folder** — a titled, annotated collection. Opening it expands its contained source files to their saved relation-bound board positions; closing it retracts and hides them.
|
||||
- **Source file** — immutable binary evidence plus user-editable title, publication time, file type, and arbitrary key/value metadata. Image is the first fully visual file renderer. PDF, web capture, email, article, filing, price list, text, and generic file are registered types with a generic fallback until their renderers are built.
|
||||
- **Note** — investigator-authored interpretation, visually distinct from source evidence.
|
||||
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to its supporting evidence. Chronologically ordered events become the emerging story.
|
||||
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to supporting exhibits. The editor manages citations, dashed support lines keep them visually distinct from red investigative thread, and chronologically ordered events form the reconstructed-story strip.
|
||||
|
||||
The planned **Party** family adds distinct Person and Organization exhibits (businesses are organizations). Their dossier-like widgets reveal normalized evidence associations, but parties remain identity objects rather than special folders.
|
||||
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
||||
|
||||
### Events and narrative
|
||||
|
||||
- [ ] Implement Event exhibits with occurrence time and investigator-authored narrative text.
|
||||
- [ ] Implement normalized Event-to-Evidence links and their board visualization.
|
||||
- [ ] Present chronologically ordered events as the emerging investigation story.
|
||||
- [x] Implement Event exhibits with occurrence time and investigator-authored narrative text.
|
||||
- [x] Implement normalized Event-to-Evidence links and their distinct board visualization.
|
||||
- [x] Present chronologically ordered events as the emerging investigation story.
|
||||
|
||||
### Party exhibits
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GUPI OSINT Board: canonical exhibit data model
|
||||
|
||||
Status: accepted design foundation; the core schema, transactional template lifecycle, and frontend exhibit registry are implemented. Parties and richer event behavior remain roadmap work.
|
||||
Status: accepted design foundation; the core schema, transactional template lifecycle, frontend exhibit registry, and Event workflow are implemented. Parties remain the next model expansion.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
|
||||
@@ -87,6 +87,17 @@ test('move, folder expansion, hand pan, pinch zoom, and reload persistence', asy
|
||||
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch)
|
||||
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch)
|
||||
|
||||
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
|
||||
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
|
||||
await page.getByLabel('Event title').fill('The browser clue was connected')
|
||||
await page.getByLabel('Event narrative').fill('The investigator connected the folder to a dated source.')
|
||||
await page.locator('.event-support-list input[type="checkbox"]').first().check()
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
|
||||
await page.reload()
|
||||
await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected')
|
||||
await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder')
|
||||
await expect(page.locator('.event-support-lines line')).toHaveCount(1)
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('Browser Template'))
|
||||
const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok())
|
||||
await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click()
|
||||
|
||||
@@ -71,10 +71,12 @@ suite('level persistence API', () => {
|
||||
const documentId = randomUUID()
|
||||
const folderId = randomUUID()
|
||||
const noteId = randomUUID()
|
||||
const eventId = randomUUID()
|
||||
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
|
||||
state.evidence = [
|
||||
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
|
||||
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
|
||||
{ id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 },
|
||||
]
|
||||
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
||||
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: noteId }]
|
||||
@@ -134,15 +136,17 @@ suite('level persistence API', () => {
|
||||
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
||||
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
|
||||
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string }>(`SELECT
|
||||
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string }>(`SELECT
|
||||
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
|
||||
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
|
||||
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
|
||||
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections`)
|
||||
expect(normalized.rows[0]).toEqual({ exhibits: '4', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1' })
|
||||
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
|
||||
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
|
||||
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence`)
|
||||
expect(normalized.rows[0]).toEqual({ exhibits: '5', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2' })
|
||||
|
||||
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
|
||||
expect(resetResponse.ok).toBe(true)
|
||||
@@ -177,6 +181,11 @@ suite('level persistence API', () => {
|
||||
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
|
||||
expect(clone.connections).toHaveLength(1)
|
||||
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
|
||||
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
|
||||
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
|
||||
expect(clonedEvent.supportingEvidenceIds).toHaveLength(2)
|
||||
expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId)
|
||||
expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId)
|
||||
|
||||
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
|
||||
clonedFolder.x = 1234
|
||||
|
||||
@@ -62,7 +62,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
async function assembleLevel(slug: string): Promise<CaseState | null> {
|
||||
const level = await findLevel(pool, slug)
|
||||
if (!level) return null
|
||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult] = await Promise.all([
|
||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult] = await Promise.all([
|
||||
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
|
||||
COALESCE(f.title, d.title, n.title, ev.title, '') AS title,
|
||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content,
|
||||
@@ -93,6 +93,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
|
||||
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
|
||||
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
|
||||
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
|
||||
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
|
||||
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
|
||||
])
|
||||
|
||||
const blocks = new Map<string, string[]>()
|
||||
@@ -104,6 +107,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
const metadata = new Map<string, Record<string, string>>()
|
||||
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
|
||||
const contained = new Map<string, string[]>()
|
||||
const eventEvidence = new Map<string, string[]>()
|
||||
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
|
||||
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
|
||||
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
|
||||
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
|
||||
@@ -121,6 +126,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
|
||||
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
|
||||
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
|
||||
supportingEvidenceIds: eventEvidence.get(row.id) || [],
|
||||
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
|
||||
}))
|
||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
|
||||
@@ -208,6 +214,14 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
|
||||
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
|
||||
}
|
||||
for (const event of state.evidence.filter(item => item.type === 'event')) {
|
||||
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) {
|
||||
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit')
|
||||
await client.query(`INSERT INTO osint.event_evidence
|
||||
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[level.board_id, event.id, evidenceId, sortOrder])
|
||||
}
|
||||
}
|
||||
for (const connection of state.connections) {
|
||||
requireUuid(connection.id, 'Connection id')
|
||||
if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')
|
||||
|
||||
+72
-3
@@ -32,6 +32,7 @@ export function App() {
|
||||
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
|
||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
||||
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -107,6 +108,16 @@ export function App() {
|
||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
|
||||
}
|
||||
|
||||
const addEvent = () => {
|
||||
if (!caseState) return
|
||||
const { viewport } = caseState
|
||||
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
||||
eventDate: new Date().toISOString(), supportingEvidenceIds: [], x: Math.max(100, (620 - viewport.x) / viewport.zoom),
|
||||
y: Math.max(100, (290 - viewport.y) / viewport.zoom), width: 270 }
|
||||
update(state => ({ ...state, evidence: [...state.evidence, event] }))
|
||||
setSelected(event.id); setEditingEventId(event.id)
|
||||
}
|
||||
|
||||
const handleCardClick = (id: string) => {
|
||||
if (!linkFrom) { setSelected(current => current === id ? null : id); return }
|
||||
if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) {
|
||||
@@ -193,6 +204,7 @@ export function App() {
|
||||
...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })),
|
||||
...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })),
|
||||
].sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
||||
const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!))
|
||||
return <main className="desktop">
|
||||
<header className="menubar">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
@@ -218,13 +230,15 @@ export function App() {
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} />
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} />
|
||||
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate!.slice(0, 10)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
|
||||
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
|
||||
<div className="board-actions">
|
||||
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
||||
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
||||
<span />
|
||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
|
||||
<span />
|
||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||
@@ -241,6 +255,14 @@ export function App() {
|
||||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
|
||||
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
|
||||
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
|
||||
{editingEventId && <EventEditor
|
||||
key={editingEventId}
|
||||
event={caseState.evidence.find(item => item.id === editingEventId)!}
|
||||
evidence={caseState.evidence}
|
||||
documents={caseState.documents}
|
||||
onClose={() => setEditingEventId(null)}
|
||||
onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
|
||||
/>}
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||
</main>
|
||||
}
|
||||
@@ -260,7 +282,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||
}
|
||||
|
||||
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void }) {
|
||||
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const suppressClick = useRef(false)
|
||||
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
||||
@@ -303,6 +325,20 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
||||
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
||||
{state.connections.map(c => { const a = byId.get(c.fromEvidenceId), b = byId.get(c.toEvidenceId); if (!a || !b) return null; const p1 = connectionPoint(a), p2 = connectionPoint(b); return <g key={c.id}><path d={`M ${p1.x} ${p1.y} C ${(p1.x+p2.x)/2} ${p1.y}, ${(p1.x+p2.x)/2} ${p2.y}, ${p2.x} ${p2.y}`}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
|
||||
</svg>
|
||||
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
||||
const evidence = byId.get(evidenceId)
|
||||
let target = evidence ? connectionPoint(evidence) : undefined
|
||||
if (!target) {
|
||||
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
|
||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
||||
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
|
||||
}
|
||||
if (!target) return []
|
||||
const origin = connectionPoint(event)
|
||||
return [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
|
||||
}))}
|
||||
</svg>
|
||||
<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>
|
||||
@@ -311,7 +347,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
||||
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>{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}/>
|
||||
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent}/>
|
||||
</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; 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 }) }}
|
||||
@@ -403,6 +439,39 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function EventEditor({ event, evidence, documents, onClose, onSave }: { event: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (event: Evidence) => void }) {
|
||||
const [title, setTitle] = useState(event.title)
|
||||
const [narrative, setNarrative] = useState(event.content)
|
||||
const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
|
||||
const [supports, setSupports] = useState(event.supportingEvidenceIds || [])
|
||||
const candidates = [
|
||||
...evidence.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })),
|
||||
...documents.map(document => ({ id: document.id, title: document.title, kind: document.fileType.replaceAll('_', ' ').toUpperCase() })),
|
||||
]
|
||||
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
||||
const submit = (submitEvent: React.FormEvent) => {
|
||||
submitEvent.preventDefault()
|
||||
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : new Date().toISOString()
|
||||
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports })
|
||||
}
|
||||
return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
|
||||
<header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="folder-editor-body">
|
||||
<small>EVENT EXHIBIT · THIS HAPPENED</small>
|
||||
<label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
|
||||
<label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
|
||||
<label className="field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL</span><input aria-label="Occurred at" type="datetime-local" required value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/></label>
|
||||
<div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
|
||||
<div className="folder-members event-support-list">
|
||||
{candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
|
||||
{candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
|
||||
</div>
|
||||
<p className="folder-editor-note">An event is an investigator assertion. Its occurrence time is separate from the publication time of the documents supporting it.</p>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
|
||||
</div>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||
|
||||
+11
-2
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { BookOpen, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil } from 'lucide-react'
|
||||
import { BookOpen, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil } from 'lucide-react'
|
||||
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
|
||||
import { folderIsOpen } from './boardDomain'
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ExhibitWidgetProps = {
|
||||
onOpenSource: (id: string) => void
|
||||
onToggleFolder: (id: string) => void
|
||||
onEditFolder: (id: string) => void
|
||||
onEditEvent: (id: string) => void
|
||||
}
|
||||
|
||||
export type ExhibitWidgetDefinition = {
|
||||
@@ -32,6 +33,14 @@ function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
|
||||
</div>
|
||||
}
|
||||
|
||||
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
|
||||
const supportCount = exhibit.supportingEvidenceIds?.length || 0
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
{exhibit.eventDate && <time>{new Date(exhibit.eventDate).toLocaleString()}</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>
|
||||
}
|
||||
|
||||
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}`,
|
||||
@@ -42,7 +51,7 @@ 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 },
|
||||
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget },
|
||||
}
|
||||
|
||||
export function exhibitWidget(type: EvidenceType) {
|
||||
|
||||
@@ -52,6 +52,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
|
||||
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
|
||||
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
|
||||
.folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; }
|
||||
.folder-bands line.closed { opacity: 0; }
|
||||
@@ -69,6 +71,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.evidence-card.folder::after { background: #80643e; }
|
||||
.evidence-card.folder header { border-color: #846e49; color: #594a33; }
|
||||
.evidence-card.folder h3 { color: #66401f; }
|
||||
.evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
|
||||
.evidence-card.event h3 { color: #70401e; }
|
||||
.evidence-card.event p { font-size: 15px; }
|
||||
.event-actions { clear: both; margin-top: 10px; padding-top: 7px; border-top: 1px dashed #858c84; display: flex; align-items: center; justify-content: space-between; }
|
||||
.event-actions span { color: #6a736d; font: 7px IBM Plex Mono; }
|
||||
.event-actions button { float: none; }
|
||||
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
|
||||
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
|
||||
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -108,6 +116,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
|
||||
.open-files { position: absolute; top: 25px; left: 25px; z-index: 4; border: 1px solid #4c675f; background: #122e28; padding: 11px 13px; display: flex; gap: 8px; font: 9px IBM Plex Mono; cursor: pointer; }
|
||||
.open-files b { color: #e19a4d; }
|
||||
.story-strip { position: absolute; z-index: 4; right: 18px; top: 18px; width: 255px; max-height: 235px; overflow: auto; padding: 10px; background: #0d2721e8; border: 1px solid #466159; box-shadow: 5px 7px 18px #0008; }
|
||||
.story-strip > small { display: block; padding: 2px 4px 8px; color: #b47a41; font: 600 8px IBM Plex Mono; letter-spacing: .12em; }
|
||||
.story-strip button { width: 100%; display: grid; grid-template-columns: 64px 1fr; gap: 2px 7px; padding: 7px 5px; text-align: left; border: 0; border-top: 1px solid #304a43; background: transparent; cursor: pointer; }
|
||||
.story-strip button:hover, .story-strip button.selected { background: #193a32; }
|
||||
.story-strip time { grid-row: 1 / 3; color: #7b938b; font: 7px IBM Plex Mono; }
|
||||
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
|
||||
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
|
||||
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
|
||||
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
|
||||
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
|
||||
@@ -146,6 +161,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.folder-members-heading small { color: #8c5c31; font: 7px IBM Plex Mono; }
|
||||
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
|
||||
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
|
||||
.event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; }
|
||||
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
|
||||
.folder-member.included { background: #e2d7bd; opacity: 1; }
|
||||
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface Evidence {
|
||||
sourceDocumentId?: string
|
||||
sourceRegionId?: string
|
||||
eventDate?: string
|
||||
supportingEvidenceIds?: string[]
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
|
||||
Reference in New Issue
Block a user