2026-08-14 12:43:11 +02:00
import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
2026-08-14 14:20:41 +02:00
import { BookOpen , CalendarClock , ChevronRight , CircleHelp , FileText , FolderOpen , Hand , Image as ImageIcon , Link2 , Minus , MousePointer2 , Network , NotebookPen , Pencil , Plus , RotateCcw , Search , Trash2 , Upload , X , ZoomIn , ZoomOut } from 'lucide-react'
2026-08-14 12:43:11 +02:00
import type { CaseDocument , CaseState , Evidence , SourceFileType , WidgetRelation } from './types'
2026-08-14 12:57:06 +02:00
import { clampBoardZoom , containedIds , dateValue , folderIsOpen , moveBoardPoint , normalizeCase , panViewport , relationPosition , timelinePositionPercent , timelineRange , zoomFromWheel } from './boardDomain'
2026-08-14 14:20:41 +02:00
import { documentWidget , exhibitWidget } from './exhibitRegistry'
2026-08-14 12:43:11 +02:00
const BOARD_W = 2400
const BOARD_H = 1500
const SOURCE_FILE_TYPES : { value : SourceFileType ; label : string }[] = [
2026-08-14 14:20:41 +02:00
'image' , 'pdf' , 'web_capture' , 'email' , 'article' , 'filing' , 'price_list' , 'text' , 'file' ,
]. map ( value => ({ value : value as SourceFileType , label : documentWidget ( value as SourceFileType ). label }))
2026-08-14 12:43:11 +02:00
2026-08-14 14:09:24 +02:00
function uid ( _prefix : string ) { return crypto . randomUUID () }
2026-08-14 12:43:11 +02:00
function connectionPoint ( item : Evidence ) {
2026-08-14 14:20:41 +02:00
return exhibitWidget ( item . type ). connectionPoint ( item )
2026-08-14 12:43:11 +02:00
}
type TemporalItem = { id : string ; sourceTemporalId : string ; date : string ; label : string ; kind : 'document' | 'widget' ; evidenceId? : string ; documentId? : string }
export function App() {
const [ caseState , setCaseState ] = useState < CaseState | null >( null )
const [ noLevels , setNoLevels ] = useState ( false )
const [ openDoc , setOpenDoc ] = useState < CaseDocument | null >( null )
const [ selected , setSelected ] = useState < string | null >( null )
const [ linkFrom , setLinkFrom ] = useState < string | null >( null )
const [ docsOpen , setDocsOpen ] = useState ( true )
const [ helpOpen , setHelpOpen ] = useState ( false )
const [ status , setStatus ] = useState ( 'CONNECTING TO ARCHIVE…' )
const [ clock , setClock ] = useState ( '' )
const [ draggingFiles , setDraggingFiles ] = useState ( false )
const [ uploading , setUploading ] = useState ( 0 )
const [ boardTool , setBoardTool ] = useState < 'move' | 'hand' > ( 'move' )
const [ editingFolderId , setEditingFolderId ] = useState < string | null >( null )
const [ editingFileId , setEditingFileId ] = useState < string | null >( null )
const saveTimer = useRef < number | undefined >( undefined )
const boardRef = useRef < HTMLDivElement >( null )
const fileInputRef = useRef < HTMLInputElement >( null )
const requestedEditMode = new URLSearchParams ( window . location . search ). get ( 'edit' ) === '1'
useEffect (() => {
const params = new URLSearchParams ( window . location . search )
fetch ( '/api/levels' ). then ( r => {
if ( ! r . ok ) throw new Error ( 'Server unavailable' )
return r . json ()
}). then ( async ( levels : { id : string }[]) => {
const levelId = params . get ( 'level' ) || levels [ 0 ] ? . id
if ( ! levelId ) { setNoLevels ( true ); setStatus ( 'NO LEVELS IN ARCHIVE' ); return }
const editQuery = params . get ( 'edit' ) === '1' ? '?edit=1' : ''
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( levelId ) }${ editQuery } ` )
if ( ! response . ok ) throw new Error ( 'Level unavailable' )
const data = await response . json ()
setCaseState ( normalizeCase ( data )); setStatus ( 'EVIDENCE INTEGRITY: PROBABLY OK' )
})
. catch (() => {
const cached = localStorage . getItem ( 'gupi-osint-board:last' )
if ( cached ) setCaseState ( normalizeCase ( JSON . parse ( cached )))
setStatus ( cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE' )
})
const tick = () => setClock ( new Date (). toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' }))
tick (); const timer = window . setInterval ( tick , 30000 )
return () => clearInterval ( timer )
}, [])
const update = useCallback (( fn : ( state : CaseState ) => CaseState ) => {
setCaseState ( current => {
if ( ! current ) return current
const next = fn ( current )
localStorage . setItem ( 'gupi-osint-board:last' , JSON . stringify ( next ))
window . clearTimeout ( saveTimer . current )
saveTimer . current = window . setTimeout (() => {
const editQuery = requestedEditMode && next . editingAllowed ? '?edit=1' : ''
fetch ( `/api/levels/ ${ encodeURIComponent ( next . id ) }${ editQuery } ` , { method : 'PUT' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ( next ) })
. then ( r => { if ( ! r . ok ) throw new Error (); setStatus ( 'SAVED TO CASE ARCHIVE' ) })
. catch (() => setStatus ( 'OFFLINE · SAVED LOCALLY' ))
}, 450 )
return next
})
}, [ requestedEditMode ])
const focusEvidence = ( id : string ) => {
if ( ! caseState ) return
const ev = caseState . evidence . 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 } }))
}
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 ,
}
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 }] }))
setOpenDoc ( null ); setSelected ( ev . id ); setStatus ( 'EVIDENCE EXTRACTED · PROVENANCE ATTACHED' )
}
const addNote = () => {
const content = window . prompt ( 'What do you think this evidence means?' ) ? . trim ()
if ( ! content || ! caseState ) return
const { viewport } = caseState
const note : Evidence = { id : uid ( 'note' ), type : 'note' , title : 'WORKING NOTE' , content , x : Math.max ( 100 , ( 500 - viewport . x ) / viewport . zoom ), y : Math.max ( 100 , ( 330 - viewport . y ) / viewport . zoom ), width : 108 }
update ( s => ({ ... s , evidence : [... s . evidence , note ] })); setSelected ( note . id )
}
const handleCardClick = ( id : string ) => {
if ( ! linkFrom ) { setSelected ( current => current === id ? null : id ); return }
if ( linkFrom !== id && caseState && ! caseState . connections . some ( c => ( c . fromEvidenceId === linkFrom && c . toEvidenceId === id ) || ( c . fromEvidenceId === id && c . toEvidenceId === linkFrom ))) {
update ( s => ({ ... s , connections : [... s . connections , { id : uid ( 'connection' ), fromEvidenceId : linkFrom , toEvidenceId : id }] }))
}
setLinkFrom ( null ); setSelected ( id )
}
const reset = async () => {
2026-08-14 14:17:54 +02:00
if ( ! caseState ? . sourceTemplateVersionId || ! window . confirm ( 'Reset this investigation to its original template version?' )) return
2026-08-14 12:43:11 +02:00
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState ! . id ) } /reset` , { method : 'POST' })
if ( response . ok ) { const data = await response . json (); setCaseState ( normalizeCase ( data )); localStorage . removeItem ( 'gupi-osint-board:last' ); setSelected ( null ); setStatus ( 'CASE RESET' ) }
}
2026-08-14 14:17:54 +02:00
const saveAsTemplate = async () => {
if ( ! caseState || ! requestedEditMode || ! caseState . editingAllowed ) return
const name = window . prompt ( 'Template name:' , caseState . title ) ? . trim ()
if ( ! name ) return
window . clearTimeout ( saveTimer . current )
setStatus ( 'FREEZING TEMPLATE VERSION…' )
const saved = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) } ?edit=1` , {
method : 'PUT' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ( caseState ),
})
if ( ! saved . ok ) { setStatus ( 'LEVEL SAVE FAILED' ); return }
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) } /templates?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ name }),
})
if ( ! response . ok ) { setStatus ( 'TEMPLATE SAVE FAILED' ); return }
const template : { slug : string ; currentVersion : number } = await response . json ()
setStatus ( `TEMPLATE ${ template . slug . toUpperCase () } · VERSION ${ template . currentVersion } ` )
}
const instantiateTemplate = async () => {
if ( ! requestedEditMode || ! caseState ? . editingAllowed ) return
const templatesResponse = await fetch ( '/api/templates' )
if ( ! templatesResponse . ok ) { setStatus ( 'TEMPLATE ARCHIVE UNAVAILABLE' ); return }
const templates : { slug : string ; name : string ; currentVersion : number }[] = await templatesResponse . json ()
if ( ! templates . length ) { setStatus ( 'NO TEMPLATES SAVED' ); return }
const templateSlug = window . prompt ( `Template slug: \ n ${ templates . map ( item => ` ${ item . slug } (v ${ item . currentVersion } )` ). join ( '\n' ) } ` , templates [ 0 ]. slug ) ? . trim ()
if ( ! templateSlug ) return
const title = window . prompt ( 'Name the new investigation:' , templates . find ( item => item . slug === templateSlug ) ? . name || 'New Investigation' ) ? . trim ()
if ( ! title ) return
setStatus ( 'CLONING TEMPLATE…' )
const response = await fetch ( `/api/templates/ ${ encodeURIComponent ( templateSlug ) } /levels?edit=1` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ title }),
})
if ( ! response . ok ) { setStatus ( 'TEMPLATE CLONE FAILED' ); return }
const level : CaseState = await response . json ()
window . location . assign ( `?level= ${ encodeURIComponent ( level . id ) } &edit=1` )
}
2026-08-14 12:43:11 +02:00
const uploadFiles = async ( files : FileList | File []) => {
if ( ! caseState || ! requestedEditMode || ! caseState . editingAllowed ) return
const queue = Array . from ( files )
setUploading ( queue . length )
setDraggingFiles ( false )
for ( const file of queue ) {
const form = new FormData ()
form . append ( 'file' , file )
try {
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 ] }))
setStatus ( `IMPORTED · ${ file . name . toUpperCase () } ` )
} catch ( error ) {
setStatus ( error instanceof Error ? error . message . toUpperCase () : 'UPLOAD FAILED' )
} finally { setUploading ( count => count - 1 ) }
}
}
if ( noLevels ) return < EmptyArchive canEdit = { requestedEditMode } onCreated = { level => { setCaseState ( level ); setNoLevels ( false ); window . history . replaceState ({}, '' , `?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 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 ))
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 >
< nav >
2026-08-14 14:17:54 +02:00
< button onClick = {() => setDocsOpen ( v => ! v )}> FILE </ button >< button onClick = { addNote }> EVIDENCE </ button >< button onClick = {() => update ( s => ({ ... s , viewport : { x : 0 , y : 28 , zoom : .7 } }))}> VIEW </ button >< button onClick = {() => { const firstWidget = temporalItems . find ( item => item . evidenceId ); if ( firstWidget ? . evidenceId ) focusEvidence ( firstWidget . evidenceId ) }}> TIMELINE </ button >{ requestedEditMode && caseState . editingAllowed && <>< button onClick = { saveAsTemplate }> SAVE TEMPLATE </ button >< button onClick = { instantiateTemplate }> NEW FROM TEMPLATE </ button ></>}< button onClick = {() => setHelpOpen ( true )}> HELP </ button >
2026-08-14 12:43:11 +02:00
</ nav >
< div className = "terminal-status" >< i /> { status }< span >{ clock }</ span ></ div >
</ header >
< section className = "workspace" >
< aside className = { `documents-panel ${ docsOpen ? '' : 'closed' } ` }>
< div className = "panel-heading" >< div >< small > CASE MATERIALS </ small >< h2 > DOCUMENTS < sup >{ caseState . documents . length }</ sup ></ h2 ></ div >< button onClick = {() => setDocsOpen ( false )}>< X size = { 17 }/></ button ></ div >
< div className = "search" >< Search size = { 15 }/>< span > Search case archive … </ span ></ div >
{ requestedEditMode && caseState . editingAllowed && <>< 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" >
{ caseState . documents . map (( doc , index ) => < button className = "doc-row" data-temporal-id = { `document: ${ doc . id } ` } key = { doc . id } onDoubleClick = {() => setOpenDoc ( doc )} onClick = {() => setOpenDoc ( doc )}>
< 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 }/>
</ button >)}
</ div >
< div className = "panel-foot" >< FolderOpen size = { 15 }/> ARCHIVE MOUNTED < span >{ requestedEditMode && caseState . editingAllowed ? 'AUTHORING' : 'READ ONLY' }</ span ></ div >
</ aside >
< div className = "board-shell" onDragEnter = { e => { if ( e . dataTransfer . types . includes ( 'Files' ) && requestedEditMode && caseState . editingAllowed ) { e . preventDefault (); setDraggingFiles ( true ) } }} onDragOver = { e => { if ( requestedEditMode && caseState . editingAllowed ) { e . preventDefault (); e . dataTransfer . dropEffect = 'copy' } }} onDragLeave = { e => { if ( ! e . currentTarget . contains ( e . relatedTarget as Node )) setDraggingFiles ( false ) }} onDrop = { e => { e . preventDefault (); if ( e . dataTransfer . files . length ) uploadFiles ( e . dataTransfer . files ) }}>
< div className = "case-heading" >< div >< small >{ requestedEditMode && caseState . editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION' }</ small >< h1 >{ caseState . title }</ h1 >< p >{ caseState . subtitle || caseState . id . toUpperCase ()}</ p ></ div >< div className = "case-number" >{ requestedEditMode && caseState . editingAllowed ? 'DRAFT' : 'CASE' }< br />< b >{ caseState . levelStatus ? . toUpperCase () || 'ACTIVE' }</ b ></ div ></ div >
< Board state = { caseState } selected = { selected } linkFrom = { linkFrom } tool = { boardTool } boardRef = { boardRef } update = { update } onCardClick = { handleCardClick } onOpenSource = { id => setOpenDoc ( caseState . documents . find ( d => d . id === id ) || null )} onEditFolder = { setEditingFolderId } onEditFile = { setEditingFileId } />
{ ! docsOpen && < button className = "open-files" onClick = {() => setDocsOpen ( true )}>< FolderOpen size = { 18 }/> CASE MATERIALS < b >{ caseState . documents . length }</ b ></ button >}
< div className = "board-actions" >
< button className = { boardTool === 'move' ? 'active' : '' } title = "Move widgets" onClick = {() => setBoardTool ( 'move' )}>< MousePointer2 size = { 17 }/> MOVE </ button >
< button className = { boardTool === 'hand' ? 'active' : '' } title = "Pan board (middle mouse always works)" onClick = {() => setBoardTool ( 'hand' )}>< Hand size = { 17 }/> HAND </ button >
< span />
< button onClick = { addNote }>< NotebookPen size = { 17 }/> NEW NOTE </ button >
< button className = { linkFrom ? 'active' : '' } disabled = { ! selected } onClick = {() => setLinkFrom ( linkFrom ? null : selected )}>< Link2 size = { 17 }/> { linkFrom ? 'SELECT TARGET' : 'CONNECT' }</ button >
< span />
2026-08-14 12:57:06 +02:00
< button aria-label = "Zoom out" onClick = {() => update ( s => ({ ... s , viewport : { ... s . viewport , zoom : clampBoardZoom ( s . viewport . zoom - . 1 ) } }))}>< ZoomOut size = { 18 }/></ button >
2026-08-14 12:43:11 +02:00
< b >{ Math . round ( caseState . viewport . zoom * 100 )} % </ b >
2026-08-14 12:57:06 +02:00
< button aria-label = "Zoom in" onClick = {() => update ( s => ({ ... s , viewport : { ... s . viewport , zoom : clampBoardZoom ( s . viewport . zoom + . 1 ) } }))}>< ZoomIn size = { 18 }/></ button >
2026-08-14 14:17:54 +02:00
< button aria-label = "Reset" title = { caseState . sourceTemplateVersionId ? 'Reset to source template' : 'This level has no source template' } disabled = { ! caseState . sourceTemplateVersionId } onClick = { reset }>< RotateCcw size = { 17 }/></ button >
2026-08-14 12:43:11 +02:00
</ div >
{ draggingFiles && < div className = "file-drop-overlay" >< div >< Upload size = { 28 }/>< b > ADD SOURCE DOCUMENTS </ b >< span > DROP FILES INTO THIS LEVEL </ span ></ div ></ div >}
</ div >
</ section >
< 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 } selected = { selected } 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 )} />}
{ editingFolderId && < FolderEditor key = { editingFolderId } folder = { caseState . evidence . find ( widget => widget . id === editingFolderId ) ! } memberIds = { containedIds ( caseState , editingFolderId )} documents = { caseState . documents } canManageContents = { requestedEditMode && Boolean ( caseState . editingAllowed )} onClose = {() => setEditingFolderId ( null )} onSave = {( folder , members ) => { update ( state => ({ ... state , evidence : state.evidence.map ( widget => widget . id === folder . id ? { ... folder , containedDocumentIds : members } : widget ), relations : [... state . relations . filter ( relation => relation . type !== 'contains' || relation . fromWidgetId !== folder . id ), ... members . map (( documentId , index ) => { const existing = state . relations . find ( relation => relation . type === 'contains' && relation . fromWidgetId === folder . id && relation . toWidgetId === documentId ); const position = relationPosition ( state , existing || { id : '' , fromWidgetId : folder.id , toWidgetId : documentId , type : 'contains' , sortOrder : index }); return { id : existing?.id || `contains: ${ folder . id } : ${ documentId } ` , fromWidgetId : folder.id , toWidgetId : documentId , type : 'contains' , sortOrder : index , config : existing?.config || position } })] })); setEditingFolderId ( null ); setStatus ( 'FOLDER UPDATED' ) }}/>}
{ editingFileId && < FileEditor key = { editingFileId } document = { caseState . documents . find ( document => document . id === editingFileId ) ! } onClose = {() => setEditingFileId ( null )} onSave = { document => { update ( state => ({ ... state , documents : state.documents.map ( candidate => candidate . id === document . id ? document : candidate ) })); setEditingFileId ( null ); setStatus ( 'FILE METADATA UPDATED' ) }}/>}
{ helpOpen && < Help onClose = {() => setHelpOpen ( false )}/>}
</ main >
}
function EmptyArchive ({ canEdit , onCreated } : { canEdit : boolean ; onCreated : ( level : CaseState ) => void }) {
const [ creating , setCreating ] = useState ( false )
const createLevel = async () => {
const title = window . prompt ( 'Name this investigation level:' , 'Untitled Investigation' ) ? . trim ()
if ( ! title ) return
setCreating ( true )
try {
const response = await fetch ( '/api/levels' , { method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ title }) })
if ( ! response . ok ) throw new Error ( 'Could not create level' )
onCreated ( await response . json ())
} finally { setCreating ( false ) }
}
return < main className = "empty-archive" >< div className = "seal" > GU </ div >< small > GLITCH UNIVERSITY LEVEL ARCHIVE </ small >< h1 > No investigations found .</ h1 >< p > The database is ready , but no authored level exists yet .</ p >{ canEdit ? < button disabled = { creating } onClick = { createLevel }>< Plus size = { 17 }/>{ creating ? 'CREATING…' : 'CREATE FIRST LEVEL' }</ button > : < p className = "hint" > Add < code > ? edit = 1 </ code > and enable level editing on the server to begin authoring .</ p >}</ main >
}
function Board ({ state , selected , linkFrom , tool , boardRef , update , onCardClick , onOpenSource , onEditFolder , onEditFile } : { state : CaseState ; selected : string | null ; linkFrom : string | null ; tool : 'move' | 'hand' ; boardRef : React.RefObject < HTMLDivElement | null >; update : ( fn : ( s : CaseState ) => CaseState ) => void ; onCardClick : ( id : string ) => void ; onOpenSource : ( id : string ) => void ; onEditFolder : ( id : string ) => void ; onEditFile : ( id : string ) => void }) {
const drag = useRef < { kind : 'pan' | 'widget' | 'relation' ; id? : string ; startX : number ; startY : number ; originX : number ; originY : number ; moved? : boolean } | null > ( null )
const suppressClick = useRef ( false )
const byId = useMemo (() => new Map ( state . evidence . map ( e => [ e . id , e ])), [ state . evidence ])
const containmentRelations = state . relations . filter ( relation => relation . type === 'contains' )
useEffect (() => {
const board = boardRef . current
if ( ! board ) return
const handlePinch = ( event : WheelEvent ) => {
if ( ! event . ctrlKey && ! event . metaKey ) return
event . preventDefault ()
event . stopPropagation ()
2026-08-14 12:57:06 +02:00
update ( s => ({ ... s , viewport : { ... s . viewport , zoom : zoomFromWheel ( s . viewport . zoom , event . deltaY ) } }))
2026-08-14 12:43:11 +02:00
}
board . addEventListener ( 'wheel' , handlePinch , { passive : false })
return () => board . removeEventListener ( 'wheel' , handlePinch )
}, [ boardRef , update ])
const pointerDown = ( event : React.PointerEvent , target ?: { kind : 'widget' | 'relation' ; id : string }) => {
if (( event . target as HTMLElement ). closest ( 'button' )) return
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 }
event . currentTarget . setPointerCapture ( event . pointerId )
}
const pointerMove = ( event : React.PointerEvent ) => {
if ( ! drag . current ) return
const dx = event . clientX - drag . current . startX , dy = event . clientY - drag . current . startY
if ( Math . abs ( dx ) + Math . abs ( dy ) > 4 ) drag . current . moved = true
2026-08-14 12:57:06 +02:00
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 update ( s => ({ ... s , viewport : panViewport ({ ... s . viewport , x : drag.current ! . originX , y : drag.current ! . originY }, { x : dx , y : dy }) }))
2026-08-14 12:43:11 +02:00
}
const finishDrag = () => { if ( drag . current ) suppressClick . current = Boolean ( drag . current . moved ); drag . current = null }
const toggleFolder = ( id : string ) => update ( s => ({ ... s , evidence : s.evidence.map ( widget => widget . id === id ? { ... widget , config : { ...( widget . config || {}), open : ! folderIsOpen ( widget ) } } : widget ) }))
return < div className = { `board-viewport tool- ${ tool } ` } ref = { boardRef }
onPointerDown = { e => { if ( tool === 'hand' || e . button === 1 ) { e . preventDefault (); pointerDown ( e ) } }}
onPointerMove = { pointerMove } onPointerUp = { finishDrag } onAuxClick = { e => { if ( e . button === 1 ) e . preventDefault () }}>
< 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 ( c => { const a = byId . get ( c . fromEvidenceId ), b = byId . get ( c . toEvidenceId ); if ( ! a || ! b ) return null ; const p1 = connectionPoint ( a ), p2 = connectionPoint ( b ); return < g key = { c . id }>< path d = { `M ${ p1 . x } ${ p1 . y } C ${ ( p1 . x + p2 . x ) / 2 } ${ p1 . y } , ${ ( p1 . x + p2 . x ) / 2 } ${ p2 . y } , ${ p2 . x } ${ p2 . y } ` }/>< circle cx = { p1 . x } cy = { p1 . y } r = "4" />< circle cx = { p2 . x } cy = { p2 . y } r = "4" /></ g > })}
</ svg >
< svg className = "folder-bands" width = { BOARD_W } height = { BOARD_H }>
{ containmentRelations . map ( relation => { const folder = byId . get ( relation . fromWidgetId ), document = state . documents . find ( candidate => candidate . id === relation . toWidgetId ); if ( ! folder || ! document ) return null ; const open = folderIsOpen ( folder ), position = relationPosition ( state , relation ), origin = { x : folder.x + folder . width / 2 , y : folder.y + 78 }; return < line className = { open ? 'open' : 'closed' } key = { relation . id } x1 = { origin . x } y1 = { origin . y } x2 = { open ? position . x + 87 : origin.x } y2 = { open ? position . y + 72 : origin.y }/> })}
</ svg >
2026-08-14 14:20:41 +02:00
{ 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 definition = exhibitWidget ( ev . type ); const Widget = definition . Component ; return < article key = { ev . id } data-temporal-id = { `widget: ${ ev . id } ` } className = { `evidence-card ${ definition . visualType } ${ selected === ev . id ? 'selected' : '' } ${ linkFrom === ev . id ? 'linking' : '' } ` } style = {{ left : ev.x , top : ev.y , width : ev.width , rotate : ` ${ ( i % 3 - 1 ) * . 45 } deg` }}
2026-08-14 12:43:11 +02:00
onPointerDown = { e => { e . stopPropagation (); 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 () }}
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 ) }}>
2026-08-14 14:20:41 +02:00
< 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 }/>
2026-08-14 12:43:11 +02:00
</ article >})}
2026-08-14 14:20:41 +02:00
{ containmentRelations . map ( relation => { const folder = byId . get ( relation . fromWidgetId ), document = state . documents . find ( candidate => candidate . id === relation . toWidgetId ); if ( ! folder || ! document ) return null ; const open = folderIsOpen ( folder ), target = relationPosition ( state , relation ); const left = open ? target.x : folder.x + folder . width / 2 - 87 , top = open ? target.y : folder.y + 45 ; const definition = documentWidget ( document . fileType ); const Preview = definition . Preview ; const source = document . assetId ? `/api/assets/ ${ encodeURIComponent ( document . assetId ) } ` : '' ; return < article key = { relation . id } data-temporal-id = { `file: ${ relation . id } ` } className = { `source-file-widget ${ open ? 'open' : 'closed' } file-type- ${ document . fileType } ` } style = {{ left , top }}
2026-08-14 12:43:11 +02:00
onPointerDown = { event => { event . stopPropagation (); if ( tool === 'hand' || event . button === 1 ) { event . preventDefault (); pointerDown ( event ) } else if ( open && event . button === 0 ) pointerDown ( event , { kind : 'relation' , id : relation.id }) }}
onPointerMove = { event => { event . stopPropagation (); pointerMove ( event ) }} onPointerUp = { event => { event . stopPropagation (); finishDrag () }} onDoubleClick = {() => open && onOpenSource ( document . id )}>
2026-08-14 14:20:41 +02:00
< header >< span >{ definition . label . toUpperCase ()}</ span >< i >{ String (( relation . sortOrder || 0 ) + 1 ). padStart ( 2 , '0' )}</ i ></ header >
< div className = "source-file-preview" >< Preview document = { document } source = { source }/></ div >
2026-08-14 12:43:11 +02:00
< strong >{ document . title }</ strong >< time >{( document . publishedAt || document . date ) ? . slice ( 0 , 10 ) || 'UNDATED' }</ time >
< div className = "source-file-actions" >< button onClick = {() => onOpenSource ( document . id )}>< BookOpen size = { 12 }/> OPEN </ button >< button onClick = {() => onEditFile ( document . id )}>< Pencil size = { 12 }/> METADATA </ button ></ div >
</ article > })}
</ div >
</ div >
}
function TemporalLinks ({ items , layoutKey } : { items : TemporalItem []; layoutKey : string }) {
const [ lines , setLines ] = useState < { id : string ; x1 : number ; y1 : number ; x2 : number ; y2 : number }[] > ([])
const itemsKey = items . map ( item => ` ${ item . id } : ${ item . date } ` ). join ( '|' )
useLayoutEffect (() => {
let frame = 0
let animateUntil = Date . now () + 500
const measure = () => {
cancelAnimationFrame ( frame )
frame = requestAnimationFrame ( function measureFrame() {
const sources = new Map ( Array . from ( document . querySelectorAll < HTMLElement >( '[data-temporal-id]' )). map ( element => [ element . dataset . temporalId , element ]))
const markers = new Map ( Array . from ( document . querySelectorAll < HTMLElement >( '[data-marker-id]' )). map ( element => [ element . dataset . markerId , element ]))
setLines ( items . flatMap ( item => {
const source = sources . get ( item . sourceTemporalId ), marker = markers . get ( item . id )
if ( ! source || ! marker ) return []
const from = source . getBoundingClientRect (), to = marker . getBoundingClientRect ()
const clip = source . closest < HTMLElement >( '.board-viewport, .doc-list' ) ? . getBoundingClientRect ()
const visibleLeft = Math . max ( from . left , clip ? . left ?? 0 ), visibleRight = Math . min ( from . right , clip ? . right ?? window . innerWidth )
const visibleTop = Math . max ( from . top , clip ? . top ?? 0 ), visibleBottom = Math . min ( from . bottom , clip ? . bottom ?? window . innerHeight )
if ( visibleRight <= visibleLeft || visibleBottom <= visibleTop ) return []
return [{ id : item.id , x1 : visibleLeft + ( visibleRight - visibleLeft ) / 2 , y1 : visibleBottom , x2 : to.left + to . width / 2 , y2 : to.top + to . height / 2 }]
}))
if ( Date . now () < animateUntil ) frame = requestAnimationFrame ( measureFrame )
})
}
measure ()
const observer = new ResizeObserver ( measure )
document . querySelectorAll < HTMLElement >( '.workspace, .timeline, [data-temporal-id], [data-marker-id]' ). forEach ( element => observer . observe ( element ))
const handleResize = () => { animateUntil = Date . now () + 500 ; measure () }
window . addEventListener ( 'resize' , handleResize )
return () => { cancelAnimationFrame ( frame ); observer . disconnect (); window . removeEventListener ( 'resize' , handleResize ) }
}, [ itemsKey , layoutKey ])
return < svg className = "temporal-links" aria-hidden = "true" >{ lines . map ( line => < line key = { line . id } x1 = { line . x1 } y1 = { line . y1 } x2 = { line . x2 } y2 = { line . y2 }/>)}</ svg >
}
function Timeline ({ items , selected , onSelect } : { items : TemporalItem []; selected : string | null ; onSelect : ( item : TemporalItem ) => void }) {
2026-08-14 12:57:06 +02:00
const { startYear , endYear , start , end } = timelineRange ( items . map ( item => item . date ))
2026-08-14 12:43:11 +02:00
const years = Array . from ({ length : endYear - startYear + 1 }, ( _ , index ) => startYear + index )
2026-08-14 12:57:06 +02:00
const position = ( date : string ) => timelinePositionPercent ( date , { start , end })
2026-08-14 12:43:11 +02:00
return < footer className = "timeline" >< div className = "timeline-label" >< small > TEMPORAL INDEX </ small >< b > TIMELINE </ b >< span >{ items . length } DATED ITEMS </ span ></ div >< div className = "timeline-track" >< div className = "axis" />{ years . map ( year => < span className = "year" key = { year } style = {{ left : ` ${ position ( ` ${ year } -01-01` ) } %` }}>{ year }</ span >)}{ items . map (( item , i ) => < button key = { item . id } data-marker-id = { item . id } className = { `marker ${ item . kind } ${ item . evidenceId === 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 ) {
if ( ! value ) return ''
if ( /^\d{4}-\d{2}-\d{2}$/ . test ( value )) return ` ${ value } T00:00`
const date = new Date ( value )
if ( ! Number . isFinite ( date . getTime ())) return ''
const local = new Date ( date . getTime () - date . getTimezoneOffset () * 60 _000 )
return local . toISOString (). slice ( 0 , 16 )
}
function FolderEditor ({ folder , memberIds , documents , canManageContents , onClose , onSave } : { folder : Evidence ; memberIds : string []; documents : CaseDocument []; canManageContents : boolean ; onClose : () => void ; onSave : ( folder : Evidence , 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 )
}
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 >
< div className = "folder-editor-body" >
< small > FOLDER WIDGET </ small >
< label className = "field" >< span > TITLE </ span >< input value = { title } onChange = { event => setTitle ( event . target . value )}/></ label >
< label className = "field" >< span > ANNOTATION </ span >< textarea rows = { 3 } value = { content } onChange = { event => setContent ( event . target . value )}/></ label >
< div className = "folder-members-heading" >< div >< b > CONTAINED SOURCE FILES </ b >< small >{ members . length } SELECTED </ small ></ div >< span >{ canManageContents ? 'LEVEL CONTENT' : 'FIXED CONTENT' }</ span ></ div >
< 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 >
</ 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 >
< div className = "folder-editor-actions" >< button type = "button" onClick = { onClose }> CANCEL </ button >< button className = "primary" type = "submit" > SAVE FOLDER </ button ></ div >
</ div >
</ form ></ div >
}
function FileEditor ({ document , onClose , onSave } : { document : CaseDocument ; onClose : () => void ; onSave : ( document : CaseDocument ) => void }) {
const [ title , setTitle ] = useState ( document . title )
const [ fileType , setFileType ] = useState < SourceFileType >( document . fileType )
const [ publishedTime , setPublishedTime ] = useState ( localDateTime ( document . publishedAt || document . date ))
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 ])) })
}
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 >
< div className = "file-editor-body" >
< small > SOURCE FILE WIDGET </ small >
< div className = "file-editor-grid" >
< 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 >< CalendarClock size = { 13 }/> PUBLISHED TIME · LOCAL </ span >< input type = "datetime-local" value = { publishedTime } onChange = { event => setPublishedTime ( event . target . value )}/></ 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 >
< div className = "metadata-rows" >{ metadata . length === 0 && < p > NO ADDITIONAL METADATA </ p >}{ metadata . map ( row => < div className = "metadata-row" key = { row . id }>< input aria-label = "Metadata key" placeholder = "FIELD" value = { row . key } onChange = { event => setMetadata ( rows => rows . map ( candidate => candidate . id === row . id ? { ... candidate , key : event.target.value } : candidate ))}/>< input aria-label = "Metadata value" placeholder = "VALUE" value = { row . value } onChange = { event => setMetadata ( rows => rows . map ( candidate => candidate . id === row . id ? { ... candidate , value : event.target.value } : candidate ))}/>< button type = "button" aria-label = "Remove metadata field" onClick = {() => setMetadata ( rows => rows . filter ( candidate => candidate . id !== row . id ))}>< Trash2 size = { 13 }/></ button ></ div >)}</ div >
< p className = "folder-editor-note" > This metadata belongs to the source file , not to any folder that contains it .</ p >
< div className = "folder-editor-actions" >< button type = "button" onClick = { onClose }> CANCEL </ button >< button className = "primary" type = "submit" > SAVE METADATA </ button ></ div >
</ div >
</ form ></ div >
}
function DocumentWindow ({ doc , onClose , onExtract , extracted } : { doc : CaseDocument ; onClose : () => 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 drag = useRef < { x : number ; y : number ; px : number ; py : number } | null > ( null )
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 }
e . currentTarget . setPointerCapture ( e . pointerId )
}
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 ></>}
</ section >
}
function DocumentAsset ({ doc } : { doc : CaseDocument }) {
const source = `/api/assets/ ${ encodeURIComponent ( doc . assetId ! ) } `
2026-08-14 14:20:41 +02:00
const Asset = documentWidget ( doc . fileType ). Asset
return < Asset document = { doc } source = { source }/>
2026-08-14 12:43:11 +02:00
}
function Help ({ onClose } : { onClose : () => void }) { return < div className = "modal-shade" >< section className = "window help" >< header >< CircleHelp size = { 16 }/>< b > Field Manual </ b >< span />< button onClick = { onClose }>< X size = { 14 }/></ button ></ header >< div >< small > GU - NET QUICK START </ small >< h2 > Reconstruct what happened .</ h2 >< ol >< li > Open a case document .</ li >< li > Extract the highlighted clue .</ li >< li > Drag evidence into meaningful groups .</ li >< li > Select a card , choose Connect , then select its target .</ li >< li > Use dated markers to move through the case .</ li ></ ol >< p > The system will not announce your conclusion . Make it visible .</ p >< button className = "primary" onClick = { onClose }> BEGIN INVESTIGATION </ button ></ div ></ section ></ div > }