feat: add event narrative and evidence workflow

This commit is contained in:
2026-08-14 14:31:52 +02:00
parent edfa4c866c
commit e333d3b634
10 changed files with 143 additions and 14 deletions
+1 -1
View File
@@ -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. - **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. - **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. - **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. 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
View File
@@ -39,9 +39,9 @@ This is the ordered implementation roadmap following the accepted exhibit model.
### Events and narrative ### Events and narrative
- [ ] Implement Event exhibits with occurrence time and investigator-authored narrative text. - [x] Implement Event exhibits with occurrence time and investigator-authored narrative text.
- [ ] Implement normalized Event-to-Evidence links and their board visualization. - [x] Implement normalized Event-to-Evidence links and their distinct board visualization.
- [ ] Present chronologically ordered events as the emerging investigation story. - [x] Present chronologically ordered events as the emerging investigation story.
### Party exhibits ### Party exhibits
+1 -1
View File
@@ -1,6 +1,6 @@
# GUPI OSINT Board: canonical exhibit data model # 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 ## Vocabulary
+11
View File
@@ -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) await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforePinch)
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforePinch) 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')) 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()) 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() await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click()
+12 -3
View File
@@ -71,10 +71,12 @@ suite('level persistence API', () => {
const documentId = randomUUID() const documentId = randomUUID()
const folderId = randomUUID() const folderId = randomUUID()
const noteId = 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.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 = [ state.evidence = [
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] }, { 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: 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.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 }] state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: noteId }]
@@ -134,15 +136,17 @@ suite('level persistence API', () => {
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport) expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) 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.exhibits)::text AS exhibits,
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents, (SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders, (SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships, (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_metadata_text_values)::text AS metadata,
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources, (SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections`) (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.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' }) const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
expect(resetResponse.ok).toBe(true) 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.evidence.map(item => item.id)).not.toContain(folderId)
expect(clone.connections).toHaveLength(1) expect(clone.connections).toHaveLength(1)
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' }) 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')! const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
clonedFolder.x = 1234 clonedFolder.x = 1234
+15 -1
View File
@@ -62,7 +62,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
async function assembleLevel(slug: string): Promise<CaseState | null> { async function assembleLevel(slug: string): Promise<CaseState | null> {
const level = await findLevel(pool, slug) const level = await findLevel(pool, slug)
if (!level) return null 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, 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.title, d.title, n.title, ev.title, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, '') AS content, 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 }>( 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 `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]), 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[]>() 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>>() 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 }) 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 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 => { const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id]) 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, 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, 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, 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, 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) || [], 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, 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) 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]) 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) { for (const connection of state.connections) {
requireUuid(connection.id, 'Connection id') requireUuid(connection.id, 'Connection id')
if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit') if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')
+72 -3
View File
@@ -32,6 +32,7 @@ export function App() {
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move') const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
const [editingFolderId, setEditingFolderId] = useState<string | null>(null) const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = 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 saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null) const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -107,6 +108,16 @@ export function App() {
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id) 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) => { const handleCardClick = (id: string) => {
if (!linkFrom) { setSelected(current => current === id ? null : id); return } 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))) { 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.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 })), ...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)) ].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"> return <main className="desktop">
<header className="menubar"> <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> <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="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> <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>} {!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
<div className="board-actions"> <div className="board-actions">
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button> <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> <button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
<span /> <span />
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button> <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> <button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
<span /> <span />
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button> <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)} />} {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') }}/>} {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') }}/>} {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)}/>} {helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
</main> </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> 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 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 suppressClick = useRef(false)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence]) 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}> <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> })} {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>
<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}> <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}/> })} {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> </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() }} 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) }}> 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> <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>})} </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 }} {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 }) }} 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> </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 }) { function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
const [title, setTitle] = useState(document.title) const [title, setTitle] = useState(document.title)
const [fileType, setFileType] = useState<SourceFileType>(document.fileType) const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
+11 -2
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react' 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 type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
import { folderIsOpen } from './boardDomain' import { folderIsOpen } from './boardDomain'
@@ -9,6 +9,7 @@ export type ExhibitWidgetProps = {
onOpenSource: (id: string) => void onOpenSource: (id: string) => void
onToggleFolder: (id: string) => void onToggleFolder: (id: string) => void
onEditFolder: (id: string) => void onEditFolder: (id: string) => void
onEditEvent: (id: string) => void
} }
export type ExhibitWidgetDefinition = { export type ExhibitWidgetDefinition = {
@@ -32,6 +33,14 @@ function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
</div> </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 standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 })
const folderDefinition: ExhibitWidgetDefinition = { const folderDefinition: ExhibitWidgetDefinition = {
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`, visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`,
@@ -42,7 +51,7 @@ export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition
folder: folderDefinition, folder: folderDefinition,
evidence: folderDefinition, evidence: folderDefinition,
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget }, 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) { export function exhibitWidget(type: EvidenceType) {
+16
View File
@@ -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 { 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 path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; } .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 { 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 { 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; } .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::after { background: #80643e; }
.evidence-card.folder header { border-color: #846e49; color: #594a33; } .evidence-card.folder header { border-color: #846e49; color: #594a33; }
.evidence-card.folder h3 { color: #66401f; } .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 { 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 { 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; } .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; } .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 { 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; } .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; } .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; } .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; } .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 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-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; } .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 { 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.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; } .folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }
+1
View File
@@ -32,6 +32,7 @@ export interface Evidence {
sourceDocumentId?: string sourceDocumentId?: string
sourceRegionId?: string sourceRegionId?: string
eventDate?: string eventDate?: string
supportingEvidenceIds?: string[]
x: number x: number
y: number y: number
width: number width: number