- migration 023: story_nodes.music_asset_id (references the asset store). - src/audio.ts: a tiny audio manager — one looping scene-music track (fade, dedup, gesture-primed autoplay) and synthesized one-shot SFX; global mute. - Runtime: RuntimeNode.musicUrl; music follows the current node (null inherits, a finished playthrough stops). SFX blips on dialogue advance/choice (not in the editor preview). Floating mute toggle during play. - Graph inspector: a "Scene music" picker sourced from uploaded audio assets. No external library (we did not adopt react-winamp — it is a React 16 CRA app, not an installable package). Bundle grew ~2 KB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1126 lines
97 KiB
TypeScript
1126 lines
97 KiB
TypeScript
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, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
|
|
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
|
|
import { AdminPanel } from './admin'
|
|
import { audio } from './audio'
|
|
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'
|
|
|
|
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.fileType, document.publishedAt, document.capturedAt, 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: Exhibit) {
|
|
return exhibitWidget(item.type).connectionPorts(item)[0]
|
|
}
|
|
|
|
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,
|
|
}))]
|
|
}
|
|
|
|
export function App() {
|
|
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
|
const [isAdmin, setIsAdmin] = useState(false)
|
|
const [noLevels, setNoLevels] = useState(false)
|
|
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
|
|
const [selected, setSelected] = useState<string | null>(null)
|
|
const [linkFrom, setLinkFrom] = useState<string | null>(null)
|
|
const [docsOpen, setDocsOpen] = useState(false)
|
|
const [documentQuery, setDocumentQuery] = useState('')
|
|
const [helpOpen, setHelpOpen] = useState(false)
|
|
const [adminMenuOpen, setAdminMenuOpen] = useState(false)
|
|
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
|
|
const [clock, setClock] = useState('')
|
|
const [draggingFiles, setDraggingFiles] = useState(false)
|
|
const [uploading, setUploading] = useState(0)
|
|
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
|
|
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
|
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
|
const [editingEventId, setEditingEventId] = useState<string | null>(null)
|
|
const [newEventDraft, setNewEventDraft] = useState<EventExhibit | null>(null)
|
|
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
|
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
|
|
const [briefOpen, setBriefOpen] = useState(false)
|
|
const [editingBrief, setEditingBrief] = useState(false)
|
|
const [editingTimeline, setEditingTimeline] = useState(false)
|
|
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
|
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
|
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
|
const [splashOpen, setSplashOpen] = useState(false)
|
|
const [splashBusy, setSplashBusy] = useState(false)
|
|
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
|
|
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
|
|
const [muted, setMuted] = useState(audio.isMuted())
|
|
const saveTimer = useRef<number | undefined>(undefined)
|
|
const boardRef = useRef<HTMLDivElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const adminMenuRef = useRef<HTMLDivElement>(null)
|
|
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
|
|
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)
|
|
return data
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (adminRoute) return
|
|
const params = new URLSearchParams(window.location.search)
|
|
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
|
|
|
const deepLinkLevel = params.get('level')
|
|
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
|
const openLevel = async (slug: string) => {
|
|
const data = await loadLevelBySlug(slug, editQuery)
|
|
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
|
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
|
}
|
|
// Player campaign entry: a live playthrough resumes silently; none shows the splash.
|
|
// An explicit ?level= deep link (admin/authoring) bypasses the campaign entirely.
|
|
const boot = async () => {
|
|
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
|
const current = await fetch('/api/playthroughs/current')
|
|
if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
|
if (!current.ok) throw new Error('Playthrough unavailable')
|
|
const state: PlaythroughState = await current.json()
|
|
setPlaythrough(state.playthrough)
|
|
setRuntimeNode(state.node)
|
|
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
|
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
|
}
|
|
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 {
|
|
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)
|
|
}, [loadLevelBySlug, adminRoute])
|
|
|
|
const applyState = useCallback(async (state: PlaythroughState) => {
|
|
setPlaythrough(state.playthrough)
|
|
setRuntimeNode(state.node)
|
|
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
|
if (!state.node && state.playthrough.status === 'finished') { setSplashOpen(true); setStatus('CASE CLOSED · GREYHAVEN FILE 87-10') }
|
|
}, [loadLevelBySlug])
|
|
|
|
const startNewGame = useCallback(async () => {
|
|
setSplashBusy(true)
|
|
try {
|
|
const response = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
if (!response.ok) throw new Error('Could not start')
|
|
await applyState(await response.json())
|
|
setSplashOpen(false)
|
|
} catch { setStatus('COULD NOT OPEN CASE FILE') } finally { setSplashBusy(false) }
|
|
}, [applyState])
|
|
|
|
// Advance the story graph through a terminal (a dialogue supplies the chosen exit;
|
|
// cutscene/level advance through the node's single terminal).
|
|
const advance = useCallback(async (terminalKey?: string) => {
|
|
if (!playthrough) return
|
|
try {
|
|
const response = await fetch(`/api/playthroughs/${encodeURIComponent(playthrough.id)}/advance`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(terminalKey ? { terminalKey } : {}) })
|
|
if (!response.ok) throw new Error()
|
|
await applyState(await response.json())
|
|
} catch { setStatus('COULD NOT ADVANCE') }
|
|
}, [playthrough, applyState])
|
|
|
|
// Scene music follows the current node (null inherits; a finished playthrough stops).
|
|
useEffect(() => {
|
|
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl)
|
|
else if (playthrough?.status === 'finished') audio.setMusic(null)
|
|
}, [runtimeNode, playthrough])
|
|
|
|
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])
|
|
|
|
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.exhibits.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 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),
|
|
}
|
|
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 },
|
|
] }))
|
|
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.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)
|
|
}
|
|
|
|
const addEvent = () => {
|
|
if (!caseState) return
|
|
const { viewport } = caseState
|
|
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) }
|
|
setNewEventDraft(event)
|
|
}
|
|
|
|
const addParty = () => {
|
|
if (!caseState) return
|
|
const { viewport } = caseState
|
|
const position = nextOpenBoardPosition(caseState.exhibits, {
|
|
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: [], ...placement(position.x, position.y, 280, 190) })
|
|
}
|
|
|
|
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.exhibits.find((item): item is PartyExhibit => item.id === existingId && item.type === 'party')
|
|
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.exhibits, {
|
|
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
|
}, { width: 280 })
|
|
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) }
|
|
update(state => ({ ...state,
|
|
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],
|
|
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.fromExhibitId === linkFrom && connection.toExhibitId === targetId) || (connection.fromExhibitId === targetId && connection.toExhibitId === linkFrom))
|
|
if (existing) {
|
|
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
|
return
|
|
}
|
|
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
|
setLinkFrom(null)
|
|
if (caseState.exhibits.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 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')
|
|
}
|
|
|
|
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 enterLevelEditor = () => {
|
|
const params = new URLSearchParams(window.location.search)
|
|
params.set('edit', '1')
|
|
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
|
}
|
|
|
|
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, exhibits: [...s.exhibits, document] }))
|
|
setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
|
|
} catch (error) {
|
|
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
|
} finally { setUploading(count => count - 1) }
|
|
}
|
|
}
|
|
|
|
const audioToggle = playthrough ? <button className={`audio-toggle${muted ? ' muted' : ''}`} title={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted(audio.toggleMute())}>♪</button> : null
|
|
|
|
if (adminRoute) return <AdminPanel />
|
|
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
|
|
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
|
|
if (runtimeNode?.kind === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /></>
|
|
if (runtimeNode?.kind === 'dialogue') return <>{audioToggle}<DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /></>
|
|
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
|
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
|
|
|
const documents = documentExhibits(caseState.exhibits)
|
|
const evidence = evidenceExhibits(caseState.exhibits)
|
|
const documentById = new Map(documents.map(document => [document.id, document]))
|
|
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
|
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
|
|
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
|
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
|
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) => {
|
|
if (!a.eventDate) return b.eventDate ? 1 : 0
|
|
if (!b.eventDate) return -1
|
|
return dateValue(a.eventDate) - dateValue(b.eventDate)
|
|
})
|
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
|
return <main className="desktop">
|
|
{audioToggle}
|
|
<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>
|
|
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
|
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
|
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
|
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
|
{runtimeNode?.kind === 'level' && <button className="report-back" onClick={() => advance()} title="Finish investigating and continue the story">REPORT BACK ▸</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">
|
|
<button role="menuitem" onClick={() => window.location.assign('/admin')}>NPC & MYSTERY ADMIN</button>
|
|
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
|
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
|
<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>}
|
|
</nav>
|
|
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
|
</header>
|
|
|
|
<section className="workspace">
|
|
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
|
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
|
<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>
|
|
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
|
|
<div className="doc-list">
|
|
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
|
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
|
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
|
</button>)}
|
|
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
|
</div>
|
|
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
|
</aside>
|
|
|
|
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
|
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
|
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} 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} />
|
|
{briefOpen && <BriefPanel
|
|
brief={caseState.brief}
|
|
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
|
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
|
canEdit={canAuthor}
|
|
onClose={closeBrief}
|
|
onEdit={() => setEditingBrief(true)}
|
|
onClassify={classifyConcept}
|
|
onNewParty={addParty}
|
|
onLocate={focusEvidence}
|
|
onEditParty={setEditingPartyId}
|
|
/>}
|
|
{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>}
|
|
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{documents.length}</b></button>}
|
|
<div className="board-actions">
|
|
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
|
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
|
<span />
|
|
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
|
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
|
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
|
<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>
|
|
<span />
|
|
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
|
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
|
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
|
<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>
|
|
</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>
|
|
|
|
<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('|')}`} />
|
|
<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] : [])} />}
|
|
{editingFolderId && <FolderEditor
|
|
key={editingFolderId}
|
|
folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
|
|
memberIds={containedIds(caseState, editingFolderId)}
|
|
documents={documents}
|
|
canManageContents={canAuthor}
|
|
onClose={() => setEditingFolderId(null)}
|
|
onSave={(folder, members) => {
|
|
update(state => ({
|
|
...state,
|
|
exhibits: state.exhibits.map(widget => widget.id === folder.id ? folder : widget),
|
|
relations: [
|
|
...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 })),
|
|
],
|
|
}))
|
|
setEditingFolderId(null)
|
|
setStatus('FOLDER UPDATED')
|
|
}}
|
|
/>}
|
|
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} 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') }}/>
|
|
}
|
|
{editingEventId && <EventEditor
|
|
key={editingEventId}
|
|
event={caseState.exhibits.find((item): item is EventExhibit => item.id === editingEventId && item.type === 'event')!}
|
|
exhibits={caseState.exhibits}
|
|
relations={caseState.relations}
|
|
onClose={() => setEditingEventId(null)}
|
|
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') }}
|
|
/>}
|
|
{newEventDraft && <EventEditor
|
|
key={newEventDraft.id}
|
|
event={newEventDraft}
|
|
exhibits={caseState.exhibits}
|
|
relations={caseState.relations}
|
|
onClose={() => setNewEventDraft(null)}
|
|
onSave={(event, supports) => {
|
|
update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) }))
|
|
setNewEventDraft(null)
|
|
setSelected(event.id)
|
|
setRecentlyCreatedExhibitId(event.id)
|
|
setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED')
|
|
}}
|
|
/>}
|
|
{editingPartyId && <PartyEditor
|
|
key={editingPartyId}
|
|
party={caseState.exhibits.find((item): item is PartyExhibit => item.id === editingPartyId && item.type === 'party')!}
|
|
exhibits={caseState.exhibits}
|
|
relations={caseState.relations}
|
|
onClose={() => setEditingPartyId(null)}
|
|
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) }))
|
|
setEditingPartyId(null)
|
|
setStatus('PARTY DOSSIER UPDATED')
|
|
}}
|
|
/>}
|
|
{newPartyDraft && <PartyEditor
|
|
key={newPartyDraft.id}
|
|
party={newPartyDraft}
|
|
exhibits={caseState.exhibits}
|
|
relations={caseState.relations}
|
|
creating
|
|
onClose={() => setNewPartyDraft(null)}
|
|
onSave={(party, related) => {
|
|
update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
|
|
setNewPartyDraft(null)
|
|
setSelected(party.id)
|
|
setRecentlyCreatedExhibitId(party.id)
|
|
setStatus(`${party.partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
|
|
}}
|
|
/>}
|
|
{editingBrief && <BriefEditor
|
|
brief={caseState.brief}
|
|
onClose={() => setEditingBrief(false)}
|
|
onSave={brief => {
|
|
update(state => ({ ...state, brief }))
|
|
setEditingBrief(false)
|
|
setStatus('LEVEL BRIEF UPDATED')
|
|
}}
|
|
/>}
|
|
{editingTimeline && <TimelineRangeEditor
|
|
range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined}
|
|
dates={temporalItems.map(item => item.date)}
|
|
onClose={() => setEditingTimeline(false)}
|
|
onSave={timelineRange => {
|
|
update(state => ({ ...state, views: state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: timelineRange ? 'fixed' : 'auto', range: timelineRange || undefined } : view) }))
|
|
setEditingTimeline(false)
|
|
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
|
}}
|
|
/>}
|
|
{threadDraft && <ThreadEditor
|
|
key={threadDraft.id}
|
|
connection={threadDraft}
|
|
sourceName={caseState.exhibits.find(item => item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'}
|
|
targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'}
|
|
isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
|
|
onClose={() => setThreadDraft(null)}
|
|
onSave={saveThread}
|
|
onRemove={() => removeThread(threadDraft.id)}
|
|
/>}
|
|
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
|
</main>
|
|
}
|
|
|
|
function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (level: CaseState) => void }) {
|
|
const [creating, setCreating] = useState(false)
|
|
const createLevel = async () => {
|
|
const title = window.prompt('Name this investigation level:', 'Untitled Investigation')?.trim()
|
|
if (!title) return
|
|
setCreating(true)
|
|
try {
|
|
const response = await fetch('/api/levels', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }) })
|
|
if (!response.ok) throw new Error('Could not create level')
|
|
onCreated(await response.json())
|
|
} finally { setCreating(false) }
|
|
}
|
|
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
|
}
|
|
|
|
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, 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; 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 }) {
|
|
const drag = useRef<{ kind: 'pan' | 'widget' | '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<number, { x: number; y: number }>())
|
|
const pinchDistance = useRef<number | null>(null)
|
|
const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
|
|
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
|
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
|
|
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
|
|
const [draggingWidget, setDraggingWidget] = useState(false)
|
|
const [trashActive, setTrashActive] = useState(false)
|
|
const trashRef = useRef<HTMLDivElement>(null)
|
|
const trashTarget = useRef(false)
|
|
const byId = useMemo(() => new Map(state.exhibits.map(exhibit => [exhibit.id, exhibit])), [state.exhibits])
|
|
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
|
const pointForId = (id: string) => {
|
|
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)
|
|
}
|
|
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'; 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) {
|
|
if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
|
|
folderLongPress.current = null
|
|
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
|
|
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 }
|
|
setDraggingWidget(target?.kind === 'widget')
|
|
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)
|
|
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
|
|
}
|
|
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 === '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)
|
|
}
|
|
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.fromExhibitId) : undefined
|
|
const to = connection ? pointForId(connection.toExhibitId) : 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, exhibits: s.exhibits.map(exhibit => exhibit.id === drag.current!.id ? { ...exhibit, ...next } : exhibit) } })
|
|
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 (folderLongPress.current?.pointerId === event.pointerId) {
|
|
window.clearTimeout(folderLongPress.current.timer)
|
|
folderLongPress.current = null
|
|
}
|
|
if (event.pointerType === 'touch') {
|
|
touchPoints.current.delete(event.pointerId)
|
|
if (touchPoints.current.size < 2) pinchDistance.current = null
|
|
}
|
|
const completedDrag = drag.current
|
|
if (completedDrag) suppressClick.current = Boolean(completedDrag.moved)
|
|
drag.current = null
|
|
setDraggingThreadTagId(null)
|
|
setDraggingWidget(false)
|
|
setTrashActive(false)
|
|
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
|
trashTarget.current = false
|
|
}
|
|
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
|
|
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)
|
|
}, [])
|
|
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)
|
|
} }
|
|
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
|
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
|
|
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) }
|
|
}}
|
|
onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
|
|
<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>
|
|
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
|
|
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
|
|
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
|
{state.connections.map(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> })}
|
|
{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>}
|
|
</svg>
|
|
{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> })}
|
|
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
|
{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 })}
|
|
</svg>
|
|
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
|
|
{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 })}
|
|
</svg>
|
|
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
|
{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}/> })}
|
|
</svg>
|
|
{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; 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 ? '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 }}
|
|
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 }) }}
|
|
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 (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) } }}>
|
|
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
|
<Widget exhibit={ev} context={widgetContext}/>
|
|
</article>})}
|
|
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
|
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
|
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
|
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
|
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
|
</article> })}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) {
|
|
const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([])
|
|
const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|')
|
|
useLayoutEffect(() => {
|
|
let frame = 0
|
|
let animateUntil = Date.now() + 500
|
|
const measure = () => {
|
|
cancelAnimationFrame(frame)
|
|
frame = requestAnimationFrame(function measureFrame() {
|
|
const sources = new Map(Array.from(document.querySelectorAll<HTMLElement>('[data-temporal-id]')).map(element => [element.dataset.temporalId, element]))
|
|
const markers = new Map(Array.from(document.querySelectorAll<HTMLElement>('[data-marker-id]')).map(element => [element.dataset.markerId, element]))
|
|
setLines(items.flatMap(item => {
|
|
const source = sources.get(item.sourceTemporalId), marker = markers.get(item.id)
|
|
if (!source || !marker) return []
|
|
const from = source.getBoundingClientRect(), to = marker.getBoundingClientRect()
|
|
const clip = source.closest<HTMLElement>('.board-viewport, .doc-list')?.getBoundingClientRect()
|
|
const visibleLeft = Math.max(from.left, clip?.left ?? 0), visibleRight = Math.min(from.right, clip?.right ?? window.innerWidth)
|
|
const visibleTop = Math.max(from.top, clip?.top ?? 0), visibleBottom = Math.min(from.bottom, clip?.bottom ?? window.innerHeight)
|
|
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) return []
|
|
return [{ id: item.id, x1: visibleLeft + (visibleRight - visibleLeft) / 2, y1: visibleBottom, x2: to.left + to.width / 2, y2: to.top + to.height / 2 }]
|
|
}))
|
|
if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame)
|
|
})
|
|
}
|
|
measure()
|
|
const observer = new ResizeObserver(measure)
|
|
document.querySelectorAll<HTMLElement>('.workspace, .timeline, [data-temporal-id], [data-marker-id]').forEach(element => observer.observe(element))
|
|
const handleResize = () => { animateUntil = Date.now() + 500; measure() }
|
|
window.addEventListener('resize', handleResize)
|
|
return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener('resize', handleResize) }
|
|
}, [itemsKey, layoutKey])
|
|
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
|
|
}
|
|
|
|
function Timeline({ items, 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 <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>
|
|
}
|
|
|
|
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 <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>
|
|
}
|
|
|
|
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 <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>
|
|
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
|
|
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
}
|
|
|
|
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; 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 }) {
|
|
const [minimized, setMinimized] = useState(false)
|
|
const partyById = new Map(parties.map(party => [party.id, party]))
|
|
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
|
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</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>
|
|
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
|
<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>
|
|
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
|
|
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
|
</aside>
|
|
}
|
|
|
|
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
|
|
const [body, setBody] = useState(brief.body)
|
|
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
|
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
|
|
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
|
|
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
|
|
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
|
|
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
|
|
<div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
|
|
<div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
|
|
<p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
|
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE BRIEF</button></div>
|
|
</div>
|
|
</form></div>
|
|
}
|
|
|
|
function PartyEditor({ party, exhibits, relations, creating = false, onClose, onSave }: { party: PartyExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; creating?: boolean; onClose: () => void; onSave: (party: PartyExhibit, related: string[]) => void }) {
|
|
const [name, setName] = useState(party.title)
|
|
const [summary, setSummary] = useState(party.content)
|
|
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind)
|
|
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
|
|
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() }))
|
|
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
|
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean) }, related) }}>
|
|
<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>
|
|
<label className="field"><span>DOSSIER SUMMARY</span><textarea aria-label="Party summary" rows={3} value={summary} onChange={event => setSummary(event.target.value)}/></label>
|
|
{partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
|
|
<label className="field"><span>ALIASES · ONE PER LINE</span><textarea aria-label="Party aliases" rows={2} value={aliases} onChange={event => setAliases(event.target.value)}/></label>
|
|
<div className="folder-members-heading"><div><b>ASSOCIATED EVIDENCE</b><small>{related.length} LINKED</small></div><span>PARTY DOSSIER</span></div>
|
|
<div className="folder-members">{candidates.map(candidate => { const included = related.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'ASSOCIATED' : 'NOT LINKED'}</span></div> })}</div>
|
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE DOSSIER</button></div>
|
|
</div>
|
|
</form></div>
|
|
}
|
|
|
|
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: FolderExhibit; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: FolderExhibit, members: string[]) => void }) {
|
|
const [title, setTitle] = useState(folder.title)
|
|
const [content, setContent] = useState(folder.content)
|
|
const [members, setMembers] = useState(memberIds)
|
|
const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
|
|
const submit = (event: React.FormEvent) => {
|
|
event.preventDefault()
|
|
onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim() }, members)
|
|
}
|
|
return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
|
|
<header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
|
|
<div className="folder-editor-body">
|
|
<small>FOLDER WIDGET</small>
|
|
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
|
<label className="field"><span>ANNOTATION</span><textarea rows={3} value={content} onChange={event => setContent(event.target.value)}/></label>
|
|
<div className="folder-members-heading"><div><b>CONTAINED SOURCE FILES</b><small>{members.length} SELECTED</small></div><span>{canManageContents ? 'LEVEL CONTENT' : 'FIXED CONTENT'}</span></div>
|
|
<div className="folder-members">
|
|
{documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
|
|
<label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
|
|
<span className="folder-member-date">{document.publishedAt?.slice(0, 10) || 'UNDATED'}</span>
|
|
</div> })}
|
|
</div>
|
|
<p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
|
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE FOLDER</button></div>
|
|
</div>
|
|
</form></div>
|
|
}
|
|
|
|
function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: EventExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; onClose: () => void; onSave: (event: EventExhibit, supports: string[]) => void }) {
|
|
const [title, setTitle] = useState(event.title)
|
|
const [narrative, setNarrative] = useState(event.content)
|
|
const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
|
|
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() }))
|
|
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
|
const submit = (submitEvent: React.FormEvent) => {
|
|
submitEvent.preventDefault()
|
|
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
|
|
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate }, supports)
|
|
}
|
|
return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
|
|
<header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header>
|
|
<div className="folder-editor-body">
|
|
<small>EVENT EXHIBIT · THIS HAPPENED</small>
|
|
<label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
|
|
<label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
|
|
<label className="field 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>
|
|
<div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
|
|
<div className="folder-members event-support-list">
|
|
{candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
|
|
{candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
|
|
</div>
|
|
<p className="folder-editor-note">An event is an investigator assertion. Leave occurrence time blank when it is unknown; creation time is never used for timeline placement.</p>
|
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
|
|
</div>
|
|
</form></div>
|
|
}
|
|
|
|
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
|
const [title, setTitle] = useState(document.title)
|
|
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
|
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
|
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
|
const submit = (event: React.FormEvent) => {
|
|
event.preventDefault()
|
|
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
|
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
|
}
|
|
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
|
|
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
|
|
<div className="file-editor-body">
|
|
<small>SOURCE FILE WIDGET</small>
|
|
<div className="file-editor-grid">
|
|
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
|
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
|
</div>
|
|
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
|
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
|
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
|
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
|
|
</div>
|
|
</form></div>
|
|
}
|
|
|
|
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
|
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
|
const [minimized, setMinimized] = useState(false)
|
|
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
|
|
const startDrag = (e: React.PointerEvent<HTMLElement>) => {
|
|
if ((e.target as HTMLElement).closest('button')) return
|
|
drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
}
|
|
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
|
|
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
|
{!minimized && <><nav>FILE EDIT EVIDENCE VIEW</nav>
|
|
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{documentWidget(doc.fileType).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
|
<footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
|
|
</section>
|
|
}
|
|
|
|
function DocumentAsset({ doc }: { doc: CaseDocument }) {
|
|
const source = `/api/assets/${encodeURIComponent(doc.assetId!)}`
|
|
const Asset = documentWidget(doc.fileType).Asset
|
|
return <Asset document={doc} source={source}/>
|
|
}
|
|
|
|
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> }
|