2026-08-14 12:43:11 +02:00
import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
2026-08-22 17:02:30 +02:00
import { BookOpen , Building2 , CalendarClock , ChevronRight , CircleHelp , ClipboardCheck , FileText , FolderOpen , Hand , Image as ImageIcon , Link2 , Minus , MousePointer2 , Network , NotebookPen , Pencil , Plus , RotateCcw , Search , Trash2 , Upload , UserRound , X , ZoomIn , ZoomOut } from 'lucide-react'
import type { BriefConcept , CaseDocument , CaseReport , CaseReportSubmissionInput , CaseState , Connection , DocumentSemanticAnalysis , EventExhibit , Evidence , EvidenceMatchRuleDefinition , Exhibit , ExhibitRelation , FolderExhibit , LevelBrief , LevelFlag , LevelGoal , OrganizationKind , PartyExhibit , PartyKind , SourceFileType , TimelineRange , TimelineView , UploadedCaseDocument } from './types'
2026-08-18 15:37:46 +02:00
import { AdminPanel } from './admin'
2026-08-17 09:24:53 +02:00
import { clampBoardZoom , containedIds , dateValue , discardExhibit , folderIsOpen , moveBoardPoint , nextOpenBoardPosition , normalizeCase , panViewport , projectThreadTag , threadCurve , threadTagLateralLimit , threadTagPlacement , timelinePositionPercent , timelineRange , zoomFromPinch , zoomFromWheel , zoomViewportAt } from './boardDomain'
import { documentExhibits , documentWidget , evidenceExhibits , exhibitWidget , type ExhibitWidgetContext , type WidgetCommand } from './exhibitRegistry'
2026-08-22 16:02:10 +02:00
import type { PlaythroughState } from './narrative'
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-22 14:53:23 +02:00
function screenshotFile ( file : File , index : number ) {
const extension = file . type === 'image/jpeg' ? 'jpg' : file . type === 'image/webp' ? 'webp' : 'png'
const timestamp = new Date (). toISOString (). replace ( 'T' , ' ' ). replace ( /:/g , '.' ). slice ( 0 , 19 )
return new File ([ file ], `Screenshot ${ timestamp }${ index ? ` ${ index + 1 } ` : '' } . ${ extension } ` , { type : file . type || 'image/png' , lastModified : Date.now () })
}
2026-08-14 16:18:13 +02:00
function briefAcknowledgementKey ( levelId : string ) { return `gupi-osint-board:brief-acknowledged: ${ levelId } ` }
2026-08-14 17:57:00 +02:00
function documentSearchText ( document : CaseDocument ) {
2026-08-22 17:02:30 +02:00
return [ document . title , document . fileType , document . publishedAt , document . capturedAt , document . sourceCitation , document . sourceUri , document . fileName , document . mimeType ,
2026-08-14 17:57:00 +02:00
... document . body , ... document . regions . flatMap ( region => [ region . label , region . excerpt , region . date ]),
... Object . entries ( document . metadata ). flatMap (([ key , value ]) => [ key , value ])]. filter ( Boolean ). join ( '\n' ). toLocaleLowerCase ()
}
2026-08-17 09:24:53 +02:00
function connectionPoint ( item : Exhibit ) {
return exhibitWidget ( item . type ). connectionPorts ( item )[ 0 ]
2026-08-14 12:43:11 +02:00
}
2026-08-17 09:24:53 +02:00
type TemporalItem = { id : string ; sourceTemporalId : string ; date : string ; label : string ; kind : 'document' | 'widget' ; exhibitId : string }
const placement = ( x : number , y : number , width : number , height : number ) => ({ x , y , width , height , rotation : 0 , zIndex : 1 , hidden : false })
function replaceDirectedRelations ( relations : ExhibitRelation [], type : 'supports' | 'concerns' , fromExhibitId : string , targets : string []) {
const retained = relations . filter ( relation => relation . type !== type || relation . fromExhibitId !== fromExhibitId )
return [... retained , ... targets . map (( toExhibitId , sortOrder ) : ExhibitRelation => ({
id : relations.find ( relation => relation . type === type && relation . fromExhibitId === fromExhibitId && relation . toExhibitId === toExhibitId ) ? . id || uid ( type ),
type , fromExhibitId , toExhibitId , sortOrder ,
}))]
}
2026-08-14 12:43:11 +02:00
export function App() {
const [ caseState , setCaseState ] = useState < CaseState | null >( null )
2026-08-15 09:29:08 +02:00
const [ isAdmin , setIsAdmin ] = useState ( false )
2026-08-14 12:43:11 +02:00
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 )
2026-08-14 17:57:00 +02:00
const [ docsOpen , setDocsOpen ] = useState ( false )
const [ documentQuery , setDocumentQuery ] = useState ( '' )
2026-08-14 12:43:11 +02:00
const [ helpOpen , setHelpOpen ] = useState ( false )
2026-08-15 09:29:08 +02:00
const [ adminMenuOpen , setAdminMenuOpen ] = useState ( false )
2026-08-14 12:43:11 +02:00
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-17 09:24:53 +02:00
const [ newEventDraft , setNewEventDraft ] = useState < EventExhibit | null >( null )
2026-08-14 14:44:58 +02:00
const [ editingPartyId , setEditingPartyId ] = useState < string | null >( null )
2026-08-17 09:24:53 +02:00
const [ newPartyDraft , setNewPartyDraft ] = useState < PartyExhibit | null >( null )
2026-08-14 14:44:58 +02:00
const [ briefOpen , setBriefOpen ] = useState ( false )
2026-08-22 17:02:30 +02:00
const [ reportOpen , setReportOpen ] = useState ( false )
const [ playerName , setPlayerName ] = useState ( 'Player' )
2026-08-14 14:44:58 +02:00
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 16:37:50 +02:00
const [ recentlyCreatedConnectionId , setRecentlyCreatedConnectionId ] = useState < string | null >( null )
const [ threadDraft , setThreadDraft ] = useState < Connection | null >( null )
2026-08-22 14:53:23 +02:00
const [ flagsOpen , setFlagsOpen ] = useState ( false )
const [ matchRulesOpen , setMatchRulesOpen ] = useState ( false )
const [ arrivingExhibitIds , setArrivingExhibitIds ] = useState < string [] >([])
2026-08-22 16:02:10 +02:00
const [ activePlaythroughId , setActivePlaythroughId ] = useState < string | null >( null )
const [ completedGoal , setCompletedGoal ] = useState < LevelGoal | null >( null )
const [ advancing , setAdvancing ] = useState ( false )
2026-08-14 12:43:11 +02:00
const saveTimer = useRef < number | undefined >( undefined )
const boardRef = useRef < HTMLDivElement >( null )
const fileInputRef = useRef < HTMLInputElement >( null )
2026-08-15 09:29:08 +02:00
const adminMenuRef = useRef < HTMLDivElement >( null )
2026-08-14 12:43:11 +02:00
const requestedEditMode = new URLSearchParams ( window . location . search ). get ( 'edit' ) === '1'
2026-08-18 15:37:46 +02:00
const adminRoute = window . location . pathname === '/admin'
const loadLevelBySlug = useCallback ( async ( slug : string , editQuery = '' ) => {
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( slug ) }${ editQuery } ` )
if ( ! response . ok ) throw new Error ( 'Level unavailable' )
const data = normalizeCase ( await response . json ())
setCaseState ( data )
2026-08-22 14:53:23 +02:00
const arrivals = data . newlyVisibleDocumentIds || []
if ( arrivals . length ) {
setArrivingExhibitIds ( arrivals )
void fetch ( `/api/levels/ ${ encodeURIComponent ( data . id ) } /reveals/seen` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ documentIds : arrivals }),
})
}
2026-08-18 15:37:46 +02:00
return data
}, [])
2026-08-14 12:43:11 +02:00
useEffect (() => {
2026-08-18 15:37:46 +02:00
if ( adminRoute ) return
2026-08-14 12:43:11 +02:00
const params = new URLSearchParams ( window . location . search )
2026-08-22 17:02:30 +02:00
fetch ( '/api/session' ). then ( response => response . ok ? response . json () : null ). then ( session => {
setIsAdmin ( Boolean ( session ? . isAdmin )); setPlayerName ( String ( session ? . playerName || 'Player' ))
}). catch (() => setIsAdmin ( false ))
2026-08-18 15:37:46 +02:00
2026-08-22 15:12:56 +02:00
const pathLevel = window . location . pathname . match ( /^\/level\/(.+)$/ )
const deepLinkLevel = params . get ( 'level' ) || ( pathLevel ? decodeURIComponent ( pathLevel [ 1 ]) : null )
2026-08-18 15:37:46 +02:00
const editQuery = params . get ( 'edit' ) === '1' ? '?edit=1' : ''
const openLevel = async ( slug : string ) => {
const data = await loadLevelBySlug ( slug , editQuery )
2026-08-22 16:02:10 +02:00
if (( data . goals . some ( goal => goal . status === 'pending' ) || data . brief . concepts . some ( concept => ! concept . resolvedPartyExhibitId ))
&& ! localStorage . getItem ( briefAcknowledgementKey ( data . id ))) setBriefOpen ( true )
try {
const playthroughResponse = await fetch ( '/api/playthroughs/current' )
if ( playthroughResponse . ok && playthroughResponse . status !== 204 ) {
const playthrough = await playthroughResponse . json () as PlaythroughState
setActivePlaythroughId ( playthrough . node ? . kind === 'level' && playthrough . node . levelSlug === data . id ? playthrough.playthrough.id : null )
} else setActivePlaythroughId ( null )
} catch { setActivePlaythroughId ( null ) }
2026-08-14 16:18:13 +02:00
setStatus ( 'EVIDENCE INTEGRITY: PROBABLY OK' )
2026-08-18 15:37:46 +02:00
}
const boot = async () => {
if ( deepLinkLevel ) { await openLevel ( deepLinkLevel ); return }
2026-08-22 14:53:23 +02:00
const levels = await ( await fetch ( '/api/levels' )). json () as { id : string }[]
if ( ! levels [ 0 ] ? . id ) { setNoLevels ( true ); setStatus ( 'NO LEVELS IN ARCHIVE' ); return }
await openLevel ( levels [ 0 ]. id )
2026-08-18 15:37:46 +02:00
}
boot (). catch ( async () => {
try {
const levels = await ( await fetch ( '/api/levels' )). json () as { id : string }[]
if ( ! levels [ 0 ] ? . id ) { setNoLevels ( true ); setStatus ( 'NO LEVELS IN ARCHIVE' ); return }
await openLevel ( levels [ 0 ]. id )
} catch {
2026-08-14 12:43:11 +02:00
const cached = localStorage . getItem ( 'gupi-osint-board:last' )
if ( cached ) setCaseState ( normalizeCase ( JSON . parse ( cached )))
setStatus ( cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE' )
2026-08-18 15:37:46 +02:00
}
})
2026-08-14 12:43:11 +02:00
const tick = () => setClock ( new Date (). toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' }))
tick (); const timer = window . setInterval ( tick , 30000 )
return () => clearInterval ( timer )
2026-08-18 15:37:46 +02:00
}, [ loadLevelBySlug , adminRoute ])
2026-08-15 09:29:08 +02:00
useEffect (() => {
if ( ! adminMenuOpen ) return
const close = ( event : PointerEvent ) => { if ( ! adminMenuRef . current ? . contains ( event . target as Node )) setAdminMenuOpen ( false ) }
document . addEventListener ( 'pointerdown' , close )
return () => document . removeEventListener ( 'pointerdown' , close )
}, [ adminMenuOpen ])
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 16:37:50 +02:00
useEffect (() => {
if ( ! recentlyCreatedConnectionId ) return
const timer = window . setTimeout (() => setRecentlyCreatedConnectionId ( null ), 1200 )
return () => window . clearTimeout ( timer )
}, [ recentlyCreatedConnectionId ])
2026-08-22 14:53:23 +02:00
useEffect (() => {
if ( ! arrivingExhibitIds . length ) return
const timer = window . setTimeout (() => setArrivingExhibitIds ([]), 1800 )
return () => window . clearTimeout ( timer )
}, [ arrivingExhibitIds ])
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
2026-08-17 09:24:53 +02:00
const ev = caseState . exhibits . find ( e => e . id === id )
2026-08-14 12:43:11 +02:00
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 ) !
2026-08-17 09:24:53 +02:00
const existingSource = caseState . relations . find ( relation => relation . type === 'source' && relation . toExhibitId === doc . id && relation . sourceRegionId === regionId )
if ( existingSource ) { setOpenDoc ( null ); focusEvidence ( existingSource . fromExhibitId ); return }
const ev : FolderExhibit = {
id : uid ( 'folder' ), type : 'folder' , title : ` ${ documentWidget ( doc . fileType ). label . toUpperCase () } EVIDENCE` , content : region.excerpt , isOpen : false ,
... placement ( 850 + Math . random () * 220 , 390 + Math . random () * 250 , 260 , 166 ),
2026-08-14 12:43:11 +02:00
}
2026-08-17 09:24:53 +02:00
update ( s => ({ ... s , exhibits : [... s . exhibits , ev ], relations : [... s . relations ,
{ id : uid ( 'contains' ), fromExhibitId : ev.id , toExhibitId : doc.id , type : 'contains' , sortOrder : 0 },
{ id : uid ( 'source' ), fromExhibitId : ev.id , toExhibitId : doc.id , type : 'source' , sortOrder : 0 , sourceRegionId : region.id },
] }))
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-17 09:24:53 +02:00
const position = nextOpenBoardPosition ( caseState . exhibits , { x : Math.max ( 100 , ( 500 - viewport . x ) / viewport . zoom ), y : Math.max ( 100 , ( 330 - viewport . y ) / viewport . zoom ) }, { width : 108 })
const note : Evidence = { id : uid ( 'note' ), type : 'note' , title : 'WORKING NOTE' , content , ... placement ( position . x , position . y , 108 , 154 ) }
update ( s => ({ ... s , exhibits : [... s . exhibits , note ] })); setSelected ( note . id ); setRecentlyCreatedExhibitId ( note . id )
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-17 09:24:53 +02:00
const position = nextOpenBoardPosition ( caseState . exhibits , { x : Math.max ( 100 , ( 620 - viewport . x ) / viewport . zoom ), y : Math.max ( 100 , ( 290 - viewport . y ) / viewport . zoom ) }, { width : 270 })
const event : EventExhibit = { id : uid ( 'event' ), type : 'event' , title : 'UNTITLED EVENT' , content : 'Describe what happened.' ,
... placement ( position . x , position . y , 270 , 174 ) }
2026-08-14 19:57:22 +02:00
setNewEventDraft ( event )
2026-08-14 14:31:52 +02:00
}
2026-08-14 18:17:31 +02:00
const addParty = () => {
if ( ! caseState ) return
const { viewport } = caseState
2026-08-17 09:24:53 +02:00
const position = nextOpenBoardPosition ( caseState . exhibits , {
2026-08-14 18:17:31 +02:00
x : Math.max ( 100 , ( 670 - viewport . x ) / viewport . zoom ), y : Math.max ( 100 , ( 310 - viewport . y ) / viewport . zoom ),
}, { width : 280 })
2026-08-17 09:24:53 +02:00
setNewPartyDraft ({ id : uid ( 'party' ), type : 'party' , partyKind : 'person' , title : '' , content : '' , aliases : [], ... placement ( position . x , position . y , 280 , 190 ) })
2026-08-14 18:17:31 +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-17 09:24:53 +02:00
const existingParty = caseState . exhibits . find (( item ) : item is PartyExhibit => item . id === existingId && item . type === 'party' )
const position = existingParty ? { x : existingParty.x , y : existingParty.y } : nextOpenBoardPosition ( caseState . exhibits , {
2026-08-14 15:02:00 +02:00
x : Math.max ( 100 , ( 670 - viewport . x ) / viewport . zoom ), y : Math.max ( 100 , ( 310 - viewport . y ) / viewport . zoom ),
}, { width : 280 })
2026-08-17 09:24:53 +02:00
const party : PartyExhibit = { id : partyId , type : 'party' , partyKind , organizationKind : partyKind === 'organization' ? 'business' : undefined ,
title : concept.label , content : concept.context , aliases : [],
... placement ( position . x , position . y , 280 , 190 ) }
2026-08-14 14:44:58 +02:00
update ( state => ({ ... state ,
2026-08-17 09:24:53 +02:00
exhibits : existingId ? state . exhibits . map ( item => item . id === existingId && item . type === 'party' ? { ... item , partyKind , organizationKind : partyKind === 'organization' ? item . organizationKind || 'business' : undefined } : item ) : [... state . exhibits , party ],
2026-08-14 14:44:58 +02:00
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 }
2026-08-14 16:37:50 +02:00
completeThread ( id )
}
const completeThread = ( targetId : string ) => {
if ( ! linkFrom || ! caseState || linkFrom === targetId ) return
2026-08-17 09:24:53 +02:00
const existing = caseState . connections . find ( connection => ( connection . fromExhibitId === linkFrom && connection . toExhibitId === targetId ) || ( connection . fromExhibitId === targetId && connection . toExhibitId === linkFrom ))
2026-08-14 16:37:50 +02:00
if ( existing ) {
setLinkFrom ( null ); setThreadDraft ( existing ); setStatus ( 'THREAD ALREADY EXISTS · EDITING TAG' )
return
2026-08-14 12:43:11 +02:00
}
2026-08-22 17:02:30 +02:00
setThreadDraft ({ id : uid ( 'connection' ), fromExhibitId : linkFrom , toExhibitId : targetId , label : 'Proof that…' , tightness : 65 , tagStyle : 'luggage' , tagPosition : 50 , tagOffset : 0 })
2026-08-14 16:37:50 +02:00
setLinkFrom ( null )
2026-08-17 09:24:53 +02:00
if ( caseState . exhibits . some ( item => item . id === targetId )) setSelected ( targetId )
2026-08-14 16:37:50 +02:00
}
const saveThread = ( connection : Connection ) => {
if ( ! caseState ) return
const exists = caseState . connections . some ( item => item . id === connection . id )
update ( state => ({ ... state , connections : exists ? state . connections . map ( item => item . id === connection . id ? connection : item ) : [... state . connections , connection ] }))
if ( ! exists ) setRecentlyCreatedConnectionId ( connection . id )
setThreadDraft ( null )
setStatus ( connection . label ? 'RED THREAD TAGGED' : 'RED THREAD TIGHTENED' )
}
const removeThread = ( id : string ) => {
update ( state => ({ ... state , connections : state.connections.filter ( connection => connection . id !== id ) }))
setThreadDraft ( null )
setStatus ( 'RED THREAD REMOVED' )
}
2026-08-14 19:37:43 +02:00
const removeExhibit = ( id : string ) => {
update ( state => discardExhibit ( state , id ))
setSelected ( current => current === id ? null : current )
setLinkFrom ( current => current === id ? null : current )
setEditingFolderId ( current => current === id ? null : current )
setEditingEventId ( current => current === id ? null : current )
setEditingPartyId ( current => current === id ? null : current )
setRecentlyCreatedExhibitId ( current => current === id ? null : current )
setStatus ( 'EXHIBIT DISCARDED · RELATIONS REMOVED' )
}
2026-08-14 16:37:50 +02:00
const toggleThreadTool = () => {
if ( linkFrom ) { setLinkFrom ( null ); setStatus ( 'RED THREAD CANCELLED' ) }
else if ( selected ) { setLinkFrom ( selected ); setStatus ( 'RED THREAD READY · SELECT TARGET' ) }
setBoardTool ( 'move' )
2026-08-14 12:43:11 +02:00
}
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-15 09:29:08 +02:00
const enterLevelEditor = () => {
const params = new URLSearchParams ( window . location . search )
params . set ( 'edit' , '1' )
window . location . assign ( ` ${ window . location . pathname } ? ${ params . toString () } ` )
}
2026-08-22 14:53:23 +02:00
const uploadFiles = useCallback ( async ( files : FileList | File [], source : 'file' | 'clipboard' = 'file' ) => {
if ( ! caseState ) return
2026-08-14 12:43:11 +02:00
const queue = Array . from ( files )
setUploading ( queue . length )
setDraggingFiles ( false )
2026-08-22 14:53:23 +02:00
for ( const [ queueIndex , file ] of queue . entries ()) {
const position = nextOpenBoardPosition ( caseState . exhibits , {
x : Math.max ( 100 , ( 520 - caseState . viewport . x ) / caseState . viewport . zoom ) + queueIndex * 24 ,
y : Math.max ( 100 , ( 310 - caseState . viewport . y ) / caseState . viewport . zoom ) + queueIndex * 24 ,
}, { width : 174 , height : 145 })
2026-08-14 12:43:11 +02:00
const form = new FormData ()
form . append ( 'file' , file )
2026-08-22 14:53:23 +02:00
form . append ( 'x' , String ( position . x ))
form . append ( 'y' , String ( position . y ))
2026-08-14 12:43:11 +02:00
try {
2026-08-22 16:02:10 +02:00
setStatus ( source === 'clipboard' ? 'PASTING SCREENSHOT · READING SOURCE…' : `IMPORTING ${ file . name . toUpperCase () } · READING SOURCE…` )
2026-08-22 14:53:23 +02:00
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) } /documents` , { method : 'POST' , body : form })
2026-08-14 12:43:11 +02:00
if ( ! response . ok ) { const body = await response . json (). catch (() => ({})); throw new Error ( body . error || `Upload failed ( ${ response . status } )` ) }
2026-08-22 14:53:23 +02:00
const uploaded : UploadedCaseDocument = await response . json ()
const { analysis , ... document } = uploaded
update ( s => {
return { ... s , exhibits : [... s . exhibits , { ... document , ... position }] }
})
setSelected ( document . id )
setArrivingExhibitIds ( current => [... new Set ([... current , document . id ])])
void fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) } /reveals/seen` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ documentIds : [ document . id ] }),
})
2026-08-22 16:02:10 +02:00
const deterministicCompletion = analysis . goals . find ( goal => goal . newlyCompleted )
2026-08-22 17:02:30 +02:00
if ( deterministicCompletion && ! caseState . report ? . requiredForCompletion ) setCompletedGoal ( deterministicCompletion )
2026-08-22 14:53:23 +02:00
if ( analysis . awardedFlags . length ) {
window . clearTimeout ( saveTimer . current )
const editQuery = requestedEditMode && caseState . editingAllowed ? '?edit=1' : ''
await loadLevelBySlug ( caseState . id , editQuery )
setSelected ( document . id )
2026-08-22 17:02:30 +02:00
setStatus ( deterministicCompletion ? caseState . report ? . requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${ analysis . awardedFlags . join ( ', ' ). toUpperCase () } · NEW MATERIAL UNLOCKED` )
2026-08-22 14:53:23 +02:00
} else if ( analysis . matchedFlags . length ) setStatus ( 'EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED' )
2026-08-22 16:02:10 +02:00
else if ( analysis . extractionStatus === 'succeeded' && analysis . goals . some ( goal => goal . status === 'pending' )) {
setStatus ( 'TEXT EXTRACTED · CHECKING SOURCE CLAIM…' )
const judgeResponse = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) } /documents/ ${ encodeURIComponent ( document . id ) } /judge` , { method : 'POST' })
if ( judgeResponse . ok ) {
const semantic = await judgeResponse . json () as DocumentSemanticAnalysis
const semanticCompletion = semantic . goals . find ( goal => goal . newlyCompleted )
if ( semanticCompletion || semantic . awardedFlags . length ) {
2026-08-22 17:02:30 +02:00
if ( semanticCompletion && ! caseState . report ? . requiredForCompletion ) setCompletedGoal ( semanticCompletion )
2026-08-22 16:02:10 +02:00
window . clearTimeout ( saveTimer . current )
const editQuery = requestedEditMode && caseState . editingAllowed ? '?edit=1' : ''
await loadLevelBySlug ( caseState . id , editQuery )
setSelected ( document . id )
}
2026-08-22 17:02:30 +02:00
if ( semanticCompletion ) setStatus ( caseState . report ? . requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' )
2026-08-22 16:02:10 +02:00
else if ( semantic . subject === 'related' && semantic . supportsClaim ) setStatus ( 'RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS' )
else if ( semantic . status === 'unavailable' ) setStatus ( 'SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE' )
else if ( semantic . status === 'pending' ) setStatus ( 'SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING' )
else setStatus ( 'SCREENSHOT SAVED · SOURCE DOES NOT YET PROVE THE OBJECTIVE' )
} else if ( source === 'clipboard' ) setStatus ( 'SCREENSHOT PASTED · TEXT ANALYZED' )
else setStatus ( `IMPORTED · ${ file . name . toUpperCase () } · TEXT ANALYZED` )
} else if ( source === 'clipboard' && analysis . extractionStatus === 'succeeded' ) setStatus ( 'SCREENSHOT PASTED · TEXT ANALYZED' )
2026-08-22 14:53:23 +02:00
else if ( source === 'clipboard' && analysis . extractionStatus === 'failed' ) setStatus ( 'SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE' )
else setStatus ( source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${ file . name . toUpperCase () } ` )
2026-08-14 12:43:11 +02:00
} catch ( error ) {
setStatus ( error instanceof Error ? error . message . toUpperCase () : 'UPLOAD FAILED' )
} finally { setUploading ( count => count - 1 ) }
}
2026-08-22 14:53:23 +02:00
}, [ caseState , loadLevelBySlug , requestedEditMode , update ])
2026-08-14 12:43:11 +02:00
2026-08-22 16:02:10 +02:00
const continueAfterGoal = async () => {
if ( ! activePlaythroughId ) { setCompletedGoal ( null ); return }
setAdvancing ( true )
try {
const response = await fetch ( `/api/playthroughs/ ${ encodeURIComponent ( activePlaythroughId ) } /advance` , {
method : 'POST' , headers : { 'content-type' : 'application/json' }, body : '{}' ,
})
if ( ! response . ok ) { setStatus ( 'OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE' ); return }
const next = await response . json () as PlaythroughState
if ( next . node ? . kind === 'level' && next . node . levelSlug ) window . location . assign ( `/level/ ${ encodeURIComponent ( next . node . levelSlug ) } ` )
else window . location . assign ( '/' )
} finally { setAdvancing ( false ) }
}
2026-08-22 17:02:30 +02:00
const openCaseReport = async () => {
if ( ! caseState ? . report ) return
window . clearTimeout ( saveTimer . current )
const editQuery = requestedEditMode && caseState . editingAllowed ? '?edit=1' : ''
setStatus ( 'COMPILING CASE REPORT…' )
const saved = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) }${ editQuery } ` , {
method : 'PUT' , headers : { 'content-type' : 'application/json' }, body :JSON.stringify ( caseState ),
})
if ( saved . ok ) await loadLevelBySlug ( caseState . id , editQuery )
setReportOpen ( true )
setStatus ( saved . ok ? 'CASE REPORT COMPILED FROM BOARD' : 'CASE REPORT OPEN · LATEST SERVER COPY' )
}
const submitReport = async ( input :CaseReportSubmissionInput ) => {
if ( ! caseState ? . report ) throw new Error ( 'Case report unavailable' )
setStatus ( 'SUBMITTING CASE REPORT…' )
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( caseState . id ) } /report/submissions` ,{
method : 'POST' , headers : { 'content-type' : 'application/json' }, body :JSON.stringify ( input ),
})
if ( ! response . ok ) { const body = await response . json (). catch (() => ({})); throw new Error ( body . error || 'Report submission failed' ) }
const report = await response . json () as CaseReport
const reportEvidence = report . claims . flatMap ( claim => claim . evidence )
const byDocument = new Map ( reportEvidence . map ( item => [ item . documentExhibitId , item ]))
const byConnection = new Map ( reportEvidence . map ( item => [ item . connectionId , item ]))
setCaseState ( current => current ? {
... current , report ,
exhibits :current.exhibits.map ( exhibit => exhibit . type === 'document' && byDocument . has ( exhibit . id )
? { ... exhibit , publishedAt :byDocument.get ( exhibit . id ) ! . publishedAt , sourceCitation :byDocument.get ( exhibit . id ) ! . sourceCitation , sourceUri :byDocument.get ( exhibit . id ) ! . sourceUri , displayNumber :byDocument.get ( exhibit . id ) ! . displayNumber }
: exhibit ),
connections :current.connections.map ( connection => byConnection . has ( connection . id )
? { ... connection , label :byConnection.get ( connection . id ) ! . relationText || undefined } : connection ),
} : current )
setStatus ( report . status === 'accepted' ? 'CASE REPORT ACCEPTED' : report . status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED FOR REVISION' : 'REPORT NEEDS SUPPORTING EVIDENCE' )
return report
}
2026-08-22 14:53:23 +02:00
useEffect (() => {
if ( ! caseState ) return
const handlePaste = ( event : ClipboardEvent ) => {
const images = Array . from ( event . clipboardData ? . items || []). flatMap ( item => {
const file = item . kind === 'file' && item . type . startsWith ( 'image/' ) ? item . getAsFile () : null
return file ? [ file ] : []
})
if ( ! images . length ) return
event . preventDefault ()
void uploadFiles ( images . map ( screenshotFile ), 'clipboard' )
}
window . addEventListener ( 'paste' , handlePaste )
return () => window . removeEventListener ( 'paste' , handlePaste )
}, [ caseState , uploadFiles ])
2026-08-18 19:51:44 +02:00
2026-08-18 15:37:46 +02:00
if ( adminRoute ) return < AdminPanel />
2026-08-15 09:29:08 +02:00
if ( noLevels ) return < EmptyArchive canEdit = { isAdmin } onCreated = { level => { window . location . assign ( `?level= ${ encodeURIComponent ( level . id ) } &edit=1` ) }} />
2026-08-14 12:43:11 +02:00
if ( ! caseState ) return < div className = "boot" >< div className = "seal" > GU </ div >< p > GLITCH UNIVERSITY NETWORK TERMINAL </ p >< small >{ status }</ small ></ div >
2026-08-17 09:24:53 +02:00
const documents = documentExhibits ( caseState . exhibits )
const evidence = evidenceExhibits ( caseState . exhibits )
const documentById = new Map ( documents . map ( document => [ document . id , document ]))
2026-08-14 17:57:00 +02:00
const normalizedDocumentQuery = documentQuery . trim (). toLocaleLowerCase ()
2026-08-17 09:24:53 +02:00
const filteredDocuments = normalizedDocumentQuery ? documents . filter ( document => documentSearchText ( document ). includes ( normalizedDocumentQuery )) : documents
2026-08-14 16:18:13 +02:00
const unresolvedConceptCount = caseState . brief . concepts . filter ( concept => ! concept . resolvedPartyExhibitId ). length
2026-08-22 16:02:10 +02:00
const pendingGoalCount = caseState . goals . filter ( goal => goal . status === 'pending' ). length
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
2026-08-22 17:02:30 +02:00
const reportAttentionCount = caseState . report ? . requiredForCompletion && caseState . report . status !== 'accepted' ? 1 : 0
2026-08-22 16:02:10 +02:00
const activeGoal = caseState . goals . find ( goal => goal . status === 'pending' ) || caseState . goals [ 0 ]
2026-08-15 09:29:08 +02:00
const canAuthor = isAdmin && requestedEditMode && Boolean ( caseState . editingAllowed )
2026-08-17 09:24:53 +02:00
const temporalItems : TemporalItem [] = caseState . exhibits . flatMap ( exhibit => exhibitWidget ( exhibit . type ). temporalFacts ( exhibit ). map ( fact => {
const membership = exhibit . type === 'document' ? caseState . relations . find ( relation => relation . type === 'contains' && relation . toExhibitId === exhibit . id ) : undefined
const folder = membership ? caseState . exhibits . find ( candidate => candidate . id === membership . fromExhibitId && candidate . type === 'folder' ) as FolderExhibit | undefined : undefined
const sourceTemporalId = folder && ! folder . isOpen ? `widget: ${ folder . id } ` : `widget: ${ exhibit . id } `
return { id : fact.id , sourceTemporalId , date : fact.start , label : fact.label , kind : exhibit.type === 'document' ? 'document' as const : 'widget' as const , exhibitId : exhibit.id }
})). sort (( a , b ) => dateValue ( a . date ) - dateValue ( b . date ))
const storyEvents = evidence . filter (( item ) : item is EventExhibit => item . type === 'event' ). sort (( a , b ) => {
2026-08-14 19:57:22 +02:00
if ( ! a . eventDate ) return b . eventDate ? 1 : 0
if ( ! b . eventDate ) return - 1
return dateValue ( a . eventDate ) - dateValue ( b . eventDate )
})
2026-08-17 09:24:53 +02:00
const timelineView = caseState . views . find (( view ) : view is TimelineView => view . type === 'timeline' )
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-15 09:29:08 +02:00
< button className = { docsOpen ? 'active' : '' } onClick = {() => setDocsOpen ( true )}> EVIDENCE </ button >
2026-08-22 16:02:10 +02:00
< button className = { briefOpen ? 'active' : '' } aria-label = "Case brief" onClick = {() => briefOpen ? closeBrief () : setBriefOpen ( true )}> CASE BRIEF { briefAttentionCount > 0 && < b className = "brief-count" >{ briefAttentionCount }</ b >}</ button >
2026-08-22 17:02:30 +02:00
{ caseState . report && < button className = { reportOpen ? 'active' : '' } aria-label = "Case report" onClick = {() => reportOpen ? setReportOpen ( false ) : void openCaseReport ()}> CASE REPORT { reportAttentionCount > 0 && < b className = "brief-count" >{ reportAttentionCount }</ b >}</ button >}
2026-08-15 09:29:08 +02:00
< button onClick = {() => setEditingTimeline ( true )}> TIMELINE </ button >
< button onClick = {() => setHelpOpen ( true )}> HELP </ button >
{ isAdmin && < div className = "admin-menu" ref = { adminMenuRef }>
< button className = { adminMenuOpen ? 'active' : '' } aria-haspopup = "menu" aria-expanded = { adminMenuOpen } onClick = {() => setAdminMenuOpen ( open => ! open )}> ADMIN </ button >
{ adminMenuOpen && < div className = "admin-menu-items" role = "menu" >
2026-08-22 14:53:23 +02:00
< button role = "menuitem" onClick = {() => { setFlagsOpen ( true ); setAdminMenuOpen ( false ) }}> LEVEL FLAGS </ button >
2026-08-15 09:29:08 +02:00
{ ! canAuthor ? < button role = "menuitem" onClick = { enterLevelEditor }> ENTER LEVEL EDITOR </ button > : <>
< button role = "menuitem" onClick = {() => { setEditingBrief ( true ); setAdminMenuOpen ( false ) }}> EDIT BRIEF & amp ; CONCEPTS </ button >
2026-08-22 14:53:23 +02:00
< button role = "menuitem" onClick = {() => { setMatchRulesOpen ( true ); setAdminMenuOpen ( false ) }}> EVIDENCE MATCHING </ button >
2026-08-15 09:29:08 +02:00
< button role = "menuitem" onClick = {() => { fileInputRef . current ? . click (); setAdminMenuOpen ( false ) }}> IMPORT DOCUMENTS </ button >
< button role = "menuitem" onClick = {() => { setAdminMenuOpen ( false ); void saveAsTemplate () }}> SAVE AS TEMPLATE </ button >
< button role = "menuitem" onClick = {() => { setAdminMenuOpen ( false ); void instantiateTemplate () }}> NEW FROM TEMPLATE </ button >
</>}
</ div >}
</ div >}
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' } ` }>
2026-08-17 09:24:53 +02:00
< div className = "panel-heading" >< div >< small > CASE MATERIALS </ small >< h2 > DOCUMENTS < sup >{ normalizedDocumentQuery ? ` ${ filteredDocuments . length } / ${ documents . length } ` : documents . length }</ sup ></ h2 ></ div >< button aria-label = "Close documents" onClick = {() => setDocsOpen ( false )}>< X size = { 17 }/></ button ></ div >
2026-08-14 17:57:00 +02:00
< label className = "search" >< Search size = { 15 }/>< input type = "search" aria-label = "Search inside documents" placeholder = "Search inside documents…" value = { documentQuery } onChange = { event => setDocumentQuery ( event . target . value )}/>{ documentQuery && < button type = "button" aria-label = "Clear document search" onClick = {() => setDocumentQuery ( '' )}>< X size = { 13 }/></ button >}</ label >
2026-08-22 14:53:23 +02:00
<>< button className = "import-document" onClick = {() => fileInputRef . current ? . click ()}>< Upload size = { 15 }/>{ uploading ? `IMPORTING ${ uploading } …` : 'ADD DOCUMENT' }</ button >< input ref = { fileInputRef } className = "file-input" type = "file" multiple onChange = { e => { if ( e . target . files ) void uploadFiles ( e . target . files ); e . target . value = '' }} /></>
2026-08-14 12:43:11 +02:00
< div className = "doc-list" >
2026-08-22 14:53:23 +02:00
{ filteredDocuments . map (( doc , index ) => < button className = { `doc-row ${ selected === doc . id ? 'selected' : '' } ${ arrivingExhibitIds . includes ( doc . id ) ? 'arriving' : '' } ` } data-document-row-id = { doc . id } data-temporal-id = { `document: ${ doc . id } ` } key = { doc . id } title = "Click to locate on board · double-click to open" onDoubleClick = {() => setOpenDoc ( doc )} onClick = {() => setSelected ( current => current === doc . id ? null : doc . id )}>
2026-08-17 09:24:53 +02:00
< div className = { `doc-icon tint- ${ index % 3 } ` }>< FileText size = { 24 }/>< b >{ doc . fileType . slice ( 0 , 3 )}</ b ></ div >
2026-08-22 17:02:30 +02:00
< div >< strong >{ doc . title }</ strong >< span > EXHIBIT { doc . displayNumber || index + 1 } · { documentWidget ( doc . fileType ). label } · { doc . publishedAt ? . slice ( 0 , 10 ) || 'UNDATED' }</ span ></ div >< ChevronRight size = { 16 }/>
2026-08-14 12:43:11 +02:00
</ button >)}
2026-08-14 17:57:00 +02:00
{ normalizedDocumentQuery && filteredDocuments . length === 0 && < div className = "no-document-results" >< Search size = { 20 }/>< b > NO MATCHING DOCUMENTS </ b >< span > Searches titles , contents , extracts , and metadata .</ span ></ div >}
2026-08-14 12:43:11 +02:00
</ div >
< div className = "panel-foot" >< FolderOpen size = { 15 }/> ARCHIVE MOUNTED < span >{ requestedEditMode && caseState . editingAllowed ? 'AUTHORING' : 'READ ONLY' }</ span ></ div >
</ aside >
2026-08-22 14:53:23 +02:00
< div className = "board-shell" onDragEnter = { e => { if ( e . dataTransfer . types . includes ( 'Files' )) { e . preventDefault (); setDraggingFiles ( true ) } }} onDragOver = { e => { 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 ) void uploadFiles ( e . dataTransfer . files ) }}>
2026-08-22 17:02:30 +02:00
< 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 >{ activeGoal && < button className = { `active-goal ${ activeGoal . status } ` } onClick = {() => reportAttentionCount ? void openCaseReport () : setBriefOpen ( true )}>< span >{ activeGoal . status === 'complete' ? reportAttentionCount ? 'SOURCE VERIFIED · REPORT REQUIRED' : 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE' }</ span >< b >{ activeGoal . title }</ b ></ button >}</ div >< div className = "case-number" >{ requestedEditMode && caseState . editingAllowed ? 'DRAFT' : 'CASE' }< br />< b >{ caseState . levelStatus ? . toUpperCase () || 'ACTIVE' }</ b ></ div ></ div >
2026-08-22 14:53:23 +02:00
< Board state = { caseState } selected = { selected } locatorDocumentId = { docsOpen && documents . some ( document => document . id === selected ) ? selected : null } linkFrom = { linkFrom } recentlyCreatedExhibitId = { recentlyCreatedExhibitId } arrivingExhibitIds = { arrivingExhibitIds } recentlyCreatedConnectionId = { recentlyCreatedConnectionId } tool = { boardTool } boardRef = { boardRef } update = { update } onCardClick = { handleCardClick } onConnectionTarget = { completeThread } onEditConnection = { connection => setThreadDraft ( connection )} onDiscardExhibit = { removeExhibit } onOpenSource = { id => setOpenDoc ( documents . find ( d => d . id === id ) || null )} onUpdateDocumentCue = {( id , cue ) => update ( state => ({ ... state , exhibits : state.exhibits.map ( exhibit => exhibit . id === id && exhibit . type === 'document' ? { ... exhibit , metadata : { ... exhibit . metadata , memory_cue : cue } } : exhibit ) }))} onEditFolder = { setEditingFolderId } onEditFile = { setEditingFileId } onEditEvent = { setEditingEventId } onEditParty = { setEditingPartyId } />
2026-08-14 14:44:58 +02:00
{ briefOpen && < BriefPanel
brief = { caseState . brief }
2026-08-22 16:02:10 +02:00
goals = { caseState . goals }
2026-08-17 09:24:53 +02:00
parties = { evidence . filter (( item ) : item is PartyExhibit => item . type === 'party' )}
2026-08-14 16:18:13 +02:00
recentlyCreatedExhibitId = { recentlyCreatedExhibitId }
2026-08-15 09:29:08 +02:00
canEdit = { canAuthor }
2026-08-14 16:18:13 +02:00
onClose = { closeBrief }
2026-08-14 14:44:58 +02:00
onEdit = {() => setEditingBrief ( true )}
onClassify = { classifyConcept }
2026-08-14 18:17:31 +02:00
onNewParty = { addParty }
2026-08-14 14:44:58 +02:00
onLocate = { focusEvidence }
2026-08-14 16:18:13 +02:00
onEditParty = { setEditingPartyId }
2026-08-14 14:44:58 +02:00
/>}
2026-08-14 19:57:22 +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 ) || 'UNDATED' }</ time >< b >{ index + 1 }. { event . title }</ b >< span >{ event . content }</ span ></ button >)}</ aside >}
2026-08-17 09:24:53 +02:00
{ ! docsOpen && < button className = "open-files" onClick = {() => setDocsOpen ( true )}>< FolderOpen size = { 18 }/> CASE MATERIALS < b >{ documents . length }</ b ></ button >}
2026-08-14 12:43:11 +02:00
< 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 18:17:31 +02:00
< button onClick = { addParty }>< UserRound size = { 17 }/> NEW PARTY </ button >
2026-08-14 16:37:50 +02:00
< button className = { `thread-tool ${ linkFrom ? 'active' : '' } ` } aria-label = "Red thread" title = { linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first' } disabled = { ! selected } onClick = { toggleThreadTool }>< Link2 size = { 18 }/></ button >
2026-08-14 12:43:11 +02:00
< 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 >
2026-08-18 15:37:46 +02:00
< DocumentLocatorBeam documentId = { docsOpen && documents . some ( document => document . id === selected ) ? selected : null } layoutKey = { ` ${ docsOpen } : ${ caseState . viewport . x } : ${ caseState . viewport . y } : ${ caseState . viewport . zoom } : ${ caseState . exhibits . map ( item => ` ${ item . id } : ${ item . x } : ${ item . y } : ${ item . type === 'folder' ? item . isOpen : '' } ` ). join ( '|' ) } ` } />
2026-08-17 09:24:53 +02:00
< TemporalLinks items = { temporalItems } layoutKey = { ` ${ caseState . viewport . x } : ${ caseState . viewport . y } : ${ caseState . viewport . zoom } : ${ docsOpen } : ${ caseState . exhibits . map ( e => ` ${ e . id } : ${ e . x } : ${ e . y } : ${ e . type === 'folder' ? e . isOpen : '' } ` ). join ( '|' ) } ` }/>
{ timelineView ? . visible !== false && < Timeline items = { temporalItems } range = { timelineView ? . rangeMode === 'fixed' ? timelineView.range : undefined } selected = { selected } onEdit = {() => setEditingTimeline ( true )} onSelect = { item => { const exhibit = caseState . exhibits . find ( candidate => candidate . id === item . exhibitId ); if ( exhibit ? . type === 'document' ) setOpenDoc ( exhibit ); else focusEvidence ( item . exhibitId ) }}/>
}
{ openDoc && < DocumentWindow doc = { openDoc } onClose = {() => setOpenDoc ( null )} onExtract = { id => extract ( openDoc , id )} extracted = { caseState . relations . flatMap ( relation => relation . type === 'source' && relation . toExhibitId === openDoc . id ? [ relation . sourceRegionId ] : [])} />}
2026-08-15 09:29:08 +02:00
{ editingFolderId && < FolderEditor
key = { editingFolderId }
2026-08-17 09:24:53 +02:00
folder = { caseState . exhibits . find (( widget ) : widget is FolderExhibit => widget . id === editingFolderId && widget . type === 'folder' ) ! }
2026-08-15 09:29:08 +02:00
memberIds = { containedIds ( caseState , editingFolderId )}
2026-08-17 09:24:53 +02:00
documents = { documents }
2026-08-15 09:29:08 +02:00
canManageContents = { canAuthor }
onClose = {() => setEditingFolderId ( null )}
onSave = {( folder , members ) => {
update ( state => ({
... state ,
2026-08-17 09:24:53 +02:00
exhibits : state.exhibits.map ( widget => widget . id === folder . id ? folder : widget ),
2026-08-15 09:29:08 +02:00
relations : [
2026-08-17 09:24:53 +02:00
... state . relations . filter ( relation => relation . type !== 'contains' || relation . fromExhibitId !== folder . id ),
... members . map (( documentId , index ) : ExhibitRelation => ({ id : state.relations.find ( relation => relation . type === 'contains' && relation . fromExhibitId === folder . id && relation . toExhibitId === documentId ) ? . id || uid ( 'contains' ), fromExhibitId : folder.id , toExhibitId : documentId , type : 'contains' , sortOrder : index })),
2026-08-15 09:29:08 +02:00
],
}))
setEditingFolderId ( null )
setStatus ( 'FOLDER UPDATED' )
}}
/>}
2026-08-22 14:53:23 +02:00
{ editingFileId && < FileEditor key = { editingFileId } document = { documents . find ( document => document . id === editingFileId ) ! } canEditGates = { canAuthor } onClose = {() => setEditingFileId ( null )} onSave = { document => { update ( state => ({ ... state , exhibits : state.exhibits.map ( candidate => candidate . id === document . id ? document : candidate ) })); setEditingFileId ( null ); setStatus ( 'FILE METADATA UPDATED' ) }}/>
2026-08-17 09:24:53 +02:00
}
2026-08-14 14:31:52 +02:00
{ editingEventId && < EventEditor
key = { editingEventId }
2026-08-17 09:24:53 +02:00
event = { caseState . exhibits . find (( item ) : item is EventExhibit => item . id === editingEventId && item . type === 'event' ) ! }
exhibits = { caseState . exhibits }
relations = { caseState . relations }
2026-08-14 14:31:52 +02:00
onClose = {() => setEditingEventId ( null )}
2026-08-17 09:24:53 +02:00
onSave = {( event , supports ) => { update ( state => ({ ... state , exhibits : state.exhibits.map ( item => item . id === event . id ? event : item ), relations : replaceDirectedRelations ( state . relations , 'supports' , event . id , supports ) })); setEditingEventId ( null ); setStatus ( 'EVENT NARRATIVE UPDATED' ) }}
2026-08-14 14:31:52 +02:00
/>}
2026-08-14 19:57:22 +02:00
{ newEventDraft && < EventEditor
key = { newEventDraft . id }
event = { newEventDraft }
2026-08-17 09:24:53 +02:00
exhibits = { caseState . exhibits }
relations = { caseState . relations }
2026-08-14 19:57:22 +02:00
onClose = {() => setNewEventDraft ( null )}
2026-08-17 09:24:53 +02:00
onSave = {( event , supports ) => {
update ( state => ({ ... state , exhibits : [... state . exhibits , event ], relations : replaceDirectedRelations ( state . relations , 'supports' , event . id , supports ) }))
2026-08-14 19:57:22 +02:00
setNewEventDraft ( null )
setSelected ( event . id )
setRecentlyCreatedExhibitId ( event . id )
setStatus ( event . eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED' )
}}
/>}
2026-08-14 14:44:58 +02:00
{ editingPartyId && < PartyEditor
key = { editingPartyId }
2026-08-17 09:24:53 +02:00
party = { caseState . exhibits . find (( item ) : item is PartyExhibit => item . id === editingPartyId && item . type === 'party' ) ! }
exhibits = { caseState . exhibits }
relations = { caseState . relations }
2026-08-14 14:44:58 +02:00
onClose = {() => setEditingPartyId ( null )}
2026-08-17 09:24:53 +02:00
onSave = {( party , related ) => {
update ( state => ({ ... state , exhibits : state.exhibits.map ( item => item . id === party . id ? party : item ), relations : replaceDirectedRelations ( state . relations , 'concerns' , party . id , related ) }))
2026-08-14 14:44:58 +02:00
setEditingPartyId ( null )
setStatus ( 'PARTY DOSSIER UPDATED' )
}}
/>}
2026-08-14 18:17:31 +02:00
{ newPartyDraft && < PartyEditor
key = { newPartyDraft . id }
party = { newPartyDraft }
2026-08-17 09:24:53 +02:00
exhibits = { caseState . exhibits }
relations = { caseState . relations }
2026-08-14 18:17:31 +02:00
creating
onClose = {() => setNewPartyDraft ( null )}
2026-08-17 09:24:53 +02:00
onSave = {( party , related ) => {
update ( state => ({ ... state , exhibits : [... state . exhibits , party ], relations : replaceDirectedRelations ( state . relations , 'concerns' , party . id , related ) }))
2026-08-14 18:17:31 +02:00
setNewPartyDraft ( null )
setSelected ( party . id )
setRecentlyCreatedExhibitId ( party . id )
setStatus ( ` ${ party . partyKind === 'person' ? 'PERSON' : 'ORGANIZATION' } DOSSIER CREATED` )
}}
/>}
2026-08-14 14:44:58 +02:00
{ 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
2026-08-17 09:24:53 +02:00
range = { timelineView ? . rangeMode === 'fixed' ? timelineView.range : undefined }
2026-08-14 15:40:54 +02:00
dates = { temporalItems . map ( item => item . date )}
onClose = {() => setEditingTimeline ( false )}
onSave = { timelineRange => {
2026-08-17 09:24:53 +02:00
update ( state => ({ ... state , views : state.views.map ( view => view . type === 'timeline' ? { ... view , rangeMode : timelineRange ? 'fixed' : 'auto' , range : timelineRange || undefined } : view ) }))
2026-08-14 15:40:54 +02:00
setEditingTimeline ( false )
setStatus ( timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC' )
}}
/>}
2026-08-14 16:37:50 +02:00
{ threadDraft && < ThreadEditor
key = { threadDraft . id }
connection = { threadDraft }
2026-08-17 09:24:53 +02:00
sourceName = { caseState . exhibits . find ( item => item . id === threadDraft . fromExhibitId ) ? . title || 'Exhibit' }
targetName = { caseState . exhibits . find ( item => item . id === threadDraft . toExhibitId ) ? . title || 'Exhibit' }
2026-08-14 16:37:50 +02:00
isNew = { ! caseState . connections . some ( item => item . id === threadDraft . id )}
onClose = {() => setThreadDraft ( null )}
onSave = { saveThread }
onRemove = {() => removeThread ( threadDraft . id )}
/>}
2026-08-22 17:02:30 +02:00
{ reportOpen && caseState . report && < CaseReportPanel key = { ` ${ caseState . id } : ${ caseState . report . status } : ${ caseState . report . claims . flatMap ( claim => claim . evidence ). length } ` }
report = { caseState . report } defaultInvestigator = { caseState . report . investigatorName || playerName } hasNext = { Boolean ( activePlaythroughId )}
onClose = {() => setReportOpen ( false )} onSubmit = { submitReport }
onContinue = {() => activePlaythroughId ? void continueAfterGoal () : setReportOpen ( false )}/>}
2026-08-14 12:43:11 +02:00
{ helpOpen && < Help onClose = {() => setHelpOpen ( false )}/>}
2026-08-22 14:53:23 +02:00
{ flagsOpen && < LevelFlagsEditor levelId = { caseState . id } onClose = {() => setFlagsOpen ( false )} onChanged = { async () => { await loadLevelBySlug ( caseState . id ) }} />}
{ matchRulesOpen && < EvidenceMatchRulesEditor levelId = { caseState . id } onClose = {() => setMatchRulesOpen ( false )}/>}
2026-08-22 16:02:10 +02:00
{ completedGoal && < GoalComplete goal = { completedGoal } hasNext = { Boolean ( activePlaythroughId )} busy = { advancing } onContinue = {() => void continueAfterGoal ()}/>}
2026-08-14 12:43:11 +02:00
</ 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-22 14:53:23 +02:00
function Board ({ state , selected , locatorDocumentId , linkFrom , recentlyCreatedExhibitId , arrivingExhibitIds , recentlyCreatedConnectionId , tool , boardRef , update , onCardClick , onEditConnection , onDiscardExhibit , onOpenSource , onUpdateDocumentCue , onEditFolder , onEditFile , onEditEvent , onEditParty } : { state : CaseState ; selected : string | null ; locatorDocumentId : string | null ; linkFrom : string | null ; recentlyCreatedExhibitId : string | null ; arrivingExhibitIds : string []; recentlyCreatedConnectionId : string | null ; tool : 'move' | 'hand' ; boardRef : React.RefObject < HTMLDivElement | null >; update : ( fn : ( s : CaseState ) => CaseState ) => void ; onCardClick : ( id : string ) => void ; onConnectionTarget : ( id : string ) => void ; onEditConnection : ( connection : Connection ) => void ; onDiscardExhibit : ( id : string ) => void ; onOpenSource : ( id : string ) => void ; onUpdateDocumentCue : ( id : string , cue : string ) => void ; onEditFolder : ( id : string ) => void ; onEditFile : ( id : string ) => void ; onEditEvent : ( id : string ) => void ; onEditParty : ( id : string ) => void }) {
2026-08-17 09:24:53 +02:00
const drag = useRef < { kind : 'pan' | 'widget' | 'thread-tag' ; id? : string ; startX : number ; startY : number ; originX : number ; originY : number ; moved? : boolean } | null > ( null )
2026-08-14 12:43:11 +02:00
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-18 15:37:46 +02:00
const folderLongPress = useRef < { pointerId : number ; id : string ; startX : number ; startY : number ; timer : number } | null > ( null )
2026-08-14 16:37:50 +02:00
const [ threadPointer , setThreadPointer ] = useState < { x : number ; y : number } | null > ( null )
2026-08-14 17:02:17 +02:00
const [ expandedThreadTagId , setExpandedThreadTagId ] = useState < string | null >( null )
2026-08-14 18:26:20 +02:00
const [ draggingThreadTagId , setDraggingThreadTagId ] = useState < string | null >( null )
2026-08-14 19:37:43 +02:00
const [ draggingWidget , setDraggingWidget ] = useState ( false )
const [ trashActive , setTrashActive ] = useState ( false )
const trashRef = useRef < HTMLDivElement >( null )
const trashTarget = useRef ( false )
2026-08-17 09:24:53 +02:00
const byId = useMemo (() => new Map ( state . exhibits . map ( exhibit => [ exhibit . id , exhibit ])), [ state . exhibits ])
2026-08-14 12:43:11 +02:00
const containmentRelations = state . relations . filter ( relation => relation . type === 'contains' )
2026-08-14 16:37:50 +02:00
const pointForId = ( id : string ) => {
2026-08-17 09:24:53 +02:00
const exhibit = byId . get ( id )
if ( ! exhibit ) return undefined
const membership = exhibit . type === 'document' ? containmentRelations . find ( item => item . toExhibitId === id ) : undefined
const folder = membership ? byId . get ( membership . fromExhibitId ) : undefined
return folder ? . type === 'folder' && ! folder . isOpen ? connectionPoint ( folder ) : connectionPoint ( exhibit )
2026-08-14 16:37:50 +02:00
}
2026-08-14 12:43:11 +02:00
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 16:44:43 +02:00
const bounds = board . getBoundingClientRect ()
const anchor = { x : event.clientX - bounds . left , y : event.clientY - bounds . top }
update ( s => ({ ... s , viewport : zoomViewportAt ( s . viewport , zoomFromWheel ( s . viewport . zoom , event . deltaY ), anchor ) }))
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 ])
2026-08-17 09:24:53 +02:00
const pointerDown = ( event : React.PointerEvent , target ?: { kind : 'widget' ; id : string }) => {
2026-08-14 12:43:11 +02:00
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 ) {
2026-08-18 15:37:46 +02:00
if ( folderLongPress . current ) window . clearTimeout ( folderLongPress . current . timer )
folderLongPress . current = null
2026-08-14 15:45:03 +02:00
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
2026-08-17 09:24:53 +02:00
drag . current = { kind : target?.kind || 'pan' , id : target?.id , startX : event.clientX , startY : event.clientY , originX : widget?.x ?? state . viewport . x , originY : widget?.y ?? state . viewport . y }
2026-08-14 19:37:43 +02:00
setDraggingWidget ( target ? . kind === 'widget' )
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
}
2026-08-14 18:26:20 +02:00
const threadTagPointerDown = ( event : React.PointerEvent , id : string ) => {
event . stopPropagation ()
if ( event . button !== 0 ) return
event . preventDefault ()
setDraggingThreadTagId ( id )
drag . current = { kind : 'thread-tag' , id , startX : event.clientX , startY : event.clientY , originX : 0 , originY : 0 }
try { event . currentTarget . setPointerCapture ( event . pointerId ) } catch { /* Synthetic test events have no active native pointer. */ }
}
2026-08-14 16:37:50 +02:00
const trackThreadPointer = ( event : React.PointerEvent ) => {
if ( linkFrom && boardRef . current ) {
const bounds = boardRef . current . getBoundingClientRect ()
setThreadPointer ({ x : ( event . clientX - bounds . left - state . viewport . x ) / state . viewport . zoom , y : ( event . clientY - bounds . top - state . viewport . y ) / state . viewport . zoom })
}
}
2026-08-14 12:43:11 +02:00
const pointerMove = ( event : React.PointerEvent ) => {
2026-08-14 16:37:50 +02:00
trackThreadPointer ( event )
2026-08-18 15:37:46 +02:00
const pendingFolderPress = folderLongPress . current
if ( pendingFolderPress ? . pointerId === event . pointerId && Math . hypot ( event . clientX - pendingFolderPress . startX , event . clientY - pendingFolderPress . startY ) > 8 ) {
window . clearTimeout ( pendingFolderPress . timer )
folderLongPress . current = null
}
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
2026-08-14 16:44:43 +02:00
if ( previous && boardRef . current ) {
const bounds = boardRef . current . getBoundingClientRect ()
const anchor = { x : ( points [ 0 ]. x + points [ 1 ]. x ) / 2 - bounds . left , y : ( points [ 0 ]. y + points [ 1 ]. y ) / 2 - bounds . top }
update ( s => ({ ... s , viewport : zoomViewportAt ( s . viewport , zoomFromPinch ( s . viewport . zoom , previous , distance ), anchor ) }))
}
2026-08-14 15:45:03 +02:00
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 19:37:43 +02:00
if ( drag . current . kind === 'widget' ) {
const bounds = trashRef . current ? . getBoundingClientRect ()
const overTrash = Boolean ( bounds && event . clientX >= bounds . left && event . clientX <= bounds . right && event . clientY >= bounds . top && event . clientY <= bounds . bottom )
trashTarget . current = overTrash
setTrashActive ( overTrash )
}
2026-08-14 18:26:20 +02:00
if ( drag . current . kind === 'thread-tag' && boardRef . current ) {
if ( drag . current . moved ) setExpandedThreadTagId ( null )
const connection = state . connections . find ( item => item . id === drag . current ! . id )
2026-08-17 09:24:53 +02:00
const from = connection ? pointForId ( connection . fromExhibitId ) : undefined
const to = connection ? pointForId ( connection . toExhibitId ) : undefined
2026-08-14 18:26:20 +02:00
if ( connection && from && to ) {
const bounds = boardRef . current . getBoundingClientRect ()
const pointer = { x : ( event . clientX - bounds . left - state . viewport . x ) / state . viewport . zoom , y : ( event . clientY - bounds . top - state . viewport . y ) / state . viewport . zoom }
const placement = projectThreadTag ( from , to , connection . tightness ?? 65 , pointer )
update ( s => ({ ... s , connections : s.connections.map ( item => item . id === connection . id ? { ... item , tagPosition : placement.positionPercent , tagOffset : placement.lateralOffset } : item ) }))
}
2026-08-17 09:24:53 +02:00
} else if ( drag . current . kind === 'widget' ) update ( s => { const next = moveBoardPoint ({ x : drag.current ! . originX , y : drag.current ! . originY }, { x : dx , y : dy }, s . viewport . zoom ); return { ... s , exhibits : s.exhibits.map ( exhibit => exhibit . id === drag . current ! . id ? { ... exhibit , ... next } : exhibit ) } })
2026-08-14 12:57:06 +02:00
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 ) => {
2026-08-18 15:37:46 +02:00
if ( folderLongPress . current ? . pointerId === event . pointerId ) {
window . clearTimeout ( folderLongPress . current . timer )
folderLongPress . current = null
}
2026-08-14 15:45:03 +02:00
if ( event . pointerType === 'touch' ) {
touchPoints . current . delete ( event . pointerId )
if ( touchPoints . current . size < 2 ) pinchDistance . current = null
}
2026-08-14 19:37:43 +02:00
const completedDrag = drag . current
if ( completedDrag ) suppressClick . current = Boolean ( completedDrag . moved )
2026-08-14 15:45:03 +02:00
drag . current = null
2026-08-14 18:26:20 +02:00
setDraggingThreadTagId ( null )
2026-08-14 19:37:43 +02:00
setDraggingWidget ( false )
setTrashActive ( false )
2026-08-22 17:02:30 +02:00
if ( completedDrag ? . kind === 'widget' && completedDrag . id && completedDrag . moved && trashTarget . current ) {
const exhibit = byId . get ( completedDrag . id )
if ( exhibit && exhibitWidget ( exhibit . type ). capabilities . discardable ) onDiscardExhibit ( completedDrag . id )
}
2026-08-14 19:37:43 +02:00
trashTarget . current = false
2026-08-14 15:45:03 +02:00
}
2026-08-17 09:24:53 +02:00
const toggleFolder = ( id : string ) => update ( s => ({ ... s , exhibits : s.exhibits.map ( exhibit => exhibit . id === id && exhibit . type === 'folder' ? { ... exhibit , isOpen : ! exhibit . isOpen } : exhibit ) }))
2026-08-18 15:37:46 +02:00
const startFolderLongPress = ( event : React.PointerEvent , id : string ) => {
if ( event . pointerType !== 'touch' || tool !== 'move' || linkFrom || ( event . target as HTMLElement ). closest ( 'button' )) return
if ( folderLongPress . current ) window . clearTimeout ( folderLongPress . current . timer )
const pointerId = event . pointerId
const timer = window . setTimeout (() => {
if ( touchPoints . current . size !== 1 || folderLongPress . current ? . pointerId !== pointerId ) return
folderLongPress . current = null
drag . current = null
trashTarget . current = false
setDraggingWidget ( false )
setTrashActive ( false )
suppressClick . current = true
toggleFolder ( id )
}, 520 )
folderLongPress . current = { pointerId , id , startX : event.clientX , startY : event.clientY , timer }
}
useEffect (() => () => {
if ( folderLongPress . current ) window . clearTimeout ( folderLongPress . current . timer )
}, [])
2026-08-17 09:24:53 +02:00
const widgetContext : ExhibitWidgetContext = { exhibits : state.exhibits , relations : state.relations , dispatch : ( command : WidgetCommand ) => {
if ( command . type === 'open-document' ) onOpenSource ( command . documentId )
else if ( command . type === 'edit-folder' ) onEditFolder ( command . folderId )
else if ( command . type === 'edit-event' ) onEditEvent ( command . eventId )
else if ( command . type === 'edit-party' ) onEditParty ( command . partyId )
else if ( command . type === 'edit-document' ) onEditFile ( command . documentId )
else if ( command . type === 'update-memory-cue' ) onUpdateDocumentCue ( command . documentId , command . cue )
} }
2026-08-14 16:37:50 +02:00
const previewOrigin = linkFrom ? pointForId ( linkFrom ) : undefined
return < div className = { `board-viewport tool- ${ tool } ${ linkFrom ? 'threading' : '' } ` } ref = { boardRef }
2026-08-14 17:59:03 +02:00
onPointerDown = { e => {
const target = e . target as HTMLElement
if ( ! target . closest ( '.thread-tag' )) setExpandedThreadTagId ( null )
const emptyBoardDrag = e . button === 0 && ! target . closest ( '.evidence-card, .source-file-widget, .thread-tag, button' )
if ( e . pointerType === 'touch' || tool === 'hand' || e . button === 1 || emptyBoardDrag ) { e . preventDefault (); pointerDown ( e ) }
}}
2026-08-14 16:37:50 +02:00
onPointerMoveCapture = { trackThreadPointer } onPointerMove = { pointerMove } onPointerLeave = {() => setThreadPointer ( null )} onPointerUp = { finishDrag } onPointerCancel = { finishDrag } onAuxClick = { e => { if ( e . button === 1 ) e . preventDefault () }}>
2026-08-14 19:37:43 +02:00
< div ref = { trashRef } className = { `board-trash ${ draggingWidget ? 'drag-ready' : '' } ${ trashActive ? 'active' : '' } ` } aria-hidden = "true" >< Trash2 size = { 23 }/>< span >< b > DISCARD </ b >< small > DRAG EXHIBIT HERE </ small ></ span ></ div >
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 }>
2026-08-17 09:24:53 +02:00
{ state . connections . map ( connection => { const p1 = pointForId ( connection . fromExhibitId ), p2 = pointForId ( connection . toExhibitId ); if ( ! p1 || ! p2 ) return null ; return < g className = { recentlyCreatedConnectionId === connection . id ? 'tightening' : '' } key = { connection . id }>< path d = { threadCurve ( p1 , p2 , connection . tightness ). path }/>< circle cx = { p1 . x } cy = { p1 . y } r = "4" />< circle cx = { p2 . x } cy = { p2 . y } r = "4" /></ g > })}
2026-08-14 16:37:50 +02:00
{ previewOrigin && threadPointer && < g className = "thread-preview" >< path d = { threadCurve ( previewOrigin , threadPointer , 35 ). path }/>< circle cx = { previewOrigin . x } cy = { previewOrigin . y } r = "4" />< circle cx = { threadPointer . x } cy = { threadPointer . y } r = "3" /></ g >}
2026-08-14 12:43:11 +02:00
</ svg >
2026-08-17 09:24:53 +02:00
{ state . connections . map ( connection => { const p1 = pointForId ( connection . fromExhibitId ), p2 = pointForId ( connection . toExhibitId ); if ( ! p1 || ! p2 ) return null ; const tagPlacement = threadTagPlacement ( p1 , p2 , connection . tightness , connection . tagPosition , connection . tagOffset ); const compact = connection . tagStyle === 'compact' ; const expanded = ! compact && expandedThreadTagId === connection . id ; const dragging = draggingThreadTagId === connection . id ; return < button key = { `tag: ${ connection . id } ` } aria-expanded = { connection . label && ! compact ? expanded : undefined } aria-label = { connection . label ? `Relation tag: ${ connection . label } ` : 'Edit untagged red thread' } className = { `thread-tag ${ connection . label ? `labelled ${ compact ? 'compact' : 'luggage luggage-tag' } ` : 'untagged' } ${ expanded ? 'expanded' : '' } ${ dragging ? 'dragging' : '' } ` } style = {{ left : tagPlacement.x , top : tagPlacement.y }} title = { connection . label ? dragging ? `Position ${ Math . round ( tagPlacement . positionPercent ) } %` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness' } onPointerDown = { event => connection . label ? threadTagPointerDown ( event , connection . id ) : event . stopPropagation ()} onClick = { event => { event . stopPropagation (); if ( suppressClick . current ) { suppressClick . current = false ; return } if ( ! connection . label || compact || expanded ) { setExpandedThreadTagId ( null ); onEditConnection ( connection ) } else setExpandedThreadTagId ( connection . id ) }}>< i />{ dragging && < output className = "thread-tag-position" >{ Math . round ( tagPlacement . positionPercent )} % </ output >}{ connection . label && ( compact ? < span className = "thread-tag-compact-label" >{ connection . label }</ span > : < span className = "thread-tag-content" >< small > RELATION TAG </ small >< b >{ connection . label }</ b >{ expanded && < em > CLICK AGAIN TO EDIT THREAD </ em >}</ span >)}</ button > })}
2026-08-14 14:31:52 +02:00
< svg className = "event-support-lines" width = { BOARD_W } height = { BOARD_H }>
2026-08-17 09:24:53 +02:00
{ state . relations . filter ( relation => relation . type === 'supports' ). map ( relation => { const origin = pointForId ( relation . fromExhibitId ), target = pointForId ( relation . toExhibitId ); return origin && target ? < line key = { relation . id } x1 = { origin . x } y1 = { origin . y } x2 = { target . x } y2 = { target . y }/> : null })}
2026-08-14 14:31:52 +02:00
</ svg >
2026-08-14 14:44:58 +02:00
< svg className = "party-association-lines" width = { BOARD_W } height = { BOARD_H }>
2026-08-17 09:24:53 +02:00
{ state . relations . filter ( relation => relation . type === 'concerns' ). map ( relation => { const origin = pointForId ( relation . fromExhibitId ), target = pointForId ( relation . toExhibitId ); return origin && target ? < line key = { relation . id } x1 = { origin . x } y1 = { origin . y } x2 = { target . x } y2 = { target . y }/> : null })}
2026-08-14 14:44:58 +02:00
</ svg >
2026-08-14 12:43:11 +02:00
< svg className = "folder-bands" width = { BOARD_W } height = { BOARD_H }>
2026-08-17 09:24:53 +02:00
{ containmentRelations . map ( relation => { const folder = byId . get ( relation . fromExhibitId ), document = byId . get ( relation . toExhibitId ); if ( folder ? . type !== 'folder' || document ? . type !== 'document' ) return null ; const origin = { x : folder.x + folder . width / 2 , y : folder.y + 78 }; return < line className = { folder . isOpen ? 'open' : 'closed' } key = { relation . id } x1 = { origin . x } y1 = { origin . y } x2 = { folder . isOpen ? document . x + document . width / 2 : origin.x } y2 = { folder . isOpen ? document . y + document . height / 2 : origin.y }/> })}
2026-08-14 12:43:11 +02:00
</ svg >
2026-08-22 14:53:23 +02:00
{ evidenceExhibits ( state . exhibits ). filter ( exhibit => ! exhibit . hidden ). map (( ev , i ) => { const containedDocuments = containedIds ( state , ev . id ). flatMap ( id => { const document = byId . get ( id ); return document ? . type === 'document' ? [ document ] : [] }); const locatedDocumentId = ev . type === 'folder' && ! ev . isOpen && containedDocuments . some ( document => document . id === locatorDocumentId ) ? locatorDocumentId : undefined ; const definition = exhibitWidget ( ev . type ); const Widget = definition . Component ; const containsArrival = ev . type === 'folder' && containedDocuments . some ( document => arrivingExhibitIds . includes ( document . id )); return < article key = { ev . id } tabIndex = { ev . type === 'folder' ? 0 : undefined } aria-expanded = { ev . type === 'folder' ? ev.isOpen : undefined } title = { ev . type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined } data-document-locator-target = { locatedDocumentId } data-temporal-id = { `widget: ${ ev . id } ` } className = { `evidence-card ${ definition . visualType } ${ ev . type === 'note' ? 'luggage-tag' : '' } ${ selected === ev . id ? 'selected' : '' } ${ locatedDocumentId ? 'document-located' : '' } ${ linkFrom === ev . id ? 'linking' : '' } ${ linkFrom && linkFrom !== ev . id ? 'thread-target' : '' } ${ recentlyCreatedExhibitId === ev . id || arrivingExhibitIds . includes ( ev . id ) || containsArrival ? 'arriving' : '' } ` } style = {{ left : ev.x , top : ev.y , width : ev.width , height : ev.height , rotate : ` ${ ev . rotation + ( i % 3 - 1 ) * . 45 } deg` , zIndex : ev.zIndex }}
2026-08-18 15:37:46 +02:00
onPointerDown = { e => { e . stopPropagation (); if ( linkFrom && e . button === 0 ) return ; if ( ev . type === 'folder' ) startFolderLongPress ( e , ev . id ); 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-18 15:37:46 +02:00
onAuxClick = { e => { if ( e . button === 1 ) e . preventDefault () }} onClick = { e => { e . stopPropagation (); if ( suppressClick . current ) { suppressClick . current = false ; return } if ( ev . type === 'folder' && e . detail > 1 ) return ; if ( tool === 'move' ) onCardClick ( ev . id ) }} onDoubleClick = { e => { e . stopPropagation (); if ( ev . type === 'folder' && tool === 'move' && ! linkFrom && ! ( e . target as HTMLElement ). closest ( 'button' )) toggleFolder ( ev . id ) }} onKeyDown = { e => { if ( ev . type === 'folder' && e . target === e . currentTarget && ( e . key === 'Enter' || e . key === ' ' )) { e . preventDefault (); toggleFolder ( ev . id ) } }}>
2026-08-17 09:24:53 +02:00
< header >< span >{ definition . heading ( ev , widgetContext )}</ span >< i >{ String ( i + 1 ). padStart ( 3 , '0' )}</ i ></ header >
< Widget exhibit = { ev } context = { widgetContext }/>
2026-08-14 12:43:11 +02:00
</ article >})}
2026-08-22 14:53:23 +02:00
{ documentExhibits ( state . exhibits ). filter ( document => ! document . hidden ). map ( document => { const membership = containmentRelations . find ( relation => relation . toExhibitId === document . id ); const folder = membership ? byId . get ( membership . fromExhibitId ) : undefined ; const open = folder ? . type !== 'folder' || folder . isOpen ; const located = open && locatorDocumentId === document . id ; const left = open ? document . x : folder.x + folder . width / 2 - document . width / 2 , top = open ? document . y : folder.y + 45 ; const definition = documentWidget ( document . fileType ); const Preview = definition . Preview ; const source = document . assetId ? `/api/assets/ ${ encodeURIComponent ( document . assetId ) } ` : '' ; return < article key = { document . id } data-document-locator-target = { located ? document . id : undefined } data-temporal-id = { `widget: ${ document . id } ` } className = { `source-file-widget ${ open ? 'open' : 'closed' } file-type- ${ document . fileType } ${ selected === document . id ? 'selected' : '' } ${ located ? 'document-located' : '' } ${ linkFrom === document . id ? 'linking' : '' } ${ linkFrom && linkFrom !== document . id ? 'thread-target' : '' } ${ arrivingExhibitIds . includes ( document . id ) ? 'arriving' : '' } ` } style = {{ left , top , width : document.width , height : document.height , rotate : ` ${ document . rotation } deg` , zIndex : document.zIndex }}
2026-08-17 09:24:53 +02:00
onPointerDown = { event => { event . stopPropagation (); if ( linkFrom && event . button === 0 ) return ; if ( tool === 'hand' || event . button === 1 ) { event . preventDefault (); pointerDown ( event ) } else if ( open && event . button === 0 ) pointerDown ( event , { kind : 'widget' , id : document.id }) }}
onPointerMove = { event => { event . stopPropagation (); pointerMove ( event ) }} onPointerUp = { event => { event . stopPropagation (); finishDrag ( event ) }} onPointerCancel = { event => { event . stopPropagation (); finishDrag ( event ) }} onClick = { event => { event . stopPropagation (); if ( suppressClick . current ) { suppressClick . current = false ; return } if ( ! open ) return ; if ( tool === 'move' ) onCardClick ( document . id ) }} onDoubleClick = {() => open && ! linkFrom && onOpenSource ( document . id )}>
2026-08-22 17:02:30 +02:00
< header >< span >{ definition . label . toUpperCase ()}</ span >< i >{ String ( document . displayNumber || ( membership ? . sortOrder || 0 ) + 1 ). padStart ( 2 , '0' )}</ i ></ header >
2026-08-14 18:30:31 +02:00
< div className = "source-file-preview" >< Preview document = { document } source = { source } onMemoryCue = { cue => onUpdateDocumentCue ( document . id , cue )}/></ div >
2026-08-17 09:24:53 +02:00
< strong >{ document . title }</ strong >< time >{ document . publishedAt ? . slice ( 0 , 10 ) || 'UNDATED' }</ time >
2026-08-14 16:37:50 +02:00
< div className = "source-file-actions" >< button onClick = { event => { event . stopPropagation (); onOpenSource ( document . id ) }}>< BookOpen size = { 12 }/> OPEN </ button >< button onClick = { event => { event . stopPropagation (); onEditFile ( document . id ) }}>< Pencil size = { 12 }/> METADATA </ button ></ div >
2026-08-14 12:43:11 +02:00
</ article > })}
</ div >
</ div >
}
2026-08-15 09:34:53 +02:00
function DocumentLocatorBeam ({ documentId , layoutKey } : { documentId : string | null ; layoutKey : string }) {
const [ beam , setBeam ] = useState < { path : string ; x : number ; y : number } | null > ( null )
useLayoutEffect (() => {
if ( ! documentId ) { setBeam ( null ); return }
let frame = 0
let animateUntil = Date . now () + 500
const matchingElement = ( attribute : 'documentRowId' | 'documentLocatorTarget' ) => Array . from ( document . querySelectorAll < HTMLElement >( attribute === 'documentRowId' ? '[data-document-row-id]' : '[data-document-locator-target]' )). find ( element => element . dataset [ attribute ] === documentId )
const measure = () => {
cancelAnimationFrame ( frame )
frame = requestAnimationFrame ( function measureFrame() {
const source = matchingElement ( 'documentRowId' )
const target = matchingElement ( 'documentLocatorTarget' )
const viewport = document . querySelector < HTMLElement >( '.board-viewport' )
if ( ! source || ! target || ! viewport ) { setBeam ( null ); return }
const from = source . getBoundingClientRect (), to = target . getBoundingClientRect (), bounds = viewport . getBoundingClientRect ()
const x1 = Math . min ( from . right - 3 , bounds . left - 3 )
const y1 = from . top + from . height / 2
const x2 = Math . max ( bounds . left + 12 , Math . min ( bounds . right - 12 , to . left + to . width / 2 ))
const y2 = Math . max ( bounds . top + 12 , Math . min ( bounds . bottom - 12 , to . top + to . height / 2 ))
const bend = Math . max ( 70 , Math . abs ( x2 - x1 ) * . 32 )
setBeam ({ path : `M ${ x1 } ${ y1 } C ${ x1 + bend } ${ y1 } , ${ x2 - bend } ${ y2 } , ${ x2 } ${ y2 } ` , x : x2 , y : y2 })
if ( Date . now () < animateUntil ) frame = requestAnimationFrame ( measureFrame )
})
}
measure ()
const observer = new ResizeObserver ( measure )
const observed = [ matchingElement ( 'documentRowId' ), matchingElement ( 'documentLocatorTarget' ), document . querySelector < HTMLElement >( '.board-viewport' )]. filter (( element ) : element is HTMLElement => Boolean ( element ))
observed . forEach ( element => observer . observe ( element ))
const handleLayoutChange = () => { animateUntil = Date . now () + 500 ; measure () }
window . addEventListener ( 'resize' , handleLayoutChange )
document . querySelector ( '.doc-list' ) ? . addEventListener ( 'scroll' , handleLayoutChange , { passive : true })
return () => {
cancelAnimationFrame ( frame )
observer . disconnect ()
window . removeEventListener ( 'resize' , handleLayoutChange )
document . querySelector ( '.doc-list' ) ? . removeEventListener ( 'scroll' , handleLayoutChange )
}
}, [ documentId , layoutKey ])
if ( ! beam ) return null
return < svg className = "document-locator-beam" aria-hidden = "true" >
< defs >< filter id = "document-locator-glow" x = "-40%" y = "-40%" width = "180%" height = "180%" >< feGaussianBlur stdDeviation = "4" result = "blur" />< feMerge >< feMergeNode in = "blur" />< feMergeNode in = "SourceGraphic" /></ feMerge ></ filter ></ defs >
< path className = "document-locator-halo" d = { beam . path }/>< path className = "document-locator-ray" d = { beam . path }/>< circle cx = { beam . x } cy = { beam . y } r = "8" />< circle className = "document-locator-pulse" cx = { beam . x } cy = { beam . y } r = "15" />
</ svg >
}
2026-08-14 12:43:11 +02:00
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 ) } })
2026-08-17 09:24:53 +02:00
return < footer className = "timeline" >< div className = "timeline-label" >< small > TEMPORAL INDEX </ small >< b > TIMELINE </ b >< button onClick = { onEdit }>{ range ? ` ${ range . start } — ${ range . end } ` : ` ${ items . length } DATED ITEMS · AUTO` }</ button ></ div >< div className = "timeline-track" >< div className = "axis" />{ ticks . map (( tick , index ) => < span className = "year" key = { ` ${ tick . value } : ${ index } ` } style = {{ left : ` ${ timelinePositionPercent ( new Date ( tick . value ). toISOString (), { start , end } )}%` }}>{ tick . label }</ span >)}{ items . map (( item , i ) => < button key = { item . id } data-marker-id = { item . id } className = { `marker ${ item . kind } ${ item . exhibitId === selected ? 'selected' : '' } ` } style = {{ left : ` ${ position ( item . date ) } %` , top : i % 2 ? 12 : 31 }} onClick = {() => onSelect ( item )} title = { ` ${ item . date . slice ( 0 , 10 ) } — ${ item . label } ` }>< i /></ button >)}</ div >< div className = "timeline-key" >< span >< i /> SOURCE </ span >< span className = "amber" >< i /> SELECTED </ span ></ div ></ footer >
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-22 17:02:30 +02:00
function CaseReportPanel ({ report , defaultInvestigator , hasNext , onClose , onSubmit , onContinue } : {
report :CaseReport ; defaultInvestigator :string ; hasNext :boolean ; onClose : () => void
onSubmit : ( input :CaseReportSubmissionInput ) => Promise < CaseReport >; onContinue : () => void
}) {
const [ investigatorName , setInvestigatorName ] = useState ( report . investigatorName || defaultInvestigator )
const [ evidence , setEvidence ] = useState < CaseReportSubmissionInput [ 'evidence' ] >(() => report . claims . flatMap ( claim => claim . evidence . map ( item => ({
connectionId :item.connectionId , documentExhibitId :item.documentExhibitId , relationText :item.relationText || 'Proof that…' ,
publishedAt :item.publishedAt?.slice ( 0 , 10 ), sourceCitation :item.sourceCitation , sourceUri :item.sourceUri ,
}))))
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState ( '' )
const updateEvidence = ( connectionId :string , patch :Partial < CaseReportSubmissionInput [ 'evidence' ][ number ] >) => setEvidence ( items => items . map ( item => item . connectionId === connectionId ? { ... item ,... patch } : item ))
const submit = async ( event :React.FormEvent ) => {
event . preventDefault (); setBusy ( true ); setError ( '' )
try { await onSubmit ({ investigatorName , evidence }) }
catch ( reason ) { setError ( reason instanceof Error ? reason . message : 'Report submission failed' ) }
finally { setBusy ( false ) }
}
const draftByConnection = new Map ( evidence . map ( item => [ item . connectionId , item ]))
return < div className = "modal-shade case-report-shade" >< form className = "window case-report" onSubmit = { event => void submit ( event )}>
< header >< ClipboardCheck size = { 16 }/>< b >{ report . title }</ b >< span />< button type = "button" aria-label = "Close case report" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div className = "case-report-paper" >< div className = "case-report-letterhead" >< small > GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME </ small >< h2 > CASE REPORT </ h2 >< span > FORM GUPI – 7 / EVIDENTIARY FINDING </ span ></ div >
{ report . claims . map (( claim , claimIndex ) => < section className = "report-claim" key = { claim . claimExhibitId }>
< h3 >< span > CLAIM { claimIndex + 1 }</ span >{ claim . statement }</ h3 >
< h4 > EVIDENCE </ h4 >
{ claim . evidence . length === 0 ? < div className = "report-empty-evidence" > No source evidence is connected to this claim . Return to the board and use red thread to attach a document .</ div >
: claim . evidence . map ( item => { const draft = draftByConnection . get ( item . connectionId ) ! ; return < article className = { `report-evidence ${ item . evidenceAccepted ? 'verified' : '' } ` } key = { item . connectionId }>
< div className = "report-evidence-heading" >< b > Exhibit { item . displayNumber }</ b >< span >{ item . fileType . replaceAll ( '_' , ' ' )} · { item . documentTitle }</ span >{ item . evidenceAccepted && < em > CONTENT VERIFIED </ em >}</ div >
< label >< span > EVIDENTIARY STATEMENT </ span >< textarea aria-label = { `Evidence statement for Exhibit ${ item . displayNumber } ` } rows = { 2 } value = { draft . relationText } onChange = { event => updateEvidence ( item . connectionId ,{ relationText :event.target.value })}/></ label >
< div className = "report-fields" >< label >< span > DATED </ span >< input aria-label = { `Date for Exhibit ${ item . displayNumber } ` } type = "date" value = { draft . publishedAt || '' } onChange = { event => updateEvidence ( item . connectionId ,{ publishedAt :event.target.value || undefined })}/></ label >
< label >< span > SOURCE / PUBLICATION </ span >< input aria-label = { `Source for Exhibit ${ item . displayNumber } ` } value = { draft . sourceCitation || '' } placeholder = "e.g. Google Patents · GB695913A" onChange = { event => updateEvidence ( item . connectionId ,{ sourceCitation :event.target.value })}/></ label ></ div >
< label >< span > SOURCE LINK · IF AVAILABLE </ span >< input aria-label = { `Source link for Exhibit ${ item . displayNumber } ` } type = "url" value = { draft . sourceUri || '' } placeholder = "https://…" onChange = { event => updateEvidence ( item . connectionId ,{ sourceUri :event.target.value })}/></ label >
</ article > })}
</ section >)}
< label className = "report-investigator" >< span > INVESTIGATOR </ span >< input aria-label = "Investigator name" value = { investigatorName } maxLength = { 300 } onChange = { event => setInvestigatorName ( event . target . value )}/></ label >
{ report . status !== 'draft' && < section className = { `report-verdict ${ report . status } ` } role = "status" >< small >{ report . status === 'accepted' ? 'REPORT ACCEPTED' : report . status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED' }</ small >< p >{ report . feedback }</ p ></ section >}
{ error && < p className = "flag-error" >{ error }</ p >}
< div className = "case-report-actions" >< button type = "button" onClick = { onClose }> RETURN TO BOARD </ button >{ report . status === 'accepted'
? < button className = "primary" type = "button" onClick = { onContinue }>{ hasNext ? 'CONTINUE' : 'CLOSE CASE' } < span > ▸ </ span ></ button >
: < button className = "primary" type = "submit" disabled = { busy }>{ busy ? 'FILING…' : 'SUBMIT CASE REPORT' }</ button >}</ div >
</ div >
</ form ></ div >
}
2026-08-14 16:37:50 +02:00
function ThreadEditor ({ connection , sourceName , targetName , isNew , onClose , onSave , onRemove } : { connection : Connection ; sourceName : string ; targetName : string ; isNew : boolean ; onClose : () => void ; onSave : ( connection : Connection ) => void ; onRemove : () => void }) {
const [ label , setLabel ] = useState ( connection . label || '' )
const [ tightness , setTightness ] = useState ( connection . tightness ?? 65 )
2026-08-14 17:07:30 +02:00
const [ tagStyle , setTagStyle ] = useState < 'luggage' | 'compact' > ( connection . tagStyle === 'compact' ? 'compact' : 'luggage' )
2026-08-14 18:26:20 +02:00
const [ tagPosition , setTagPosition ] = useState ( connection . tagPosition ?? 50 )
const save = ( tag = label ) => {
const lateralLimit = threadTagLateralLimit ( tightness )
onSave ({ ... connection , label : tag.trim () || undefined , tightness , tagStyle , tagPosition ,
tagOffset : Math.max ( - lateralLimit , Math . min ( lateralLimit , connection . tagOffset ?? 0 )) })
}
2026-08-14 16:37:50 +02:00
return < div className = "modal-shade" >< form className = "window thread-editor" onSubmit = { event => { event . preventDefault (); save () }}>
< header >< Link2 size = { 16 }/>< b >{ isNew ? 'Add relation tag' : 'Edit red thread' }</ b >< span />< button type = "button" aria-label = "Close thread editor" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div >< small > RED THREAD · INVESTIGATOR RELATION </ small >< div className = "thread-endpoints" >< b >{ sourceName }</ b >< i />< b >{ targetName }</ b ></ div >
2026-08-22 17:02:30 +02:00
< p > What does this connection prove ? Complete the sentence on the luggage tag ; it will also appear in the Case Report .</ p >
< label className = "field" >< span > EVIDENTIARY STATEMENT · OPTIONAL </ span >< input aria-label = "Thread tag" autoFocus placeholder = "Proof that…" value = { label } onChange = { event => setLabel ( event . target . value )}/></ label >
2026-08-14 17:07:30 +02:00
< fieldset className = "tag-style-picker" >< legend > TAG PRESENTATION </ legend >< label className = { tagStyle === 'luggage' ? 'selected' : '' }>< input type = "radio" name = "tag-style" value = "luggage" checked = { tagStyle === 'luggage' } onChange = {() => setTagStyle ( 'luggage' )}/>< span className = "tag-style-luggage" >< i /> LUGGAGE </ span >< small > Expressive · rotates to read </ small ></ label >< label className = { tagStyle === 'compact' ? 'selected' : '' }>< input type = "radio" name = "tag-style" value = "compact" checked = { tagStyle === 'compact' } onChange = {() => setTagStyle ( 'compact' )}/>< span className = "tag-style-compact" >< i /> COMPACT </ span >< small > Quiet · less board clutter </ small ></ label ></ fieldset >
2026-08-14 18:26:20 +02:00
< label className = "field thread-position-control" >< span > TAG POSITION < output >{ tagPosition } % </ output ></ span >< input aria-label = "Tag position" type = "range" min = "5" max = "95" step = "1" value = { tagPosition } onChange = { event => setTagPosition ( Number ( event . target . value ))}/>< small > Drag the tag on the board for along - thread position and tension - limited lateral play .</ small ></ label >
2026-08-14 16:37:50 +02:00
< label className = "field thread-tightness" >< span > THREAD TIGHTNESS < output >{ tightness } % </ output ></ span >< input aria-label = "Thread tightness" type = "range" min = "0" max = "100" step = "5" value = { tightness } onChange = { event => setTightness ( Number ( event . target . value ))}/>< small >< span > SLACK </ span >< span > TAUT </ span ></ small ></ label >
< div className = "folder-editor-actions" >{ ! isNew && < button className = "danger" type = "button" onClick = { onRemove }> REMOVE THREAD </ button >}< span />{ isNew && < button type = "button" onClick = {() => save ( '' )}> SKIP TAG </ button >}< button className = "primary" type = "submit" >{ isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD' }</ button ></ div >
</ div >
</ form ></ div >
}
2026-08-22 16:02:10 +02:00
function BriefPanel ({ brief , goals , parties , recentlyCreatedExhibitId , canEdit , onClose , onEdit , onClassify , onNewParty , onLocate , onEditParty } : { brief : LevelBrief ; goals : LevelGoal []; parties : PartyExhibit []; recentlyCreatedExhibitId : string | null ; canEdit : boolean ; onClose : () => void ; onEdit : () => void ; onClassify : ( id : string , kind : PartyKind ) => void ; onNewParty : () => void ; onLocate : ( id : string ) => void ; onEditParty : ( id : string ) => void }) {
2026-08-14 18:33:04 +02:00
const [ minimized , setMinimized ] = useState ( false )
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
2026-08-22 16:02:10 +02:00
const pending = goals . filter ( goal => goal . status === 'pending' ). length
const heading = goals . length ? brief . concepts . length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
return < aside className = { `brief-panel ${ minimized ? 'minimized' : '' } ` }>< header onDoubleClick = {() => setMinimized ( value => ! value )}>< div >< small > LEVEL BRIEF · { pending ? ` ${ pending } OBJECTIVE ${ pending === 1 ? '' : 'S' } OPEN` : unresolved ? ` ${ unresolved } UNRESOLVED` : 'READY' }</ small >< b >{ heading }</ b ></ div >< span />< button type = "button" aria-label = { minimized ? 'Restore brief' : 'Minimize brief' } title = { minimized ? 'Restore' : 'Minimize' } onDoubleClick = { event => event . stopPropagation ()} onClick = {() => setMinimized ( value => ! value )}>{ minimized ? < Plus size = { 14 }/> : < Minus size = { 14 }/>}</ button >< button type = "button" aria-label = "Close brief" title = "Close" onDoubleClick = { event => event . stopPropagation ()} 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-22 16:02:10 +02:00
{ goals . length > 0 && < div className = "brief-goals" >{ goals . map (( goal , index ) => < section className = { goal . status } key = { goal . key }>< i >{ goal . status === 'complete' ? '✓' : index + 1 }</ i >< div >< b >{ goal . title }</ b >< span >{ goal . instructions }</ span ></ div >< em >{ goal . status === 'complete' ? 'VERIFIED' : 'OPEN' }</ em ></ section >)}</ div >}
{ brief . concepts . length > 0 && <>< 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 >
< button className = "new-party-from-brief" onClick = { onNewParty }>< Plus size = { 13 }/> CREATE PARTY NOT LISTED ABOVE </ button ></>}
2026-08-14 14:44:58 +02:00
{ canEdit && < button className = "edit-brief" onClick = { onEdit }>< Pencil size = { 13 }/> EDIT BRIEF & CONCEPTS </ button >}
2026-08-22 16:02:10 +02:00
< button className = "dismiss-brief" onClick = { onClose }>{ pending || unresolved === brief . concepts . length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD' }</ button >
2026-08-14 14:44:58 +02:00
</ aside >
}
2026-08-22 16:02:10 +02:00
function GoalComplete ({ goal , hasNext , busy , onContinue } : { goal : LevelGoal ; hasNext : boolean ; busy : boolean ; onContinue : () => void }) {
return < div className = "goal-complete-shade" role = "dialog" aria-modal = "true" aria-labelledby = "goal-complete-title" >
< section className = "goal-complete-card" >
< div className = "goal-complete-signal" >< i />< span > GU - NET SOURCE AUTHENTICATION </ span >< i /></ div >
< small > OBJECTIVE COMPLETE </ small >
< h2 id = "goal-complete-title" >{ goal . title }</ h2 >
< p >{ goal . completionMessage || 'The submitted evidence satisfies this objective.' }</ p >
< div className = "goal-complete-stamp" > SOURCE < br />< b > VERIFIED </ b ></ div >
< button type = "button" disabled = { busy } onClick = { onContinue }>{ busy ? 'OPENING NEXT FILE…' : hasNext ? 'CONTINUE' : 'RETURN TO BOARD' } < span > ▸ </ span ></ button >
</ section >
</ div >
}
2026-08-14 14:44:58 +02:00
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 >
}
2026-08-17 09:24:53 +02:00
function PartyEditor ({ party , exhibits , relations , creating = false , onClose , onSave } : { party : PartyExhibit ; exhibits : Exhibit []; relations : ExhibitRelation []; creating? : boolean ; onClose : () => void ; onSave : ( party : PartyExhibit , related : string []) => void }) {
2026-08-14 14:44:58 +02:00
const [ name , setName ] = useState ( party . title )
const [ summary , setSummary ] = useState ( party . content )
2026-08-17 09:24:53 +02:00
const [ partyKind , setPartyKind ] = useState < PartyKind >( party . partyKind )
2026-08-14 14:44:58 +02:00
const [ organizationKind , setOrganizationKind ] = useState < OrganizationKind >( party . organizationKind || 'business' )
2026-08-17 09:24:53 +02:00
const [ aliases , setAliases ] = useState ( party . aliases . join ( '\n' ))
const [ related , setRelated ] = useState ( relations . filter ( relation => relation . type === 'concerns' && relation . fromExhibitId === party . id ). map ( relation => relation . toExhibitId ))
const candidates = exhibits . filter ( item => item . id !== party . id && item . type !== 'party' ). map ( item => ({ id : item.id , title : item.title , kind : item.type === 'document' ? item . fileType . toUpperCase () : item . type . toUpperCase () }))
2026-08-14 14:44:58 +02:00
const toggle = ( id : string ) => setRelated ( current => current . includes ( id ) ? current . filter ( item => item !== id ) : [... current , id ])
2026-08-17 09:24:53 +02:00
return < div className = "modal-shade" >< form className = "window folder-editor party-editor" onSubmit = { submit => { submit . preventDefault (); onSave ({ ... party , partyKind , title : name.trim (), content : summary.trim (), organizationKind : partyKind === 'organization' ? organizationKind : undefined , aliases : aliases.split ( '\n' ). map ( item => item . trim ()). filter ( Boolean ) }, related ) }}>
2026-08-14 18:17:31 +02:00
< header >{ partyKind === 'person' ? < UserRound size = { 16 }/> : < Building2 size = { 16 }/>}< b >{ creating ? 'Create party dossier' : `Edit ${ partyKind } dossier` }</ b >< span />< button type = "button" aria-label = "Close party editor" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div className = "folder-editor-body" >< small > PARTY EXHIBIT · { partyKind . toUpperCase ()}</ small >
{ creating && < label className = "field" >< span > PARTY TYPE </ span >< select aria-label = "Party type" value = { partyKind } onChange = { event => setPartyKind ( event . target . value as PartyKind )}>< option value = "person" > Person </ option >< option value = "organization" > Organization </ option ></ select ></ label >}
< label className = "field" >< span > DISPLAY NAME </ span >< input aria-label = "Party name" required value = { name } onChange = { event => setName ( event . target . value )}/></ label >
2026-08-14 14:44:58 +02:00
< label className = "field" >< span > DOSSIER SUMMARY </ span >< textarea aria-label = "Party summary" rows = { 3 } value = { summary } onChange = { event => setSummary ( event . target . value )}/></ label >
2026-08-14 18:17:31 +02:00
{ 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 >}
2026-08-14 14:44:58 +02:00
< 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-17 09:24:53 +02:00
function FolderEditor ({ folder , memberIds , documents , canManageContents , onClose , onSave } : { folder : FolderExhibit ; memberIds : string []; documents : CaseDocument []; canManageContents : boolean ; onClose : () => void ; onSave : ( folder : FolderExhibit , members : string []) => void }) {
2026-08-14 12:43:11 +02:00
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 ()
2026-08-17 09:24:53 +02:00
onSave ({ ... folder , title : title.trim () || 'UNTITLED EVIDENCE FOLDER' , content : content.trim () }, members )
2026-08-14 12:43:11 +02:00
}
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 >
2026-08-17 09:24:53 +02:00
< span className = "folder-member-date" >{ document . publishedAt ? . slice ( 0 , 10 ) || 'UNDATED' }</ span >
2026-08-14 12:43:11 +02:00
</ 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-17 09:24:53 +02:00
function EventEditor ({ event , exhibits , relations , onClose , onSave } : { event : EventExhibit ; exhibits : Exhibit []; relations : ExhibitRelation []; onClose : () => void ; onSave : ( event : EventExhibit , supports : string []) => void }) {
2026-08-14 14:31:52 +02:00
const [ title , setTitle ] = useState ( event . title )
const [ narrative , setNarrative ] = useState ( event . content )
const [ occurredAt , setOccurredAt ] = useState ( localDateTime ( event . eventDate ))
2026-08-17 09:24:53 +02:00
const [ supports , setSupports ] = useState ( relations . filter ( relation => relation . type === 'supports' && relation . fromExhibitId === event . id ). map ( relation => relation . toExhibitId ))
const candidates = exhibits . filter ( item => item . id !== event . id && item . type !== 'event' ). map ( item => ({ id : item.id , title : item.title , kind : item.type === 'document' ? item . fileType . replaceAll ( '_' , ' ' ). toUpperCase () : item . type . toUpperCase () }))
2026-08-14 14:31:52 +02:00
const toggle = ( id : string ) => setSupports ( current => current . includes ( id ) ? current . filter ( item => item !== id ) : [... current , id ])
const submit = ( submitEvent : React.FormEvent ) => {
submitEvent . preventDefault ()
2026-08-14 19:57:22 +02:00
const eventDate = occurredAt ? new Date ( occurredAt ). toISOString () : undefined
2026-08-17 09:24:53 +02:00
onSave ({ ... event , title : title.trim () || 'UNTITLED EVENT' , content : narrative.trim () || 'Something happened.' , eventDate }, supports )
2026-08-14 14:31:52 +02:00
}
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 >
2026-08-14 19:57:22 +02:00
< label className = "field event-date-field" >< span >< CalendarClock size = { 13 }/> OCCURRED AT · LOCAL · OPTIONAL </ span >< span >< input aria-label = "Occurred at" type = "datetime-local" value = { occurredAt } onChange = { change => setOccurredAt ( change . target . value )}/>< button type = "button" disabled = { ! occurredAt } onClick = {() => setOccurredAt ( '' )}> CLEAR DATE </ button ></ span ></ label >
2026-08-14 14:31:52 +02:00
< 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 >
2026-08-14 19:57:22 +02:00
< p className = "folder-editor-note" > An event is an investigator assertion . Leave occurrence time blank when it is unknown ; creation time is never used for timeline placement .</ p >
2026-08-14 14:31:52 +02:00
< 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-22 14:53:23 +02:00
function FileEditor ({ document , canEditGates , onClose , onSave } : { document : CaseDocument ; canEditGates : boolean ; onClose : () => void ; onSave : ( document : CaseDocument ) => void }) {
2026-08-14 12:43:11 +02:00
const [ title , setTitle ] = useState ( document . title )
const [ fileType , setFileType ] = useState < SourceFileType >( document . fileType )
2026-08-17 09:24:53 +02:00
const [ publishedTime , setPublishedTime ] = useState ( localDateTime ( document . publishedAt ))
2026-08-22 14:53:23 +02:00
const [ requiredFlags , setRequiredFlags ] = useState (( document . requiredFlags || []). join ( ', ' ))
2026-08-14 12:43:11 +02:00
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
2026-08-22 14:53:23 +02:00
onSave ({ ... document , title : title.trim () || document . fileName || 'UNTITLED FILE' , fileType , publishedAt ,
requiredFlags : canEditGates ? [... new Set ( requiredFlags . split ( ',' ). map ( value => value . trim (). toLowerCase ()). filter ( Boolean ))] : document . requiredFlags ,
metadata : Object.fromEntries ( metadata . filter ( row => row . key . trim ()). map ( row => [ row . key . trim (), row . value ])) })
2026-08-14 12:43:11 +02:00
}
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 >
2026-08-22 14:53:23 +02:00
{ canEditGates && < label className = "field gate-field" >< span > REVEAL FLAGS · ALL REQUIRED </ span >< input value = { requiredFlags } placeholder = "tip.received, archive.unlocked" pattern = "[a-z0-9_.\-, ]*" onChange = { event => setRequiredFlags ( event . target . value )}/>< small > Leave blank to show this document when the level first loads .</ small ></ label >}
2026-08-14 12:43:11 +02:00
< 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 >
}
2026-08-22 14:53:23 +02:00
function LevelFlagsEditor ({ levelId , onClose , onChanged } : { levelId : string ; onClose : () => void ; onChanged : () => void | Promise < void > }) {
const [ flags , setFlags ] = useState < LevelFlag [] >([])
const [ newKey , setNewKey ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState ( '' )
const load = useCallback ( async () => {
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( levelId ) } /flags` )
if ( ! response . ok ) throw new Error ( 'Could not load level flags' )
setFlags ( await response . json ())
}, [ levelId ])
useEffect (() => { void load (). catch ( error => setError ( error instanceof Error ? error . message : 'Could not load flags' )) }, [ load ])
const setEarned = async ( key : string , earned : boolean ) => {
setBusy ( true ); setError ( '' )
try {
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( levelId ) } /flags/ ${ encodeURIComponent ( key ) } ` , { method : earned ? 'PUT' : 'DELETE' })
if ( ! response . ok ) { const body = await response . json (). catch (() => ({})); throw new Error ( body . error || 'Could not update flag' ) }
await Promise . all ([ load (), onChanged ()])
setNewKey ( '' )
} catch ( error ) { setError ( error instanceof Error ? error . message : 'Could not update flag' ) } finally { setBusy ( false ) }
}
const submit = ( event : React.FormEvent ) => {
event . preventDefault ()
const key = newKey . trim (). toLowerCase ()
if ( key ) void setEarned ( key , true )
}
return < div className = "modal-shade" >< section className = "window flags-editor" >
< header >< Network size = { 16 }/>< b > Level flags </ b >< span />< button type = "button" aria-label = "Close level flags" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div className = "flags-editor-body" >< small > ACHIEVEMENTS / DOCUMENT REVEALS </ small >
< p > Documents remain server - hidden until every flag assigned in their metadata has been earned .</ p >
< div className = "flag-list" >{ flags . length === 0 && < div className = "flag-empty" > NO FLAGS OR DOCUMENT GATES IN THIS LEVEL </ div >}{ flags . map ( flag => < div className = { `flag-row ${ flag . earnedAt ? 'earned' : '' } ` } key = { flag . key }>< div >< b >{ flag . key }</ b >< small >{ flag . gatedDocumentCount } GATED DOCUMENT { flag . gatedDocumentCount === 1 ? '' : 'S' }</ small ></ div >< button disabled = { busy } onClick = {() => void setEarned ( flag . key , ! flag . earnedAt )}>{ flag . earnedAt ? 'REVOKE' : 'AWARD' }</ button ></ div >)}</ div >
< form className = "flag-add" onSubmit = { submit }>< input aria-label = "New flag key" value = { newKey } placeholder = "tip.received" pattern = "[a-z][a-z0-9_.-]{0,63}" onChange = { event => setNewKey ( event . target . value . toLowerCase ())}/>< button disabled = { busy || ! newKey . trim ()} type = "submit" > AWARD FLAG </ button ></ form >
{ error && < p className = "flag-error" >{ error }</ p >}
</ div >
</ section ></ div >
}
type MatchRuleDraft = {
id? : string
name : string
2026-08-22 16:02:10 +02:00
sourceLabel : string
sourceUri : string
2026-08-22 14:53:23 +02:00
flagKey : string
minimumAnchorMatches : number
enabled : boolean
anchors : { id : string ; phrase : string ; minimumSimilarity : number }[]
}
2026-08-22 16:02:10 +02:00
const emptyMatchRule = () : MatchRuleDraft => ({ name : '' , sourceLabel : '' , sourceUri : '' , flagKey : '' , minimumAnchorMatches : 1 , enabled : true ,
2026-08-22 14:53:23 +02:00
anchors : [{ id : uid ( 'anchor' ), phrase : '' , minimumSimilarity : 0.72 }] })
function EvidenceMatchRulesEditor ({ levelId , onClose } : { levelId : string ; onClose : () => void }) {
const [ rules , setRules ] = useState < EvidenceMatchRuleDefinition [] >([])
const [ draft , setDraft ] = useState < MatchRuleDraft >( emptyMatchRule )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState ( '' )
const load = useCallback ( async () => {
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( levelId ) } /evidence-match-rules` )
if ( ! response . ok ) throw new Error ( 'Could not load evidence match rules' )
setRules ( await response . json ())
}, [ levelId ])
useEffect (() => { void load (). catch ( error => setError ( error instanceof Error ? error . message : 'Could not load rules' )) }, [ load ])
2026-08-22 16:02:10 +02:00
const edit = ( rule : EvidenceMatchRuleDefinition ) => setDraft ({ id :rule.id , name :rule.name , sourceLabel :rule.sourceLabel || '' , sourceUri :rule.sourceUri || '' , flagKey :rule.flagKey ,
2026-08-22 14:53:23 +02:00
minimumAnchorMatches :rule.minimumAnchorMatches , enabled :rule.enabled ,
anchors :rule.anchors.map ( anchor => ({ id :anchor.id , phrase :anchor.phrase , minimumSimilarity :anchor.minimumSimilarity })) })
const submit = async ( event : React.FormEvent ) => {
event . preventDefault (); setBusy ( true ); setError ( '' )
try {
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( levelId ) } /evidence-match-rules ${ draft . id ? `/ ${ encodeURIComponent ( draft . id ) } ` : '' } ` , {
method : draft.id ? 'PUT' : 'POST' , headers : { 'content-type' : 'application/json' },
2026-08-22 16:02:10 +02:00
body : JSON.stringify ({ name :draft.name , sourceLabel :draft.sourceLabel , sourceUri :draft.sourceUri , flagKey :draft.flagKey.toLowerCase (), minimumAnchorMatches :draft.minimumAnchorMatches ,
2026-08-22 14:53:23 +02:00
enabled :draft.enabled , anchors :draft.anchors.map ( anchor => ({ phrase :anchor.phrase , minimumSimilarity :anchor.minimumSimilarity })) }),
})
if ( ! response . ok ) { const body = await response . json (). catch (() => ({})); throw new Error ( body . error || 'Could not save evidence rule' ) }
await load (); setDraft ( emptyMatchRule ())
} catch ( error ) { setError ( error instanceof Error ? error . message : 'Could not save evidence rule' ) } finally { setBusy ( false ) }
}
const remove = async ( rule : EvidenceMatchRuleDefinition ) => {
if ( ! window . confirm ( `Delete evidence match rule “ ${ rule . name } ”?` )) return
setBusy ( true ); setError ( '' )
try {
const response = await fetch ( `/api/levels/ ${ encodeURIComponent ( levelId ) } /evidence-match-rules/ ${ encodeURIComponent ( rule . id ) } ` , { method : 'DELETE' })
if ( ! response . ok ) throw new Error ( 'Could not delete evidence rule' )
await load (); if ( draft . id === rule . id ) setDraft ( emptyMatchRule ())
} catch ( error ) { setError ( error instanceof Error ? error . message : 'Could not delete evidence rule' ) } finally { setBusy ( false ) }
}
const validAnchors = draft . anchors . filter ( anchor => anchor . phrase . trim (). length >= 12 )
const canSave = draft . name . trim () && /^[a-z][a-z0-9_.-]{0,63}$/ . test ( draft . flagKey ) && validAnchors . length === draft . anchors . length
&& draft . minimumAnchorMatches >= 1 && draft . minimumAnchorMatches <= draft . anchors . length
return < div className = "modal-shade" >< section className = "window match-rules-editor" >
< header >< Search size = { 16 }/>< b > Evidence text matching </ b >< span />< button type = "button" aria-label = "Close evidence matching" onClick = { onClose }>< X size = { 14 }/></ button ></ header >
< div className = "match-rules-body" >< small > OCR / FUZZY PASSAGE RULES </ small >
< p > When OCR from a player - uploaded source matches enough distinctive passages , the configured flag is awarded . Matching ignores case , punctuation , accents , and ordinary OCR noise .</ p >
< div className = "match-rule-layout" >< div className = "match-rule-list" >
{ rules . length === 0 && < div className = "flag-empty" > NO AUTOMATIC EVIDENCE RULES </ div >}
{ rules . map ( rule => < div className = { `match-rule-row ${ rule . enabled ? '' : 'disabled' } ` } key = { rule . id }>< div >< b >{ rule . name }</ b >< small >{ rule . flagKey } · { rule . minimumAnchorMatches } / { rule . anchors . length } ANCHORS </ small ></ div >< button type = "button" onClick = {() => edit ( rule )}> EDIT </ button >< button type = "button" disabled = { busy } onClick = {() => void remove ( rule )}>< Trash2 size = { 12 }/></ button ></ div >)}
</ div >
< form className = "match-rule-form" onSubmit = { event => void submit ( event )}>
< div className = "match-rule-form-heading" >< b >{ draft . id ? 'EDIT RULE' : 'NEW RULE' }</ b >{ draft . id && < button type = "button" onClick = {() => setDraft ( emptyMatchRule ())}> NEW </ button >}</ div >
< label className = "field" >< span > RULE NAME </ span >< input value = { draft . name } maxLength = { 160 } onChange = { event => setDraft ( value => ({ ... value , name :event.target.value }))} placeholder = "Contemporary fire report" /></ label >
2026-08-22 16:02:10 +02:00
< div className = "match-rule-fields" >< label className = "field" >< span > SOURCE LABEL · OPTIONAL </ span >< input value = { draft . sourceLabel } maxLength = { 300 } onChange = { event => setDraft ( value => ({ ... value , sourceLabel :event.target.value }))} placeholder = "Google Patents · GB695913A" /></ label >
< label className = "field" >< span > CANONICAL URL · OPTIONAL </ span >< input type = "url" value = { draft . sourceUri } maxLength = { 2000 } onChange = { event => setDraft ( value => ({ ... value , sourceUri :event.target.value }))} placeholder = "https://patents.google.com/…" /></ label ></ div >
2026-08-22 14:53:23 +02:00
< div className = "match-rule-fields" >< label className = "field" >< span > AWARD FLAG </ span >< input value = { draft . flagKey } pattern = "[a-z][a-z0-9_.-]{0,63}" onChange = { event => setDraft ( value => ({ ... value , flagKey :event.target.value.toLowerCase () }))} placeholder = "source.fire-report" /></ label >
< label className = "field" >< span > REQUIRED HITS </ span >< input type = "number" min = "1" max = { draft . anchors . length } value = { draft . minimumAnchorMatches } onChange = { event => setDraft ( value => ({ ... value , minimumAnchorMatches :Number ( event . target . value ) }))}/></ label ></ div >
< label className = "match-rule-enabled" >< input type = "checkbox" checked = { draft . enabled } onChange = { event => setDraft ( value => ({ ... value , enabled :event.target.checked }))}/> ENABLE THIS RULE </ label >
< div className = "anchor-heading" >< b > REFERENCE PASSAGES </ b >< button type = "button" onClick = {() => setDraft ( value => ({ ... value , anchors : [... value . anchors ,{ id :uid ( 'anchor' ), phrase : '' , minimumSimilarity :.72 }] }))}>< Plus size = { 12 }/> ADD PASSAGE </ button ></ div >
< div className = "anchor-list" >{ draft . anchors . map (( anchor , index ) => < div className = "anchor-row" key = { anchor . id }>< div >< small > ANCHOR { index + 1 }</ small >< textarea value = { anchor . phrase } rows = { 3 } placeholder = "Paste a distinctive passage of at least 12 characters…" onChange = { event => setDraft ( value => ({ ... value , anchors :value.anchors.map ( item => item . id === anchor . id ? { ... item , phrase :event.target.value } : item ) }))}/></ div >< label >< span > SIMILARITY </ span >< input type = "number" min = "0.5" max = "1" step = "0.01" value = { anchor . minimumSimilarity } onChange = { event => setDraft ( value => ({ ... value , anchors :value.anchors.map ( item => item . id === anchor . id ? { ... item , minimumSimilarity :Number ( event . target . value ) } : item ) }))}/></ label >< button type = "button" aria-label = "Remove reference passage" disabled = { draft . anchors . length === 1 } onClick = {() => setDraft ( value => ({ ... value , minimumAnchorMatches :Math.min ( value . minimumAnchorMatches , value . anchors . length - 1 ), anchors :value.anchors.filter ( item => item . id !== anchor . id ) }))}>< Trash2 size = { 13 }/></ button ></ div >)}</ div >
{ error && < p className = "flag-error" >{ error }</ p >}
< div className = "folder-editor-actions" >< button type = "button" onClick = { onClose }> CLOSE </ button >< button className = "primary" type = "submit" disabled = { busy || ! canSave }>{ busy ? 'SAVING…' : 'SAVE RULE' }</ button ></ div >
</ form ></ div >
</ div >
</ section ></ div >
}
2026-08-14 12:43:11 +02:00
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 >
2026-08-17 09:24:53 +02:00
< div className = { `paper ${ doc . assetId ? 'asset-paper' : '' } ` }>< div className = "paper-meta" >< span > GLITCH UNIVERSITY ARCHIVE </ span >< b >{ documentWidget ( doc . fileType ). label }</ b ></ div >{ doc . assetId ? < DocumentAsset doc = { doc }/> : doc . body . map (( line , i ) => < p key = { i }>{ line }</ p >)}{ doc . regions . length > 0 && < div className = "extracts" >{ doc . regions . map ( r => < button key = { r . id } className = { extracted . includes ( r . id ) ? 'done' : '' } onClick = {() => onExtract ( r . id )}>< Network size = { 15 }/>{ extracted . includes ( r . id ) ? 'LOCATE ON BOARD' : r . label }</ button >)}</ div >}</ div >
< footer >< span > ARCHIVE ITEM · { doc . publishedAt ? . slice ( 0 , 10 ) || 'UNDATED' }</ span >< span > PROVENANCE LOCKED </ span ></ footer ></>}
2026-08-14 12:43:11 +02:00
</ 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
}
2026-08-14 16:37:50 +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 an exhibit , choose the red - thread tool , then select its target .</ li >< li > Tag the thread with the claim it represents .</ 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 > }