Add classified evidence presentations

This commit is contained in:
2026-08-22 19:28:06 +02:00
parent dc0147aebc
commit 10dcd567c6
18 changed files with 414 additions and 45 deletions
+91 -14
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, 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, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
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'
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
const BOARD_W = 2400
@@ -20,7 +20,7 @@ function screenshotFile(file: File, index: number) {
}
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) {
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,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()
}
@@ -54,6 +54,7 @@ export function App() {
const [clock, setClock] = useState('')
const [draggingFiles, setDraggingFiles] = useState(false)
const [uploading, setUploading] = useState(0)
const [documentClassificationQueue, setDocumentClassificationQueue] = useState<string[]>([])
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null)
@@ -275,7 +276,10 @@ export function App() {
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
return
}
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom)
const target = caseState.exhibits.find(exhibit => exhibit.id === targetId)
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId,
label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
setLinkFrom(null)
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
}
@@ -421,12 +425,24 @@ export function App() {
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])])
} catch (error) {
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
} finally { setUploading(count => count - 1) }
}
}, [caseState, loadLevelBySlug, requestedEditMode, update])
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
const presentation=documentCapture(captureKind)
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height })
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
? classify(exhibit)
: exhibit) }))
setOpenDoc(current => current?.id === documentId ? classify(current) : current)
setDocumentClassificationQueue(current => current.filter(id => id !== documentId))
setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`)
}
const continueAfterGoal = async () => {
if (!activePlaythroughId) { setCompletedGoal(null); return }
setAdvancing(true)
@@ -501,6 +517,7 @@ export function App() {
const documentById = new Map(documents.map(document => [document.id, document]))
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
@@ -553,7 +570,7 @@ export function App() {
<div className="doc-list">
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} 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.fileType.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).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>
@@ -600,7 +617,11 @@ export function App() {
<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] : [])} />}
{classificationDocument && <DocumentClassificationPanel document={classificationDocument}
onChoose={captureKind => classifyDocument(classificationDocument.id,captureKind)}/>}
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)}
onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }}
onType={captureKind => classifyDocument(openDoc.id,captureKind)} 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.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
@@ -621,7 +642,7 @@ export function App() {
setStatus('FOLDER UPDATED')
}}
/>}
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} 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') }}/>
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
}
{editingEventId && <EventEditor
key={editingEventId}
@@ -925,10 +946,10 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} context={widgetContext}/>
</article>})}
{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 && locatorDocumentId === 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' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
{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 && locatorDocumentId === 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 presentation=documentCapture(document.captureKind); 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}`} data-capture-kind={document.captureKind} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} 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(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (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?.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>
@@ -1242,16 +1263,42 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E
</form></div>
}
function CaptureKindIcon({ kind }:{ kind:Exclude<DocumentCaptureKind,'unclassified'> }) {
if (kind === 'photo') return <Camera size={24}/>
if (kind === 'scene') return <Images size={24}/>
if (kind === 'clipping') return <Newspaper size={24}/>
return <FileText size={24}/>
}
function DocumentClassificationPanel({ document,onChoose }:{ document:CaseDocument;onChoose:(kind:DocumentCaptureKind)=>void }) {
const choices=(Object.keys(documentCaptureRegistry) as DocumentCaptureKind[]).filter((kind):kind is Exclude<DocumentCaptureKind,'unclassified'> => kind !== 'unclassified')
const source=document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''
return <div className="modal-shade evidence-classification-shade"><section className="evidence-classification" role="dialog" aria-modal="true" aria-labelledby="evidence-classification-title">
<header><ClipboardCheck size={16}/><b>Classify new evidence</b><span/><button type="button" aria-label="Classify later" onClick={() => onChoose('unclassified')}><X size={14}/></button></header>
<div className="evidence-classification-body">
<div className="classification-source">{source ? <img src={source} alt=""/> : <ImageIcon size={36}/>}<div><small>EXHIBIT {document.displayNumber || '—'}</small><strong>{document.title}</strong></div></div>
<div className="classification-question"><small>NEW SOURCE DOCUMENT</small><h2 id="evidence-classification-title">What kind of evidence is this?</h2><p>This changes how it appears on the board. It does not alter the original file, OCR, or provenance.</p></div>
<div className="classification-options">{choices.map(kind => { const definition=documentCapture(kind); return <button type="button" key={kind} aria-label={`Classify as ${definition.label}`} onClick={() => onChoose(kind)}>
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>
</button> })}</div>
<button type="button" className="classify-later" onClick={() => onChoose('unclassified')}>NOT SURE · CLASSIFY LATER</button>
</div>
</section></div>
}
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
const [title, setTitle] = useState(document.title)
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
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,
const presentation=documentCapture(captureKind)
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt,
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
}
@@ -1263,6 +1310,7 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
</div>
<label className="field"><span>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} {definition.description}</option>)}</select></label>
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
@@ -1385,10 +1433,28 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
</section></div>
}
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
const DOCUMENT_TYPE_MENU:Exclude<DocumentCaptureKind,'unclassified'>[] = ['full_page','scene','photo','clipping']
function DocumentWindow({ doc, onClose, onInfo, onDelete, onType, onExtract, extracted }: {
doc: CaseDocument
onClose: () => void
onInfo: () => void
onDelete: () => void
onType: (captureKind:Exclude<DocumentCaptureKind,'unclassified'>) => void
onExtract: (id: string) => void
extracted: (string | undefined)[]
}) {
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
const [minimized, setMinimized] = useState(false)
const [menu, setMenu] = useState<'file'|'type'|null>(null)
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
const menuRef = useRef<HTMLElement>(null)
useEffect(() => {
if (!menu) return
const closeMenu = (event:PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenu(null) }
document.addEventListener('pointerdown',closeMenu)
return () => document.removeEventListener('pointerdown',closeMenu)
},[menu])
const startDrag = (e: React.PointerEvent<HTMLElement>) => {
if ((e.target as HTMLElement).closest('button')) return
drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
@@ -1396,8 +1462,19 @@ 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>{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>
{!minimized && <><nav ref={menuRef} className="document-menu-bar" aria-label="Document actions">
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'file'} onClick={() => setMenu(current => current === 'file' ? null : 'file')}>FILE</button>
{menu === 'file' && <div className="document-menu-items" role="menu">
<button type="button" role="menuitem" onClick={() => { setMenu(null);onInfo() }}><Info size={13}/><span><b>INFO</b><small>Metadata and provenance</small></span></button>
<button type="button" role="menuitem" className="danger" onClick={() => { setMenu(null);onDelete() }}><Trash2 size={13}/><span><b>DELETE</b><small>Remove from this board</small></span></button>
</div>}
</div>
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'type'} onClick={() => setMenu(current => current === 'type' ? null : 'type')}>TYPE</button>
{menu === 'type' && <div className="document-menu-items type-menu" role="menu">{DOCUMENT_TYPE_MENU.map(kind => { const definition=documentCapture(kind);return <button type="button" role="menuitemradio" aria-checked={doc.captureKind === kind} key={kind} onClick={() => { onType(kind);setMenu(null) }}>
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>{doc.captureKind === kind && <Check className="menu-check" size={13}/>}</button> })}</div>}
</div>
</nav>
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`} onPointerDown={() => setMenu(null)}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).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>
}