2026-08-14 12:43:11 +02:00
import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
2026-08-14 14:44:58 +02:00
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'
2026-08-14 15:40:54 +02:00
import type { BriefConcept , CaseDocument , CaseState , Evidence , LevelBrief , OrganizationKind , PartyKind , SourceFileType , TimelineRange , WidgetRelation } from './types'
2026-08-14 15:45:03 +02:00
import { clampBoardZoom , containedIds , dateValue , folderIsOpen , moveBoardPoint , nextOpenBoardPosition , normalizeCase , panViewport , relationPosition , timelinePositionPercent , timelineRange , zoomFromPinch , 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 16:18:13 +02:00
function briefAcknowledgementKey ( levelId : string ) { return `gupi-osint-board:brief-acknowledged: ${ levelId } ` }
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 )
2026-08-14 14:31:52 +02:00
const [ editingEventId , setEditingEventId ] = useState < string | null >( null )
2026-08-14 14:44:58 +02:00
const [ editingPartyId , setEditingPartyId ] = useState < string | null >( null )
const [ briefOpen , setBriefOpen ] = useState ( false )
const [ editingBrief , setEditingBrief ] = useState ( false )
2026-08-14 15:40:54 +02:00
const [ editingTimeline , setEditingTimeline ] = useState ( false )
2026-08-14 16:18:13 +02:00
const [ recentlyCreatedExhibitId , setRecentlyCreatedExhibitId ] = useState < string | null >( null )
2026-08-14 12:43:11 +02:00
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' )
2026-08-14 16:18:13 +02:00
const data = normalizeCase ( await response . json ())
setCaseState ( data )
if ( data . brief . concepts . some ( concept => ! concept . resolvedPartyExhibitId ) && ! localStorage . getItem ( briefAcknowledgementKey ( data . id ))) setBriefOpen ( true )
setStatus ( 'EVIDENCE INTEGRITY: PROBABLY OK' )
2026-08-14 12:43:11 +02:00
})
. 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 )
}, [])
2026-08-14 16:18:13 +02:00
useEffect (() => {
if ( ! recentlyCreatedExhibitId ) return
const timer = window . setTimeout (() => setRecentlyCreatedExhibitId ( null ), 1400 )
return () => window . clearTimeout ( timer )
}, [ recentlyCreatedExhibitId ])
2026-08-14 12:43:11 +02:00
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 }] }))
2026-08-14 16:18:13 +02:00
setOpenDoc ( null ); setSelected ( ev . id ); setRecentlyCreatedExhibitId ( ev . id ); setStatus ( 'EVIDENCE EXTRACTED · PROVENANCE ATTACHED' )
2026-08-14 12:43:11 +02:00
}
const addNote = () => {
const content = window . prompt ( 'What do you think this evidence means?' ) ? . trim ()
if ( ! content || ! caseState ) return
const { viewport } = caseState
2026-08-14 15:02:00 +02:00
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 }
2026-08-14 16:18:13 +02:00
update ( s => ({ ... s , evidence : [... s . evidence , note ] })); setSelected ( note . id ); setRecentlyCreatedExhibitId ( note . id )
2026-08-14 12:43:11 +02:00
}
2026-08-14 14:31:52 +02:00
const addEvent = () => {
if ( ! caseState ) return
const { viewport } = caseState
2026-08-14 15:02:00 +02:00
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 })
2026-08-14 14:31:52 +02:00
const event : Evidence = { id : uid ( 'event' ), type : 'event' , title : 'UNTITLED EVENT' , content : 'Describe what happened.' ,
2026-08-14 15:02:00 +02:00
eventDate : new Date (). toISOString (), supportingEvidenceIds : [], ... position , width : 270 }
2026-08-14 14:31:52 +02:00
update ( state => ({ ... state , evidence : [... state . evidence , event ] }))
2026-08-14 16:18:13 +02:00
setSelected ( event . id ); setRecentlyCreatedExhibitId ( event . id ); setEditingEventId ( event . id )
2026-08-14 14:31:52 +02:00
}
2026-08-14 14:44:58 +02:00
const classifyConcept = ( conceptId : string , partyKind : PartyKind ) => {
if ( ! caseState ) return
const concept = caseState . brief . concepts . find ( item => item . id === conceptId )
if ( ! concept ) return
const existingId = concept . resolvedPartyExhibitId
const partyId = existingId || uid ( 'party' )
const { viewport } = caseState
2026-08-14 15:02:00 +02:00
const existingParty = caseState . evidence . find ( item => item . id === existingId )
const position = existingParty ? { x : existingParty.x , y : existingParty.y } : nextOpenBoardPosition ( caseState . evidence , {
x : Math.max ( 100 , ( 670 - viewport . x ) / viewport . zoom ), y : Math.max ( 100 , ( 310 - viewport . y ) / viewport . zoom ),
}, { width : 280 })
2026-08-14 14:44:58 +02:00
const party : Evidence = { id : partyId , type : 'party' , partyKind , organizationKind : partyKind === 'organization' ? 'business' : undefined ,
title : concept.label , content : concept.context , aliases : [], relatedEvidenceIds : [],
2026-08-14 15:02:00 +02:00
... position , width : 280 }
2026-08-14 14:44:58 +02:00
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 ],
brief : { ... state . brief , concepts : state.brief.concepts.map ( item => item . id === conceptId ? { ... item , resolvedPartyExhibitId : partyId } : item ) },
}))
2026-08-14 16:18:13 +02:00
setSelected ( partyId )
setRecentlyCreatedExhibitId ( partyId )
setStatus ( ` ${ partyKind === 'person' ? 'PERSON' : 'ORGANIZATION' } DOSSIER CREATED` )
}
const closeBrief = () => {
if ( caseState ) localStorage . setItem ( briefAcknowledgementKey ( caseState . id ), new Date (). toISOString ())
setBriefOpen ( false )
2026-08-14 14:44:58 +02:00
}
2026-08-14 12:43:11 +02:00
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 ]))
2026-08-14 16:18:13 +02:00
const unresolvedConceptCount = caseState . brief . concepts . filter ( concept => ! concept . resolvedPartyExhibitId ). length
2026-08-14 12:43:11 +02:00
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 ))
2026-08-14 14:31:52 +02:00
const storyEvents = caseState . evidence . filter ( item => item . type === 'event' && item . eventDate ). sort (( a , b ) => dateValue ( a . eventDate ! ) - dateValue ( b . eventDate ! ))
2026-08-14 12:43:11 +02:00
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 16:18:13 +02:00
< button onClick = {() => setDocsOpen ( v => ! v )}> FILE </ button >< button className = { briefOpen ? 'active' : '' } aria-label = "Case brief" onClick = {() => briefOpen ? closeBrief () : setBriefOpen ( true )}> CASE BRIEF { unresolvedConceptCount > 0 && < b className = "brief-count" >{ unresolvedConceptCount }</ b >}</ button >< button onClick = { addNote }> EVIDENCE </ button >< button onClick = {() => update ( s => ({ ... s , viewport : { x : 0 , y : 28 , zoom : .7 } }))}> VIEW </ button >< button onClick = {() => setEditingTimeline ( true )}> 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 >
2026-08-14 16:18:13 +02:00
< Board state = { caseState } selected = { selected } linkFrom = { linkFrom } recentlyCreatedExhibitId = { recentlyCreatedExhibitId } tool = { boardTool } boardRef = { boardRef } update = { update } onCardClick = { handleCardClick } onOpenSource = { id => setOpenDoc ( caseState . documents . find ( d => d . id === id ) || null )} onEditFolder = { setEditingFolderId } onEditFile = { setEditingFileId } onEditEvent = { setEditingEventId } onEditParty = { setEditingPartyId } />
2026-08-14 14:44:58 +02:00
{ briefOpen && < BriefPanel
brief = { caseState . brief }
parties = { caseState . evidence . filter ( item => item . type === 'party' )}
2026-08-14 16:18:13 +02:00
recentlyCreatedExhibitId = { recentlyCreatedExhibitId }
2026-08-14 14:44:58 +02:00
canEdit = { requestedEditMode && Boolean ( caseState . editingAllowed )}
2026-08-14 16:18:13 +02:00
onClose = { closeBrief }
2026-08-14 14:44:58 +02:00
onEdit = {() => setEditingBrief ( true )}
onClassify = { classifyConcept }
onLocate = { focusEvidence }
2026-08-14 16:18:13 +02:00
onEditParty = { setEditingPartyId }
2026-08-14 14:44:58 +02:00
/>}
2026-08-14 14:31:52 +02:00
{ storyEvents . length > 0 && < aside className = "story-strip" >< small > RECONSTRUCTED STORY </ small >{ storyEvents . map (( event , index ) => < button key = { event . id } className = { selected === event . id ? 'selected' : '' } onClick = {() => focusEvidence ( event . id )}>< time >{ event . eventDate ! . slice ( 0 , 10 )}</ time >< b >{ index + 1 }. { event . title }</ b >< span >{ event . content }</ span ></ button >)}</ aside >}
2026-08-14 12:43:11 +02:00
{ ! 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 >
2026-08-14 14:31:52 +02:00
< button onClick = { addEvent }>< CalendarClock size = { 17 }/> NEW EVENT </ button >
2026-08-14 12:43:11 +02:00
< 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 ( '|' ) } ` }/>
2026-08-14 15:40:54 +02:00
< 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 ) }}/>
2026-08-14 12:43:11 +02:00
{ 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' ) }}/>}
2026-08-14 14:31:52 +02:00
{ editingEventId && < EventEditor
key = { editingEventId }
event = { caseState . evidence . find ( item => item . id === editingEventId ) ! }
evidence = { caseState . evidence }
documents = { caseState . documents }
onClose = {() => setEditingEventId ( null )}
onSave = { event => { update ( state => ({ ... state , evidence : state.evidence.map ( item => item . id === event . id ? event : item ) })); setEditingEventId ( null ); setStatus ( 'EVENT NARRATIVE UPDATED' ) }}
/>}
2026-08-14 14:44:58 +02:00
{ editingPartyId && < PartyEditor
key = { editingPartyId }
party = { caseState . evidence . find ( item => item . id === editingPartyId ) ! }
evidence = { caseState . evidence }
documents = { caseState . documents }
onClose = {() => setEditingPartyId ( null )}
onSave = { party => {
update ( state => ({ ... state , evidence : state.evidence.map ( item => item . id === party . id ? party : item ) }))
setEditingPartyId ( null )
setStatus ( 'PARTY DOSSIER UPDATED' )
}}
/>}
{ editingBrief && < BriefEditor
brief = { caseState . brief }
onClose = {() => setEditingBrief ( false )}
onSave = { brief => {
update ( state => ({ ... state , brief }))
setEditingBrief ( false )
setStatus ( 'LEVEL BRIEF UPDATED' )
}}
/>}
2026-08-14 15:40:54 +02:00
{ editingTimeline && < TimelineRangeEditor
range = { caseState . timelineRange }
dates = { temporalItems . map ( item => item . date )}
onClose = {() => setEditingTimeline ( false )}
onSave = { timelineRange => {
update ( state => ({ ... state , timelineRange }))
setEditingTimeline ( false )
setStatus ( timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC' )
}}
/>}
2026-08-14 12:43:11 +02:00
{ 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 >
}
2026-08-14 16:18:13 +02:00
function Board ({ state , selected , linkFrom , recentlyCreatedExhibitId , tool , boardRef , update , onCardClick , onOpenSource , onEditFolder , onEditFile , onEditEvent , onEditParty } : { state : CaseState ; selected : string | null ; linkFrom : string | null ; recentlyCreatedExhibitId : string | null ; tool : 'move' | 'hand' ; boardRef : React.RefObject < HTMLDivElement | null >; update : ( fn : ( s : CaseState ) => CaseState ) => void ; onCardClick : ( id : string ) => void ; onOpenSource : ( id : string ) => void ; onEditFolder : ( id : string ) => void ; onEditFile : ( id : string ) => void ; onEditEvent : ( id : string ) => void ; onEditParty : ( id : string ) => void }) {
2026-08-14 12:43:11 +02:00
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 )
2026-08-14 15:45:03 +02:00
const touchPoints = useRef ( new Map < number , { x : number ; y : number }>())
const pinchDistance = useRef < number | null >( null )
2026-08-14 12:43:11 +02:00
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
2026-08-14 15:45:03 +02:00
const handleWheelZoom = ( event : WheelEvent ) => {
2026-08-14 12:43:11 +02:00
event . preventDefault ()
event . stopPropagation ()
2026-08-14 15:45:03 +02:00
if ( event . ctrlKey || event . metaKey || event . deltaY === 0 ) return
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
}
2026-08-14 15:45:03 +02:00
board . addEventListener ( 'wheel' , handleWheelZoom , { passive : false })
return () => board . removeEventListener ( 'wheel' , handleWheelZoom )
2026-08-14 12:43:11 +02:00
}, [ boardRef , update ])
const pointerDown = ( event : React.PointerEvent , target ?: { kind : 'widget' | 'relation' ; id : string }) => {
if (( event . target as HTMLElement ). closest ( 'button' )) return
2026-08-14 15:45:03 +02:00
if ( event . pointerType === 'touch' ) {
touchPoints . current . set ( event . pointerId , { x : event.clientX , y : event.clientY })
if ( touchPoints . current . size >= 2 ) {
const points = [... touchPoints . current . values ()]
pinchDistance . current = Math . hypot ( points [ 1 ]. x - points [ 0 ]. x , points [ 1 ]. y - points [ 0 ]. y )
drag . current = null
event . preventDefault ()
return
}
}
2026-08-14 12:43:11 +02:00
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 }
2026-08-14 15:45:03 +02:00
try { event . currentTarget . setPointerCapture ( event . pointerId ) } catch { /* Synthetic test events have no active native pointer. */ }
2026-08-14 12:43:11 +02:00
}
const pointerMove = ( event : React.PointerEvent ) => {
2026-08-14 15:45:03 +02:00
if ( event . pointerType === 'touch' && touchPoints . current . has ( event . pointerId )) {
touchPoints . current . set ( event . pointerId , { x : event.clientX , y : event.clientY })
if ( touchPoints . current . size >= 2 ) {
const points = [... touchPoints . current . values ()]
const distance = Math . hypot ( points [ 1 ]. x - points [ 0 ]. x , points [ 1 ]. y - points [ 0 ]. y )
const previous = pinchDistance . current
if ( previous ) update ( s => ({ ... s , viewport : { ... s . viewport , zoom : zoomFromPinch ( s . viewport . zoom , previous , distance ) } }))
pinchDistance . current = distance
suppressClick . current = true
event . preventDefault ()
return
}
}
2026-08-14 12:43:11 +02:00
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
}
2026-08-14 15:45:03 +02:00
const finishDrag = ( event : React.PointerEvent ) => {
if ( event . pointerType === 'touch' ) {
touchPoints . current . delete ( event . pointerId )
if ( touchPoints . current . size < 2 ) pinchDistance . current = null
}
if ( drag . current ) suppressClick . current = Boolean ( drag . current . moved )
drag . current = null
}
2026-08-14 12:43:11 +02:00
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 }
2026-08-14 15:45:03 +02:00
onPointerDown = { e => { if ( e . pointerType === 'touch' || tool === 'hand' || e . button === 1 ) { e . preventDefault (); pointerDown ( e ) } }}
onPointerMove = { pointerMove } onPointerUp = { finishDrag } onPointerCancel = { finishDrag } onAuxClick = { e => { if ( e . button === 1 ) e . preventDefault () }}>
2026-08-14 12:43:11 +02:00
< 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 >
2026-08-14 14:31:52 +02:00
< svg className = "event-support-lines" width = { BOARD_W } height = { BOARD_H }>
{ state . evidence . filter ( event => event . type === 'event' ). flatMap ( event => ( event . supportingEvidenceIds || []). flatMap ( evidenceId => {
const evidence = byId . get ( evidenceId )
let target = evidence ? connectionPoint ( evidence ) : undefined
if ( ! target ) {
const relation = containmentRelations . find ( item => item . toWidgetId === evidenceId )
const folder = relation ? byId . get ( relation . fromWidgetId ) : undefined
if ( relation && folder ) target = folderIsOpen ( folder ) ? { x : relationPosition ( state , relation ). x + 87 , y : relationPosition ( state , relation ). y + 72 } : connectionPoint ( folder )
}
if ( ! target ) return []
const origin = connectionPoint ( event )
return [< line key = { ` ${ event . id } : ${ evidenceId } ` } x1 = { origin . x } y1 = { origin . y } x2 = { target . x } y2 = { target . y }/>]
}))}
</ svg >
2026-08-14 14:44:58 +02:00
< 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 }/>]
}))}
</ svg >
2026-08-14 12:43:11 +02:00
< 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 16:18:13 +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' : '' } ${ recentlyCreatedExhibitId === ev . id ? 'arriving' : '' } ` } 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 }) }}
2026-08-14 15:45:03 +02:00
onPointerMove = { e => { e . stopPropagation (); pointerMove ( e ) }} onPointerUp = { e => { e . stopPropagation (); finishDrag ( e ) }} onPointerCancel = { e => { e . stopPropagation (); finishDrag ( e ) }}
2026-08-14 12:43:11 +02:00
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 >
2026-08-14 14:44:58 +02:00
< Widget exhibit = { ev } documents = { containedDocuments } onOpenSource = { onOpenSource } onToggleFolder = { toggleFolder } onEditFolder = { onEditFolder } onEditEvent = { onEditEvent } onEditParty = { onEditParty }/>
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 }) }}
2026-08-14 15:45:03 +02:00
onPointerMove = { event => { event . stopPropagation (); pointerMove ( event ) }} onPointerUp = { event => { event . stopPropagation (); finishDrag ( event ) }} onPointerCancel = { event => { event . stopPropagation (); finishDrag ( event ) }} 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 >
}
2026-08-14 15:40:54 +02:00
function Timeline ({ items , range , selected , onSelect , onEdit } : { items : TemporalItem []; range? : TimelineRange | null ; selected : string | null ; onSelect : ( item : TemporalItem ) => void ; onEdit : () => void }) {
const { startYear , endYear , start , end } = timelineRange ( items . map ( item => item . date ), undefined , range || undefined )
2026-08-14 12:57:06 +02:00
const position = ( date : string ) => timelinePositionPercent ( date , { start , end })
2026-08-14 15:40:54 +02:00
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 ? '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 >
2026-08-14 12:43:11 +02:00
}
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 )
}
2026-08-14 15:40:54 +02:00
function TimelineRangeEditor ({ range , dates , onClose , onSave } : { range? : TimelineRange | null ; dates : string []; onClose : () => void ; onSave : ( range : TimelineRange | null ) => void }) {
const dated = dates . map ( date => date . slice ( 0 , 10 )). filter ( Boolean ). sort ()
const [ start , setStart ] = useState ( range ? . start || dated [ 0 ] || '' )
const [ end , setEnd ] = useState ( range ? . end || dated [ dated . length - 1 ] || '' )
const valid = Boolean ( start && end && end > start )
return < div className = "modal-shade" >< form className = "window timeline-editor" onSubmit = { event => { event . preventDefault (); if ( valid ) onSave ({ start , end }) }}>
< header >< CalendarClock size = { 16 }/>< b > Adjust timeline range </ b >< span />< button type = "button" aria-label = "Close timeline editor" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div >< small > TEMPORAL VIEWPORT · BOARD SETTING </ small >< p > Choose the interval shown across the full timeline . Dated evidence outside it is pinned to the nearest edge .</ p >
< div className = "timeline-range-fields" >< label className = "field" >< span > START DATE </ span >< input aria-label = "Timeline start date" type = "date" required value = { start } onChange = { event => setStart ( event . target . value )}/></ label >< label className = "field" >< span > END DATE </ span >< input aria-label = "Timeline end date" type = "date" required value = { end } min = { start } onChange = { event => setEnd ( event . target . value )}/></ label ></ div >
< div className = "folder-editor-actions" >< button type = "button" onClick = {() => onSave ( null )}> USE AUTOMATIC RANGE </ button >< button className = "primary" type = "submit" disabled = { ! valid }> APPLY RANGE </ button ></ div >
</ div >
</ form ></ div >
}
2026-08-14 16:18:13 +02:00
function BriefPanel ({ brief , parties , recentlyCreatedExhibitId , canEdit , onClose , onEdit , onClassify , onLocate , onEditParty } : { brief : LevelBrief ; parties : Evidence []; recentlyCreatedExhibitId : string | null ; canEdit : boolean ; onClose : () => void ; onEdit : () => void ; onClassify : ( id : string , kind : PartyKind ) => void ; onLocate : ( id : string ) => void ; onEditParty : ( id : string ) => void }) {
2026-08-14 14:44:58 +02:00
const partyById = new Map ( parties . map ( party => [ party . id , party ]))
2026-08-14 16:18:13 +02:00
const unresolved = brief . concepts . filter ( concept => ! concept . resolvedPartyExhibitId ). length
return < aside className = "brief-panel" >< header >< div >< small > LEVEL BRIEF · { unresolved } UNRESOLVED </ small >< b > CONCEPT CLASSIFICATION </ b ></ div >< button aria-label = "Close brief" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
2026-08-14 14:44:58 +02:00
< p >{ brief . body || 'No brief has been authored yet.' }</ p >
2026-08-14 16:18:13 +02:00
< div className = "brief-concepts" >{ brief . concepts . map ( concept => { const resolved = concept . resolvedPartyExhibitId ? partyById . get ( concept . resolvedPartyExhibitId ) : undefined ; return < section className = { ` ${ resolved ? 'resolved' : '' } ${ resolved ? . id === recentlyCreatedExhibitId ? 'just-resolved' : '' } ` } key = { concept . id }>< div >< b >{ concept . label }</ b >< span >{ concept . context }</ span ></ div >{ resolved ? < div className = "resolved-actions" >< span >{ resolved . partyKind === 'person' ? < UserRound size = { 14 }/> : < Building2 size = { 14 }/>} { resolved . partyKind ? . toUpperCase ()}</ span >< button onClick = {() => onLocate ( resolved . id )}> LOCATE </ button >< button onClick = {() => onEditParty ( resolved . id )}> EDIT DOSSIER </ button ></ div > : < div className = "classify-actions" >< button onClick = {() => onClassify ( concept . id , 'person' )}>< UserRound size = { 14 }/> PERSON </ button >< button onClick = {() => onClassify ( concept . id , 'organization' )}>< Building2 size = { 14 }/> ORGANIZATION </ button ></ div >}</ section > })}</ div >
2026-08-14 14:44:58 +02:00
{ canEdit && < button className = "edit-brief" onClick = { onEdit }>< Pencil size = { 13 }/> EDIT BRIEF & CONCEPTS </ button >}
2026-08-14 16:18:13 +02:00
< button className = "dismiss-brief" onClick = { onClose }>{ unresolved === brief . concepts . length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD' }</ button >
2026-08-14 14:44:58 +02:00
</ aside >
}
function BriefEditor ({ brief , onClose , onSave } : { brief : LevelBrief ; onClose : () => void ; onSave : ( brief : LevelBrief ) => void }) {
const [ body , setBody ] = useState ( brief . body )
const [ concepts , setConcepts ] = useState < BriefConcept [] >( brief . concepts )
const addConcept = () => setConcepts ( current => [... current , { id : uid ( 'concept' ), label : '' , context : '' , expectedPartyKind : 'person' }])
return < div className = "modal-shade" >< form className = "window folder-editor brief-editor" onSubmit = { submit => { submit . preventDefault (); onSave ({ body : body.trim (), concepts : concepts.filter ( item => item . label . trim ()). map ( item => ({ ... item , label : item.label.trim (), context : item.context.trim () })) }) }}>
< header >< BookOpen size = { 16 }/>< b > Edit level brief </ b >< span />< button type = "button" aria-label = "Close brief editor" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div className = "folder-editor-body" >< small > AUTHORING · PLAYER CONCEPTS </ small >
< label className = "field" >< span > BRIEF </ span >< textarea aria-label = "Level brief" rows = { 5 } value = { body } onChange = { event => setBody ( event . target . value )}/></ label >
< div className = "metadata-heading" >< div >< b > CONCEPTS TO CLASSIFY </ b >< small > EXPECTED TYPE IS HIDDEN FROM PLAYERS </ small ></ div >< button type = "button" onClick = { addConcept }>< Plus size = { 13 }/> ADD CONCEPT </ button ></ div >
< div className = "concept-editor-list" >{ concepts . map ( concept => < div className = "concept-editor-row" key = { concept . id }>< input aria-label = "Concept name" placeholder = "REAL NAME" value = { concept . label } onChange = { event => setConcepts ( items => items . map ( item => item . id === concept . id ? { ... item , label : event.target.value } : item ))}/>< input aria-label = "Concept context" placeholder = "CONTEXT IN THE BRIEF" value = { concept . context } onChange = { event => setConcepts ( items => items . map ( item => item . id === concept . id ? { ... item , context : event.target.value } : item ))}/>< select aria-label = "Expected party type" value = { concept . expectedPartyKind || 'person' } onChange = { event => setConcepts ( items => items . map ( item => item . id === concept . id ? { ... item , expectedPartyKind : event.target.value as PartyKind } : item ))}>< option value = "person" > Person </ option >< option value = "organization" > Organization </ option ></ select >< button type = "button" aria-label = "Remove concept" onClick = {() => setConcepts ( items => items . filter ( item => item . id !== concept . id ))}>< Trash2 size = { 13 }/></ button ></ div >)}</ div >
< p className = "folder-editor-note" > Concepts are names in the brief , not board exhibits . A player turns each concept into a Party exhibit by classifying it .</ p >
< div className = "folder-editor-actions" >< button type = "button" onClick = { onClose }> CANCEL </ button >< button className = "primary" type = "submit" > SAVE BRIEF </ button ></ div >
</ div >
</ form ></ div >
}
function PartyEditor ({ party , evidence , documents , onClose , onSave } : { party : Evidence ; evidence : Evidence []; documents : CaseDocument []; onClose : () => void ; onSave : ( party : Evidence ) => void }) {
const [ name , setName ] = useState ( party . title )
const [ summary , setSummary ] = useState ( party . content )
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 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 , title : name.trim () || party . title , content : summary.trim (), organizationKind : party.partyKind === 'organization' ? organizationKind : undefined , aliases : aliases.split ( '\n' ). map ( item => item . trim ()). filter ( Boolean ), relatedEvidenceIds : related }) }}>
< header >{ party . partyKind === 'person' ? < UserRound size = { 16 }/> : < Building2 size = { 16 }/>}< b > Edit { party . 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 · { party . partyKind ? . toUpperCase ()}</ small >
< label className = "field" >< span > DISPLAY NAME </ span >< input aria-label = "Party name" value = { name } onChange = { event => setName ( event . target . value )}/></ label >
< label className = "field" >< span > DOSSIER SUMMARY </ span >< textarea aria-label = "Party summary" rows = { 3 } value = { summary } onChange = { event => setSummary ( event . target . value )}/></ label >
{ party . partyKind === 'organization' && < label className = "field" >< span > ORGANIZATION TYPE </ span >< select aria-label = "Organization type" value = { organizationKind } onChange = { event => setOrganizationKind ( event . target . value as OrganizationKind )}>< option value = "business" > Business </ option >< option value = "public_body" > Public body </ option >< option value = "association" > Association </ option >< option value = "informal_group" > Informal group </ option >< option value = "other" > Other </ option ></ select ></ label >}
< label className = "field" >< span > ALIASES · ONE PER LINE </ span >< textarea aria-label = "Party aliases" rows = { 2 } value = { aliases } onChange = { event => setAliases ( event . target . value )}/></ label >
< div className = "folder-members-heading" >< div >< b > ASSOCIATED EVIDENCE </ b >< small >{ related . length } LINKED </ small ></ div >< span > PARTY DOSSIER </ span ></ div >
< div className = "folder-members" >{ candidates . map ( candidate => { const included = related . includes ( candidate . id ); return < div className = { `folder-member ${ included ? 'included' : '' } ` } key = { candidate . id }>< label >< input type = "checkbox" checked = { included } onChange = {() => toggle ( candidate . id )}/>< Network size = { 16 }/>< span >< b >{ candidate . title }</ b >< small >{ candidate . kind }</ small ></ span ></ label >< span className = "folder-member-date" >{ included ? 'ASSOCIATED' : 'NOT LINKED' }</ span ></ div > })}</ div >
< div className = "folder-editor-actions" >< button type = "button" onClick = { onClose }> CANCEL </ button >< button className = "primary" type = "submit" > SAVE DOSSIER </ button ></ div >
</ div >
</ form ></ div >
}
2026-08-14 12:43:11 +02:00
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 >
}
2026-08-14 14:31:52 +02:00
function EventEditor ({ event , evidence , documents , onClose , onSave } : { event : Evidence ; evidence : Evidence []; documents : CaseDocument []; onClose : () => void ; onSave : ( event : Evidence ) => void }) {
const [ title , setTitle ] = useState ( event . title )
const [ narrative , setNarrative ] = useState ( event . content )
const [ occurredAt , setOccurredAt ] = useState ( localDateTime ( event . eventDate ))
const [ supports , setSupports ] = useState ( event . supportingEvidenceIds || [])
const candidates = [
... evidence . filter ( item => item . id !== event . id && item . type !== 'event' ). map ( item => ({ id : item.id , title : item.title , kind : item.type.toUpperCase () })),
... documents . map ( document => ({ id : document.id , title : document.title , kind : document.fileType.replaceAll ( '_' , ' ' ). toUpperCase () })),
]
const toggle = ( id : string ) => setSupports ( current => current . includes ( id ) ? current . filter ( item => item !== id ) : [... current , id ])
const submit = ( submitEvent : React.FormEvent ) => {
submitEvent . preventDefault ()
const eventDate = occurredAt ? new Date ( occurredAt ). toISOString () : new Date (). toISOString ()
onSave ({ ... event , title : title.trim () || 'UNTITLED EVENT' , content : narrative.trim () || 'Something happened.' , eventDate , supportingEvidenceIds : supports })
}
return < div className = "modal-shade" >< form className = "window folder-editor event-editor" onSubmit = { submit }>
< header >< CalendarClock size = { 16 }/>< b > Edit reconstructed event </ b >< span />< button type = "button" aria-label = "Close event editor" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div className = "folder-editor-body" >
< small > EVENT EXHIBIT · THIS HAPPENED </ small >
< label className = "field" >< span > TITLE </ span >< input aria-label = "Event title" value = { title } onChange = { change => setTitle ( change . target . value )}/></ label >
< label className = "field" >< span > NARRATIVE </ span >< textarea aria-label = "Event narrative" rows = { 4 } value = { narrative } onChange = { change => setNarrative ( change . target . value )}/></ label >
< label className = "field" >< span >< CalendarClock size = { 13 }/> OCCURRED AT · LOCAL </ span >< input aria-label = "Occurred at" type = "datetime-local" required value = { occurredAt } onChange = { change => setOccurredAt ( change . target . value )}/></ label >
< div className = "folder-members-heading" >< div >< b > SUPPORTING EXHIBITS </ b >< small >{ supports . length } CITED </ small ></ div >< span > NORMALIZED EVENT EVIDENCE </ span ></ div >
< div className = "folder-members event-support-list" >
{ candidates . length === 0 && < p > ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE .</ p >}
{ candidates . map ( candidate => { const included = supports . includes ( candidate . id ); return < div className = { `folder-member ${ included ? 'included' : '' } ` } key = { candidate . id }>< label >< input type = "checkbox" checked = { included } onChange = {() => toggle ( candidate . id )}/>< Network size = { 16 }/>< span >< b >{ candidate . title }</ b >< small >{ candidate . kind }</ small ></ span ></ label >< span className = "folder-member-date" >{ included ? 'SUPPORTS EVENT' : 'NOT CITED' }</ span ></ div > })}
</ div >
< p className = "folder-editor-note" > An event is an investigator assertion . Its occurrence time is separate from the publication time of the documents supporting it .</ p >
< div className = "folder-editor-actions" >< button type = "button" onClick = { onClose }> CANCEL </ button >< button className = "primary" type = "submit" > SAVE EVENT </ button ></ div >
</ div >
</ form ></ div >
}
2026-08-14 12:43:11 +02:00
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 > }