import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types' import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { documentWidget, exhibitWidget } from './exhibitRegistry' const BOARD_W = 2400 const BOARD_H = 1500 const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [ 'image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file', ].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label })) function uid(_prefix: string) { return crypto.randomUUID() } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function documentSearchText(document: CaseDocument) { return [document.title, document.kind, document.date, document.publishedAt, document.fileName, document.mimeType, ...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]), ...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase() } function connectionPoint(item: Evidence) { return exhibitWidget(item.type).connectionPoint(item) } type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string } export function App() { const [caseState, setCaseState] = useState(null) const [noLevels, setNoLevels] = useState(false) const [openDoc, setOpenDoc] = useState(null) const [selected, setSelected] = useState(null) const [linkFrom, setLinkFrom] = useState(null) const [docsOpen, setDocsOpen] = useState(false) const [documentQuery, setDocumentQuery] = useState('') const [helpOpen, setHelpOpen] = useState(false) const [status, setStatus] = useState('CONNECTING TO ARCHIVE…') const [clock, setClock] = useState('') const [draggingFiles, setDraggingFiles] = useState(false) const [uploading, setUploading] = useState(0) const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move') const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) const [editingEventId, setEditingEventId] = useState(null) const [editingPartyId, setEditingPartyId] = useState(null) const [newPartyDraft, setNewPartyDraft] = useState(null) const [briefOpen, setBriefOpen] = useState(false) const [editingBrief, setEditingBrief] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false) const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState(null) const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState(null) const [threadDraft, setThreadDraft] = useState(null) const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1' useEffect(() => { const params = new URLSearchParams(window.location.search) fetch('/api/levels').then(r => { if (!r.ok) throw new Error('Server unavailable') return r.json() }).then(async (levels: { id: string }[]) => { const levelId = params.get('level') || levels[0]?.id if (!levelId) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return } const editQuery = params.get('edit') === '1' ? '?edit=1' : '' const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`) if (!response.ok) throw new Error('Level unavailable') const data = normalizeCase(await response.json()) setCaseState(data) if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true) setStatus('EVIDENCE INTEGRITY: PROBABLY OK') }) .catch(() => { const cached = localStorage.getItem('gupi-osint-board:last') if (cached) setCaseState(normalizeCase(JSON.parse(cached))) setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE') }) const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })) tick(); const timer = window.setInterval(tick, 30000) return () => clearInterval(timer) }, []) useEffect(() => { if (!recentlyCreatedExhibitId) return const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400) return () => window.clearTimeout(timer) }, [recentlyCreatedExhibitId]) useEffect(() => { if (!recentlyCreatedConnectionId) return const timer = window.setTimeout(() => setRecentlyCreatedConnectionId(null), 1200) return () => window.clearTimeout(timer) }, [recentlyCreatedConnectionId]) const update = useCallback((fn: (state: CaseState) => CaseState) => { setCaseState(current => { if (!current) return current const next = fn(current) localStorage.setItem('gupi-osint-board:last', JSON.stringify(next)) window.clearTimeout(saveTimer.current) saveTimer.current = window.setTimeout(() => { const editQuery = requestedEditMode && next.editingAllowed ? '?edit=1' : '' fetch(`/api/levels/${encodeURIComponent(next.id)}${editQuery}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(next) }) .then(r => { if (!r.ok) throw new Error(); setStatus('SAVED TO CASE ARCHIVE') }) .catch(() => setStatus('OFFLINE · SAVED LOCALLY')) }, 450) return next }) }, [requestedEditMode]) const focusEvidence = (id: string) => { if (!caseState) return const ev = caseState.evidence.find(e => e.id === id) if (!ev) return setSelected(id) update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } })) } const extract = (doc: CaseDocument, regionId: string) => { if (!caseState) return const region = doc.regions.find(r => r.id === regionId)! const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId) if (existing) { setOpenDoc(null); focusEvidence(existing.id); return } const ev: Evidence = { id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false }, sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id], x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260, } update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] })) setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } const addNote = () => { const content = window.prompt('What do you think this evidence means?')?.trim() if (!content || !caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 } update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) } const addEvent = () => { if (!caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 }) const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 } update(state => ({ ...state, evidence: [...state.evidence, event] })) setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id) } const addParty = () => { if (!caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), }, { width: 280 }) setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], relatedEvidenceIds: [], ...position, width: 280 }) } 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 const existingParty = caseState.evidence.find(item => item.id === existingId) const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), }, { width: 280 }) const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined, title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [], ...position, width: 280 } update(state => ({ ...state, evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party], brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) }, })) 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) } const handleCardClick = (id: string) => { if (!linkFrom) { setSelected(current => current === id ? null : id); return } completeThread(id) } const completeThread = (targetId: string) => { if (!linkFrom || !caseState || linkFrom === targetId) return const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom)) if (existing) { setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') return } setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) setLinkFrom(null) if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId) } 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') } const toggleThreadTool = () => { if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') } else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') } setBoardTool('move') } const reset = async () => { if (!caseState?.sourceTemplateVersionId || !window.confirm('Reset this investigation to its original template version?')) return 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') } } 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`) } const uploadFiles = async (files: FileList | File[]) => { if (!caseState || !requestedEditMode || !caseState.editingAllowed) return const queue = Array.from(files) setUploading(queue.length) setDraggingFiles(false) for (const file of queue) { const form = new FormData() form.append('file', file) try { const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form }) if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) } const document: CaseDocument = await response.json() update(s => ({ ...s, documents: [...s.documents, document] })) setStatus(`IMPORTED · ${file.name.toUpperCase()}`) } catch (error) { setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') } finally { setUploading(count => count - 1) } } } if (noLevels) return { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (!caseState) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status}
const documentById = new Map(caseState.documents.map(document => [document.id, document])) const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase() const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId)) const temporalItems: TemporalItem[] = [ ...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => { const document = documentById.get(documentId) const date = document?.publishedAt || document?.date const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId) return document && date && relation ? [{ id: `folder:${folder.id}:document:${document.id}`, sourceTemporalId: folderIsOpen(folder) ? `file:${relation.id}` : `widget:${folder.id}`, date, label: document.title, kind: 'document' as const, evidenceId: folder.id, documentId: document.id }] : [] })), ...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })), ...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })), ].sort((a, b) => dateValue(a.date) - dateValue(b.date)) const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!)) return
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
{status}{clock}
{ if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> {briefOpen && item.type === 'party')} recentlyCreatedExhibitId={recentlyCreatedExhibitId} canEdit={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={closeBrief} onEdit={() => setEditingBrief(true)} onClassify={classifyConcept} onNewParty={addParty} onLocate={focusEvidence} onEditParty={setEditingPartyId} />} {storyEvents.length > 0 && } {!docsOpen && }
{Math.round(caseState.viewport.zoom * 100)}%
{draggingFiles &&
ADD SOURCE DOCUMENTSDROP FILES INTO THIS LEVEL
}
`${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} {editingFolderId && widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>} {editingFileId && document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} {editingEventId && item.id === editingEventId)!} evidence={caseState.evidence} documents={caseState.documents} onClose={() => setEditingEventId(null)} onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} />} {editingPartyId && item.id === editingPartyId)!} evidence={caseState.evidence} documents={caseState.documents} onClose={() => setEditingPartyId(null)} onSave={party => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) })) setEditingPartyId(null) setStatus('PARTY DOSSIER UPDATED') }} />} {newPartyDraft && setNewPartyDraft(null)} onSave={party => { update(state => ({ ...state, evidence: [...state.evidence, party] })) setNewPartyDraft(null) setSelected(party.id) setRecentlyCreatedExhibitId(party.id) setStatus(`${party.partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`) }} />} {editingBrief && setEditingBrief(false)} onSave={brief => { update(state => ({ ...state, brief })) setEditingBrief(false) setStatus('LEVEL BRIEF UPDATED') }} />} {editingTimeline && item.date)} onClose={() => setEditingTimeline(false)} onSave={timelineRange => { update(state => ({ ...state, timelineRange })) setEditingTimeline(false) setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC') }} />} {threadDraft && item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'} targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'} isNew={!caseState.connections.some(item => item.id === threadDraft.id)} onClose={() => setThreadDraft(null)} onSave={saveThread} onRemove={() => removeThread(threadDraft.id)} />} {helpOpen && setHelpOpen(false)}/>}
} 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
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) const pinchDistance = useRef(null) const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null) const [expandedThreadTagId, setExpandedThreadTagId] = useState(null) const [draggingThreadTagId, setDraggingThreadTagId] = useState(null) const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence]) const containmentRelations = state.relations.filter(relation => relation.type === 'contains') const pointForId = (id: string) => { const evidence = byId.get(id) if (evidence) return connectionPoint(evidence) const relation = containmentRelations.find(item => item.toWidgetId === id) const folder = relation ? byId.get(relation.fromWidgetId) : undefined if (!relation || !folder) return undefined const position = relationPosition(state, relation) return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder) } useEffect(() => { const board = boardRef.current if (!board) return const handleWheelZoom = (event: WheelEvent) => { event.preventDefault() event.stopPropagation() if (event.ctrlKey || event.metaKey || event.deltaY === 0) return 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) })) } board.addEventListener('wheel', handleWheelZoom, { passive: false }) return () => board.removeEventListener('wheel', handleWheelZoom) }, [boardRef, update]) const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => { if ((event.target as HTMLElement).closest('button')) return if (event.pointerType === 'touch') { touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (touchPoints.current.size >= 2) { const points = [...touchPoints.current.values()] pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) drag.current = null event.preventDefault() return } } const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined const position = relation ? relationPosition(state, relation) : undefined drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y } try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } } 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. */ } } 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 }) } } const pointerMove = (event: React.PointerEvent) => { trackThreadPointer(event) if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) { touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (touchPoints.current.size >= 2) { const points = [...touchPoints.current.values()] const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) const previous = pinchDistance.current if (previous && 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) })) } pinchDistance.current = distance suppressClick.current = true event.preventDefault() return } } 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 if (drag.current.kind === 'thread-tag' && boardRef.current) { if (drag.current.moved) setExpandedThreadTagId(null) const connection = state.connections.find(item => item.id === drag.current!.id) const from = connection ? pointForId(connection.fromEvidenceId) : undefined const to = connection ? pointForId(connection.toEvidenceId) : undefined if (connection && from && to) { const bounds = boardRef.current.getBoundingClientRect() const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom } const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer) update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) })) } } else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } }) else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } }) else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) })) } const finishDrag = (event: React.PointerEvent) => { if (event.pointerType === 'touch') { touchPoints.current.delete(event.pointerId) if (touchPoints.current.size < 2) pinchDistance.current = null } if (drag.current) suppressClick.current = Boolean(drag.current.moved) drag.current = null setDraggingThreadTagId(null) } const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) })) const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined return
{ 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) } }} onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return })} {previewOrigin && threadPointer && } {state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return })} {state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { const evidence = byId.get(evidenceId) let target = evidence ? connectionPoint(evidence) : undefined if (!target) { const relation = containmentRelations.find(item => item.toWidgetId === evidenceId) const folder = relation ? byId.get(relation.fromWidgetId) : undefined if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder) } if (!target) return [] const origin = connectionPoint(event) return [] }))} {state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => { const evidence = byId.get(evidenceId) let target = evidence ? connectionPoint(evidence) : undefined if (!target) { const relation = containmentRelations.find(item => item.toWidgetId === evidenceId) const folder = relation ? byId.get(relation.fromWidgetId) : undefined if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder) } if (!target) return [] const origin = connectionPoint(party) return [] }))} {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
})} {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }} onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
{document.title}
})}
} 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('[data-temporal-id]')).map(element => [element.dataset.temporalId, element])) const markers = new Map(Array.from(document.querySelectorAll('[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('.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('.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 } 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) const position = (date: string) => timelinePositionPercent(date, { start, end }) const ticks = range ? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } }) : Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } }) return
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
} function localDateTime(value?: string) { if (!value) return '' if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00` const date = new Date(value) if (!Number.isFinite(date.getTime())) return '' const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) return local.toISOString().slice(0, 16) } function 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
{ event.preventDefault(); if (valid) onSave({ start, end }) }}>
Adjust timeline range
TEMPORAL VIEWPORT · BOARD SETTING

Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.

} 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) const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage') 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)) }) } return
{ event.preventDefault(); save() }}>
{isNew ? 'Add relation tag' : 'Edit red thread'}
RED THREAD · INVESTIGATOR RELATION
{sourceName}{targetName}

What does this connection mean? Add a short tag if the thread represents a specific claim.

TAG PRESENTATION
{!isNew && }{isNew && }
} function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { const partyById = new Map(parties.map(party => [party.id, party])) const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length return } function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) { const [body, setBody] = useState(brief.body) const [concepts, setConcepts] = useState(brief.concepts) const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }]) return
{ submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
Edit level brief
AUTHORING · PLAYER CONCEPTS