Adding working board

This commit is contained in:
2026-08-17 09:24:53 +02:00
parent 35685f765a
commit 0237da74cf
18 changed files with 1365 additions and 738 deletions
+152 -166
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
const BOARD_W = 2400
const BOARD_H = 1500
@@ -13,15 +13,24 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
function uid(_prefix: string) { return crypto.randomUUID() }
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) {
return [document.title, document.kind, document.date, document.publishedAt, document.fileName, document.mimeType,
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
}
function connectionPoint(item: Evidence) {
return exhibitWidget(item.type).connectionPoint(item)
function connectionPoint(item: Exhibit) {
return exhibitWidget(item.type).connectionPorts(item)[0]
}
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; exhibitId: string }
const placement = (x: number, y: number, width: number, height: number) => ({ x, y, width, height, rotation: 0, zIndex: 1, hidden: false })
function replaceDirectedRelations(relations: ExhibitRelation[], type: 'supports' | 'concerns', fromExhibitId: string, targets: string[]) {
const retained = relations.filter(relation => relation.type !== type || relation.fromExhibitId !== fromExhibitId)
return [...retained, ...targets.map((toExhibitId, sortOrder): ExhibitRelation => ({
id: relations.find(relation => relation.type === type && relation.fromExhibitId === fromExhibitId && relation.toExhibitId === toExhibitId)?.id || uid(type),
type, fromExhibitId, toExhibitId, sortOrder,
}))]
}
export function App() {
const [caseState, setCaseState] = useState<CaseState | null>(null)
@@ -42,9 +51,9 @@ export function App() {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null)
const [newEventDraft, setNewEventDraft] = useState<EventExhibit | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false)
@@ -121,7 +130,7 @@ export function App() {
const focusEvidence = (id: string) => {
if (!caseState) return
const ev = caseState.evidence.find(e => e.id === id)
const ev = caseState.exhibits.find(e => e.id === id)
if (!ev) return
setSelected(id)
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
@@ -130,14 +139,16 @@ export function App() {
const extract = (doc: CaseDocument, regionId: string) => {
if (!caseState) return
const region = doc.regions.find(r => r.id === regionId)!
const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId)
if (existing) { setOpenDoc(null); focusEvidence(existing.id); return }
const ev: Evidence = {
id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false },
sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id],
x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260,
const existingSource = caseState.relations.find(relation => relation.type === 'source' && relation.toExhibitId === doc.id && relation.sourceRegionId === regionId)
if (existingSource) { setOpenDoc(null); focusEvidence(existingSource.fromExhibitId); return }
const ev: FolderExhibit = {
id: uid('folder'), type: 'folder', title: `${documentWidget(doc.fileType).label.toUpperCase()} EVIDENCE`, content: region.excerpt, isOpen: false,
...placement(850 + Math.random() * 220, 390 + Math.random() * 250, 260, 166),
}
update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] }))
update(s => ({ ...s, exhibits: [...s.exhibits, ev], relations: [...s.relations,
{ id: uid('contains'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'contains', sortOrder: 0 },
{ id: uid('source'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'source', sortOrder: 0, sourceRegionId: region.id },
] }))
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
}
@@ -145,27 +156,27 @@ export function App() {
const content = window.prompt('What do you think this evidence means?')?.trim()
if (!content || !caseState) return
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) }
update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
}
const addEvent = () => {
if (!caseState) return
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
supportingEvidenceIds: [], ...position, width: 270 }
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
const event: EventExhibit = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
...placement(position.x, position.y, 270, 174) }
setNewEventDraft(event)
}
const addParty = () => {
if (!caseState) return
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, {
const position = nextOpenBoardPosition(caseState.exhibits, {
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
}, { width: 280 })
setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], relatedEvidenceIds: [], ...position, width: 280 })
setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], ...placement(position.x, position.y, 280, 190) })
}
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
@@ -175,15 +186,15 @@ export function App() {
const existingId = concept.resolvedPartyExhibitId
const partyId = existingId || uid('party')
const { viewport } = caseState
const existingParty = caseState.evidence.find(item => item.id === existingId)
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, {
const existingParty = caseState.exhibits.find((item): item is PartyExhibit => item.id === existingId && item.type === 'party')
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.exhibits, {
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
}, { width: 280 })
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
...position, width: 280 }
const party: PartyExhibit = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
title: concept.label, content: concept.context, aliases: [],
...placement(position.x, position.y, 280, 190) }
update(state => ({ ...state,
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
exhibits: existingId ? state.exhibits.map(item => item.id === existingId && item.type === 'party' ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.exhibits, party],
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
}))
setSelected(partyId)
@@ -203,14 +214,14 @@ export function App() {
const completeThread = (targetId: string) => {
if (!linkFrom || !caseState || linkFrom === targetId) return
const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom))
const existing = caseState.connections.find(connection => (connection.fromExhibitId === linkFrom && connection.toExhibitId === targetId) || (connection.fromExhibitId === targetId && connection.toExhibitId === linkFrom))
if (existing) {
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
return
}
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
setLinkFrom(null)
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
}
const saveThread = (connection: Connection) => {
@@ -306,7 +317,7 @@ export function App() {
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const document: CaseDocument = await response.json()
update(s => ({ ...s, documents: [...s.documents, document] }))
update(s => ({ ...s, exhibits: [...s.exhibits, document] }))
setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
} catch (error) {
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
@@ -317,27 +328,25 @@ export function App() {
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
const documents = documentExhibits(caseState.exhibits)
const evidence = evidenceExhibits(caseState.exhibits)
const documentById = new Map(documents.map(document => [document.id, document]))
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
const temporalItems: TemporalItem[] = [
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
const document = documentById.get(documentId)
const date = document?.publishedAt || document?.date
const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId)
return document && date && relation ? [{ id: `folder:${folder.id}:document:${document.id}`, sourceTemporalId: folderIsOpen(folder) ? `file:${relation.id}` : `widget:${folder.id}`, date, label: document.title, kind: 'document' as const, evidenceId: folder.id, 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 })),
].sort((a, b) => dateValue(a.date) - dateValue(b.date))
const storyEvents = caseState.evidence.filter(item => item.type === 'event').sort((a, b) => {
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
})).sort((a, b) => dateValue(a.date) - dateValue(b.date))
const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
if (!a.eventDate) return b.eventDate ? 1 : 0
if (!b.eventDate) return -1
return dateValue(a.eventDate) - dateValue(b.eventDate)
})
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
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>
@@ -363,13 +372,13 @@ export function App() {
<section className="workspace">
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${caseState.documents.length}` : caseState.documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
<div className="doc-list">
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
</button>)}
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
</div>
@@ -378,10 +387,10 @@ export function App() {
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { 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} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')}
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
canEdit={canAuthor}
onClose={closeBrief}
@@ -392,7 +401,7 @@ export function App() {
onEditParty={setEditingPartyId}
/>}
{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) || 'UNDATED'}</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>{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>
@@ -411,51 +420,49 @@ export function App() {
</div>
</section>
<DocumentLocatorBeam documentId={caseState.documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.evidence.map(item => `${item.id}:${item.x}:${item.y}:${String(item.config?.open)}`).join('|')}:${caseState.relations.map(item => `${item.id}:${String(item.config?.x)}:${String(item.config?.y)}`).join('|')}`} />
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
{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)} />}
<DocumentLocatorBeam documentId={documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} />
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
{timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
}
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
{editingFolderId && <FolderEditor
key={editingFolderId}
folder={caseState.evidence.find(widget => widget.id === editingFolderId)!}
folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
memberIds={containedIds(caseState, editingFolderId)}
documents={caseState.documents}
documents={documents}
canManageContents={canAuthor}
onClose={() => setEditingFolderId(null)}
onSave={(folder, members) => {
update(state => ({
...state,
evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget),
exhibits: state.exhibits.map(widget => widget.id === folder.id ? folder : 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 }
}),
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromExhibitId !== folder.id),
...members.map((documentId, index): ExhibitRelation => ({ id: state.relations.find(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id && relation.toExhibitId === documentId)?.id || uid('contains'), fromExhibitId: folder.id, toExhibitId: documentId, type: 'contains', sortOrder: index })),
],
}))
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={documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.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}
event={caseState.exhibits.find((item): item is EventExhibit => item.id === editingEventId && item.type === 'event')!}
exhibits={caseState.exhibits}
relations={caseState.relations}
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') }}
onSave={(event, supports) => { update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === event.id ? event : item), relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
/>}
{newEventDraft && <EventEditor
key={newEventDraft.id}
event={newEventDraft}
evidence={caseState.evidence}
documents={caseState.documents}
exhibits={caseState.exhibits}
relations={caseState.relations}
onClose={() => setNewEventDraft(null)}
onSave={event => {
update(state => ({ ...state, evidence: [...state.evidence, event] }))
onSave={(event, supports) => {
update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) }))
setNewEventDraft(null)
setSelected(event.id)
setRecentlyCreatedExhibitId(event.id)
@@ -464,12 +471,12 @@ export function App() {
/>}
{editingPartyId && <PartyEditor
key={editingPartyId}
party={caseState.evidence.find(item => item.id === editingPartyId)!}
evidence={caseState.evidence}
documents={caseState.documents}
party={caseState.exhibits.find((item): item is PartyExhibit => item.id === editingPartyId && item.type === 'party')!}
exhibits={caseState.exhibits}
relations={caseState.relations}
onClose={() => setEditingPartyId(null)}
onSave={party => {
update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) }))
onSave={(party, related) => {
update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === party.id ? party : item), relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
setEditingPartyId(null)
setStatus('PARTY DOSSIER UPDATED')
}}
@@ -477,12 +484,12 @@ export function App() {
{newPartyDraft && <PartyEditor
key={newPartyDraft.id}
party={newPartyDraft}
evidence={caseState.evidence}
documents={caseState.documents}
exhibits={caseState.exhibits}
relations={caseState.relations}
creating
onClose={() => setNewPartyDraft(null)}
onSave={party => {
update(state => ({ ...state, evidence: [...state.evidence, party] }))
onSave={(party, related) => {
update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
setNewPartyDraft(null)
setSelected(party.id)
setRecentlyCreatedExhibitId(party.id)
@@ -499,11 +506,11 @@ export function App() {
}}
/>}
{editingTimeline && <TimelineRangeEditor
range={caseState.timelineRange}
range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined}
dates={temporalItems.map(item => item.date)}
onClose={() => setEditingTimeline(false)}
onSave={timelineRange => {
update(state => ({ ...state, timelineRange }))
update(state => ({ ...state, views: state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: timelineRange ? 'fixed' : 'auto', range: timelineRange || undefined } : view) }))
setEditingTimeline(false)
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
}}
@@ -511,8 +518,8 @@ export function App() {
{threadDraft && <ThreadEditor
key={threadDraft.id}
connection={threadDraft}
sourceName={caseState.evidence.find(item => item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'}
targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'}
sourceName={caseState.exhibits.find(item => item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'}
targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'}
isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
onClose={() => setThreadDraft(null)}
onSave={saveThread}
@@ -537,8 +544,8 @@ 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, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
const pinchDistance = useRef<number | null>(null)
@@ -549,16 +556,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
const [trashActive, setTrashActive] = useState(false)
const trashRef = useRef<HTMLDivElement>(null)
const trashTarget = useRef(false)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
const byId = useMemo(() => new Map(state.exhibits.map(exhibit => [exhibit.id, exhibit])), [state.exhibits])
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
const pointForId = (id: string) => {
const evidence = byId.get(id)
if (evidence) return connectionPoint(evidence)
const relation = containmentRelations.find(item => item.toWidgetId === id)
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
if (!relation || !folder) return undefined
const position = relationPosition(state, relation)
return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder)
const exhibit = byId.get(id)
if (!exhibit) return undefined
const membership = exhibit.type === 'document' ? containmentRelations.find(item => item.toExhibitId === id) : undefined
const folder = membership ? byId.get(membership.fromExhibitId) : undefined
return folder?.type === 'folder' && !folder.isOpen ? connectionPoint(folder) : connectionPoint(exhibit)
}
useEffect(() => {
const board = boardRef.current
@@ -574,7 +579,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
board.addEventListener('wheel', handleWheelZoom, { passive: false })
return () => board.removeEventListener('wheel', handleWheelZoom)
}, [boardRef, update])
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => {
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget'; id: string }) => {
if ((event.target as HTMLElement).closest('button')) return
if (event.pointerType === 'touch') {
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
@@ -587,9 +592,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
}
}
const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
const position = relation ? relationPosition(state, relation) : undefined
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? state.viewport.x, originY: widget?.y ?? state.viewport.y }
setDraggingWidget(target?.kind === 'widget')
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
}
@@ -638,16 +641,15 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
if (drag.current.kind === 'thread-tag' && boardRef.current) {
if (drag.current.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === drag.current!.id)
const from = connection ? pointForId(connection.fromEvidenceId) : undefined
const to = connection ? pointForId(connection.toEvidenceId) : undefined
const from = connection ? pointForId(connection.fromExhibitId) : undefined
const to = connection ? pointForId(connection.toExhibitId) : undefined
if (connection && from && to) {
const bounds = boardRef.current.getBoundingClientRect()
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
}
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === drag.current!.id ? { ...exhibit, ...next } : exhibit) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
}
const finishDrag = (event: React.PointerEvent) => {
@@ -664,7 +666,16 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
trashTarget.current = false
}
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
const widgetContext: ExhibitWidgetContext = { exhibits: state.exhibits, relations: state.relations, dispatch: (command: WidgetCommand) => {
if (command.type === 'open-document') onOpenSource(command.documentId)
else if (command.type === 'toggle-folder') toggleFolder(command.folderId)
else if (command.type === 'edit-folder') onEditFolder(command.folderId)
else if (command.type === 'edit-event') onEditEvent(command.eventId)
else if (command.type === 'edit-party') onEditParty(command.partyId)
else if (command.type === 'edit-document') onEditFile(command.documentId)
else if (command.type === 'update-memory-cue') onUpdateDocumentCue(command.documentId, command.cue)
} }
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
onPointerDown={e => {
@@ -678,54 +689,32 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
<svg className="connections" width={BOARD_W} height={BOARD_H}>
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
</svg>
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: placement.x, top: placement.y }} title={connection.label ? dragging ? `Position ${Math.round(placement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(placement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
<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}/>]
}))}
{state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
</svg>
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).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(party)
return [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
{state.relations.filter(relation => relation.type === 'concerns').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
</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}/> })}
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
</svg>
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !folderIsOpen(ev) && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
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} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} context={widgetContext}/>
</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), located = open && selected === document.id, 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-document-locator-target={located ? document.id : undefined} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }}
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && selected === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.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={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
</article> })}
</div>
@@ -817,7 +806,7 @@ function Timeline({ items, range, selected, onSelect, onEdit }: { items: Tempora
const ticks = range
? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
: Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected || item.documentId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.exhibitId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
}
function localDateTime(value?: string) {
@@ -866,7 +855,7 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
</form></div>
}
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
const [minimized, setMinimized] = useState(false)
const partyById = new Map(parties.map(party => [party.id, party]))
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
@@ -895,16 +884,16 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
</form></div>
}
function PartyEditor({ party, evidence, documents, creating = false, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; creating?: boolean; onClose: () => void; onSave: (party: Evidence) => void }) {
function PartyEditor({ party, exhibits, relations, creating = false, onClose, onSave }: { party: PartyExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; creating?: boolean; onClose: () => void; onSave: (party: PartyExhibit, related: string[]) => void }) {
const [name, setName] = useState(party.title)
const [summary, setSummary] = useState(party.content)
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind || 'person')
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind)
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
const [related, setRelated] = useState(party.relatedEvidenceIds || [])
const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))]
const [aliases, setAliases] = useState(party.aliases.join('\n'))
const [related, setRelated] = useState(relations.filter(relation => relation.type === 'concerns' && relation.fromExhibitId === party.id).map(relation => relation.toExhibitId))
const candidates = exhibits.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.toUpperCase() : item.type.toUpperCase() }))
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}>
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean) }, related) }}>
<header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small>
{creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>}
@@ -919,14 +908,14 @@ function PartyEditor({ party, evidence, documents, creating = false, onClose, on
</form></div>
}
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, members: string[]) => void }) {
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: FolderExhibit; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: FolderExhibit, members: string[]) => void }) {
const [title, setTitle] = useState(folder.title)
const [content, setContent] = useState(folder.content)
const [members, setMembers] = useState(memberIds)
const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
const submit = (event: React.FormEvent) => {
event.preventDefault()
onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim(), containedDocumentIds: members }, members)
onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim() }, members)
}
return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
<header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
@@ -938,7 +927,7 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
<div className="folder-members">
{documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
<label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
<span className="folder-member-date">{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</span>
<span className="folder-member-date">{document.publishedAt?.slice(0, 10) || 'UNDATED'}</span>
</div> })}
</div>
<p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
@@ -947,20 +936,17 @@ 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 }) {
function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: EventExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; onClose: () => void; onSave: (event: EventExhibit, supports: string[]) => 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 [supports, setSupports] = useState(relations.filter(relation => relation.type === 'supports' && relation.fromExhibitId === event.id).map(relation => relation.toExhibitId))
const candidates = exhibits.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.replaceAll('_', ' ').toUpperCase() : item.type.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() : undefined
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports })
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate }, 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>
@@ -983,12 +969,12 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
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)
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt || document.date))
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
const submit = (event: React.FormEvent) => {
event.preventDefault()
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, date: publishedAt?.slice(0, 10) || '', metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
}
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
@@ -1019,8 +1005,8 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
{!minimized && <><nav>FILE&nbsp;&nbsp; EDIT&nbsp;&nbsp; EVIDENCE&nbsp;&nbsp; VIEW</nav>
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.kind}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
<footer><span>ARCHIVE ITEM · {doc.date}</span><span>PROVENANCE LOCKED</span></footer></>}
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{documentWidget(doc.fileType).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
<footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
</section>
}