1554 lines
136 KiB
TypeScript
1554 lines
136 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||
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 { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||
import type { PlaythroughState } from './narrative'
|
||
|
||
// three.js stays out of the board bundle until the player opens the inventory.
|
||
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
|
||
|
||
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 screenshotFile(file: File, index: number) {
|
||
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png'
|
||
const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19)
|
||
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
|
||
}
|
||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||
function documentSearchText(document: CaseDocument) {
|
||
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,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 clippingInsetRotation(id:string) {
|
||
let hash=0
|
||
for (const character of id) hash=(hash * 31 + character.charCodeAt(0)) >>> 0
|
||
const degrees=((hash % 49) - 24) / 10
|
||
return degrees === 0 ? .7 : degrees
|
||
}
|
||
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 [documentClassificationQueue, setDocumentClassificationQueue] = useState<string[]>([])
|
||
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 [reportOpen, setReportOpen] = useState(false)
|
||
const [playerName, setPlayerName] = useState('Player')
|
||
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 [flagsOpen, setFlagsOpen] = useState(false)
|
||
const [inventoryOpen, setInventoryOpen] = useState(false)
|
||
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
||
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
|
||
const [advancing, setAdvancing] = useState(false)
|
||
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)
|
||
const arrivals = data.newlyVisibleDocumentIds || []
|
||
if (arrivals.length) {
|
||
setArrivingExhibitIds(arrivals)
|
||
void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }),
|
||
})
|
||
}
|
||
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)); setPlayerName(String(session?.playerName || 'Player'))
|
||
}).catch(() => setIsAdmin(false))
|
||
|
||
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
||
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||
const openLevel = async (slug: string) => {
|
||
const data = await loadLevelBySlug(slug, editQuery)
|
||
if ((data.goals.some(goal => goal.status === 'pending') || data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId))
|
||
&& !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||
try {
|
||
const playthroughResponse = await fetch('/api/playthroughs/current')
|
||
if (playthroughResponse.ok && playthroughResponse.status !== 204) {
|
||
const playthrough = await playthroughResponse.json() as PlaythroughState
|
||
setActivePlaythroughId(playthrough.node?.kind === 'level' && playthrough.node.levelSlug === data.id ? playthrough.playthrough.id : null)
|
||
} else setActivePlaythroughId(null)
|
||
} catch { setActivePlaythroughId(null) }
|
||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||
}
|
||
const boot = async () => {
|
||
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
||
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)
|
||
}
|
||
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])
|
||
|
||
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])
|
||
|
||
useEffect(() => {
|
||
if (!arrivingExhibitIds.length) return
|
||
const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800)
|
||
return () => window.clearTimeout(timer)
|
||
}, [arrivingExhibitIds])
|
||
|
||
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 = (preset?: string, presentation:NotePresentation = 'luggage') => {
|
||
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
|
||
if (!content || !caseState) return
|
||
const { viewport } = caseState
|
||
const size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 }
|
||
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width:size.width })
|
||
const note: Evidence = { id: uid('note'), type:'note', title:presentation === 'lined_sheet' ? 'FIELD NOTE' : 'WORKING NOTE', content,presentation,
|
||
...placement(position.x,position.y,size.width,size.height) }
|
||
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
|
||
}
|
||
const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom)
|
||
const target = caseState.exhibits.find(exhibit => exhibit.id === targetId)
|
||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId,
|
||
label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',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 = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
||
if (!caseState) return
|
||
const queue = Array.from(files)
|
||
setUploading(queue.length)
|
||
setDraggingFiles(false)
|
||
for (const [queueIndex, file] of queue.entries()) {
|
||
const position = nextOpenBoardPosition(caseState.exhibits, {
|
||
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
|
||
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
|
||
}, { width: 174, height: 145 })
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
form.append('x', String(position.x))
|
||
form.append('y', String(position.y))
|
||
try {
|
||
setStatus(source === 'clipboard' ? 'PASTING SCREENSHOT · READING SOURCE…' : `IMPORTING ${file.name.toUpperCase()} · READING SOURCE…`)
|
||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
|
||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
||
const uploaded: UploadedCaseDocument = await response.json()
|
||
const { analysis, ...document } = uploaded
|
||
update(s => {
|
||
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
||
})
|
||
setSelected(document.id)
|
||
setArrivingExhibitIds(current => [...new Set([...current, document.id])])
|
||
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||
})
|
||
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
|
||
if (deterministicCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(deterministicCompletion)
|
||
if (analysis.awardedFlags.length) {
|
||
window.clearTimeout(saveTimer.current)
|
||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||
await loadLevelBySlug(caseState.id, editQuery)
|
||
setSelected(document.id)
|
||
setStatus(deterministicCompletion ? caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
|
||
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
|
||
const judgeResponse = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents/${encodeURIComponent(document.id)}/judge`, { method: 'POST' })
|
||
if (judgeResponse.ok) {
|
||
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
|
||
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
|
||
if (semanticCompletion || semantic.awardedFlags.length) {
|
||
if (semanticCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(semanticCompletion)
|
||
window.clearTimeout(saveTimer.current)
|
||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||
await loadLevelBySlug(caseState.id, editQuery)
|
||
setSelected(document.id)
|
||
}
|
||
if (semanticCompletion) setStatus(caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE')
|
||
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
|
||
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
|
||
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
|
||
else setStatus('SCREENSHOT SAVED · SOURCE DOES NOT YET PROVE THE OBJECTIVE')
|
||
} else if (source === 'clipboard') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||
else setStatus(`IMPORTED · ${file.name.toUpperCase()} · TEXT ANALYZED`)
|
||
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||
if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])])
|
||
} catch (error) {
|
||
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
||
} finally { setUploading(count => count - 1) }
|
||
}
|
||
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||
|
||
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||
const presentation=documentCapture(captureKind)
|
||
const initialClipRotation=Math.round((Math.random() * 20 - 10) * 10) / 10
|
||
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height,
|
||
title:captureKind === 'clipping' && document.captureKind === 'unclassified' && document.title === document.fileName ? '' : document.title,
|
||
rotation:captureKind === 'clipping' && document.captureKind === 'unclassified' ? initialClipRotation : document.rotation })
|
||
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
||
? classify(exhibit)
|
||
: exhibit) }))
|
||
setOpenDoc(current => current?.id === documentId ? classify(current) : current)
|
||
setDocumentClassificationQueue(current => current.filter(id => id !== documentId))
|
||
setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`)
|
||
}
|
||
|
||
const continueAfterGoal = async () => {
|
||
if (!activePlaythroughId) { setCompletedGoal(null); return }
|
||
setAdvancing(true)
|
||
try {
|
||
const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
||
})
|
||
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
|
||
const next = await response.json() as PlaythroughState
|
||
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
|
||
else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
|
||
} finally { setAdvancing(false) }
|
||
}
|
||
|
||
const openCaseReport = async () => {
|
||
if (!caseState?.report) return
|
||
window.clearTimeout(saveTimer.current)
|
||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||
setStatus('COMPILING CASE REPORT…')
|
||
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}${editQuery}`, {
|
||
method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(caseState),
|
||
})
|
||
if (saved.ok) await loadLevelBySlug(caseState.id,editQuery)
|
||
setReportOpen(true)
|
||
setStatus(saved.ok ? 'CASE REPORT COMPILED FROM BOARD' : 'CASE REPORT OPEN · LATEST SERVER COPY')
|
||
}
|
||
|
||
const submitReport = async (input:CaseReportSubmissionInput) => {
|
||
if (!caseState?.report) throw new Error('Case report unavailable')
|
||
setStatus('SUBMITTING CASE REPORT…')
|
||
const response=await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/report/submissions`,{
|
||
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),
|
||
})
|
||
if (!response.ok) { const body=await response.json().catch(() => ({})); throw new Error(body.error || 'Report submission failed') }
|
||
const report=await response.json() as CaseReport
|
||
const reportEvidence=report.claims.flatMap(claim => claim.evidence)
|
||
const byDocument=new Map(reportEvidence.map(item => [item.documentExhibitId,item]))
|
||
const byConnection=new Map(reportEvidence.map(item => [item.connectionId,item]))
|
||
setCaseState(current => current ? {
|
||
...current,report,
|
||
exhibits:current.exhibits.map(exhibit => exhibit.type === 'document' && byDocument.has(exhibit.id)
|
||
? { ...exhibit,publishedAt:byDocument.get(exhibit.id)!.publishedAt,sourceCitation:byDocument.get(exhibit.id)!.sourceCitation,sourceUri:byDocument.get(exhibit.id)!.sourceUri,displayNumber:byDocument.get(exhibit.id)!.displayNumber }
|
||
: exhibit),
|
||
connections:current.connections.map(connection => byConnection.has(connection.id)
|
||
? { ...connection,label:byConnection.get(connection.id)!.relationText || undefined } : connection),
|
||
} : current)
|
||
setStatus(report.status === 'accepted' ? 'CASE REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED FOR REVISION' : 'REPORT NEEDS SUPPORTING EVIDENCE')
|
||
return report
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!caseState) return
|
||
const handlePaste = (event: ClipboardEvent) => {
|
||
const images = Array.from(event.clipboardData?.items || []).flatMap(item => {
|
||
const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null
|
||
return file ? [file] : []
|
||
})
|
||
if (!images.length) return
|
||
event.preventDefault()
|
||
void uploadFiles(images.map(screenshotFile), 'clipboard')
|
||
}
|
||
window.addEventListener('paste', handlePaste)
|
||
return () => window.removeEventListener('paste', handlePaste)
|
||
}, [caseState, uploadFiles])
|
||
|
||
if (adminRoute) return <AdminPanel />
|
||
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 classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
|
||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
|
||
const widget=exhibitWidget(exhibit.type)
|
||
if (!widget) throw new Error(`No widget is registered for exhibit type “${String(exhibit.type)}”`)
|
||
return widget.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">
|
||
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||
onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||
</Suspense>}
|
||
<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{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
||
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||
{activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
|
||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||
<button role="menuitem" onClick={() => { setFlagsOpen(true); setAdminMenuOpen(false) }}>LEVEL FLAGS</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={() => { setMatchRulesOpen(true); setAdminMenuOpen(false) }}>EVIDENCE MATCHING</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>
|
||
<><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></>
|
||
<div className="doc-list">
|
||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).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')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => reportAttentionCount ? void openCaseReport() : setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? reportAttentionCount ? 'SOURCE VERIFIED · REPORT REQUIRED' : 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||
{briefOpen && <BriefPanel
|
||
brief={caseState.brief}
|
||
goals={caseState.goals}
|
||
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) }}/>
|
||
}
|
||
{classificationDocument && <DocumentClassificationPanel document={classificationDocument}
|
||
onChoose={captureKind => classifyDocument(classificationDocument.id,captureKind)}/>}
|
||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)}
|
||
onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }}
|
||
onType={captureKind => classifyDocument(openDoc.id,captureKind)} 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)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); 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)}
|
||
/>}
|
||
{reportOpen && caseState.report && <CaseReportPanel key={`${caseState.id}:${caseState.report.status}:${caseState.report.claims.flatMap(claim => claim.evidence).length}`}
|
||
report={caseState.report} defaultInvestigator={caseState.report.investigatorName || playerName} hasNext={Boolean(activePlaythroughId)}
|
||
onClose={() => setReportOpen(false)} onSubmit={submitReport}
|
||
onContinue={() => activePlaythroughId ? void continueAfterGoal() : setReportOpen(false)}/>}
|
||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
|
||
</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, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||
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) {
|
||
const exhibit=byId.get(completedDrag.id)
|
||
if (exhibit && exhibitWidget(exhibit.type).capabilities.discardable) 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; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); const notePresentation=ev.type === 'note' ? ev.presentation === 'lined_sheet' ? 'lined-sheet' : 'luggage-tag' : ''; 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} ${notePresentation} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||
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 presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left,top,width:document.width,height:document.height,rotate:`${document.rotation}deg`,zIndex:document.zIndex,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
||
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>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (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>
|
||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||
<div className="source-file-footer">{document.captureKind === 'clipping' && <span className={`clip-provenance ${missingProvenance.length ? 'incomplete' : 'complete'}`}>{missingProvenance.length ? `ADD ${missingProvenance.join(' + ')}` : 'SOURCE RECORDED'}</span>}<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></div>
|
||
</article> })}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
function MugshotCaption({ name }:{ name:string }) {
|
||
const [visibleName,setVisibleName]=useState(name)
|
||
const [writing,setWriting]=useState(false)
|
||
const previousName=useRef(name)
|
||
useEffect(() => {
|
||
if (name === previousName.current) return
|
||
previousName.current=name
|
||
if (!name) { setVisibleName('');setWriting(false);return }
|
||
const letterDelay=Math.max(48,Math.min(82,900 / name.length))
|
||
const duration=Math.round(letterDelay * name.length)
|
||
let character=0
|
||
setVisibleName('');setWriting(true)
|
||
const stopSound=audio.sharpie(duration + 80)
|
||
const timer=window.setInterval(() => {
|
||
character+=1
|
||
setVisibleName(name.slice(0,character))
|
||
if (character >= name.length) { window.clearInterval(timer);setWriting(false) }
|
||
},letterDelay)
|
||
return () => { window.clearInterval(timer);stopSound?.() }
|
||
},[name])
|
||
return <strong className={`mugshot-caption ${writing ? 'writing' : ''}`} aria-label={name ? `Identified as ${name}` : 'Unlabelled mugshot'}><span>{visibleName}</span></strong>
|
||
}
|
||
|
||
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 publishedDateParts(value?: string) {
|
||
if (!value) return { date:'',time:'' }
|
||
const parsed=new Date(value)
|
||
if (!Number.isFinite(parsed.getTime())) return { date:'',time:'' }
|
||
const iso=parsed.toISOString()
|
||
const time=iso.slice(11,16)
|
||
return { date:iso.slice(0,10),time:time === '00:00' ? '' : time }
|
||
}
|
||
|
||
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 CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubmit, onContinue }: {
|
||
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
||
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
||
}) {
|
||
const investigatorName=report.investigatorName || defaultInvestigator
|
||
const [busy,setBusy]=useState(false)
|
||
const [error,setError]=useState('')
|
||
const submit=async (event:React.FormEvent) => {
|
||
event.preventDefault();setBusy(true);setError('')
|
||
try { await onSubmit({ investigatorName }) }
|
||
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
|
||
finally { setBusy(false) }
|
||
}
|
||
return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
|
||
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
|
||
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI–7 / EVIDENTIARY FINDING</span></div>
|
||
{report.claims.map((claim,claimIndex) => <section className="report-claim" key={claim.claimExhibitId}>
|
||
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||
<h4>EVIDENCE</h4>
|
||
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
||
: claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
||
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted ? <em>CONTENT VERIFIED</em> : rejected ? <em className="rejected">NOT VERIFIED</em> : null}</div>
|
||
{rejected && <p className="report-evidence-diagnostic">{item.verification.detail}</p>}
|
||
<div className="report-reference"><span>EVIDENTIARY STATEMENT</span><p>{item.relationText || 'No evidentiary statement attached.'}</p></div>
|
||
<div className="report-fields"><div className="report-reference"><span>DATED</span><p>{item.publishedAt?.slice(0,10) || 'NOT RECORDED'}</p></div>
|
||
<div className="report-reference"><span>SOURCE / PUBLICATION</span><p>{item.sourceCitation || 'NOT RECORDED'}</p></div></div>
|
||
<div className="report-reference"><span>SOURCE LINK · IF AVAILABLE</span>{item.sourceUri ? <a href={item.sourceUri} target="_blank" rel="noreferrer">{item.sourceUri}</a> : <p>NOT RECORDED</p>}</div>
|
||
</article> })}
|
||
</section>)}
|
||
<div className="report-investigator report-reference"><span>INVESTIGATOR</span><p>{investigatorName}</p></div>
|
||
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
|
||
{error && <p className="flag-error">{error}</p>}
|
||
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
||
? <button className="primary" type="button" onClick={onContinue}>{hasNext ? 'CONTINUE' : 'CLOSE CASE'} <span>▸</span></button>
|
||
: <button className="primary" type="submit" disabled={busy}>{busy ? 'FILING…' : 'SUBMIT CASE REPORT'}</button>}</div>
|
||
</div>
|
||
</form></div>
|
||
}
|
||
|
||
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 prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
|
||
<label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
||
<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, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||
const [minimized, setMinimized] = useState(false)
|
||
const partyById = new Map(parties.map(party => [party.id, party]))
|
||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||
const pending = goals.filter(goal => goal.status === 'pending').length
|
||
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
|
||
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
||
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
|
||
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
|
||
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
|
||
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||
</aside>
|
||
}
|
||
|
||
function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; hasNext: boolean; busy: boolean; onContinue: () => void }) {
|
||
return <div className="goal-complete-shade" role="dialog" aria-modal="true" aria-labelledby="goal-complete-title">
|
||
<section className="goal-complete-card">
|
||
<div className="goal-complete-signal"><i/><span>GU-NET SOURCE AUTHENTICATION</span><i/></div>
|
||
<small>OBJECTIVE COMPLETE</small>
|
||
<h2 id="goal-complete-title">{goal.title}</h2>
|
||
<p>{goal.completionMessage || 'The submitted evidence satisfies this objective.'}</p>
|
||
<div className="goal-complete-stamp">SOURCE<br/><b>VERIFIED</b></div>
|
||
<button type="button" disabled={busy} onClick={onContinue}>{busy ? 'OPENING NEXT FILE…' : hasNext ? 'CONTINUE' : 'RETURN TO BOARD'} <span>▸</span></button>
|
||
</section>
|
||
</div>
|
||
}
|
||
|
||
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 CaptureKindIcon({ kind }:{ kind:Exclude<DocumentCaptureKind,'unclassified'> }) {
|
||
if (kind === 'photo') return <Camera size={24}/>
|
||
if (kind === 'scene') return <Images size={24}/>
|
||
if (kind === 'clipping') return <Newspaper size={24}/>
|
||
return <FileText size={24}/>
|
||
}
|
||
|
||
function DocumentClassificationPanel({ document,onChoose }:{ document:CaseDocument;onChoose:(kind:DocumentCaptureKind)=>void }) {
|
||
const choices=(Object.keys(documentCaptureRegistry) as DocumentCaptureKind[]).filter((kind):kind is Exclude<DocumentCaptureKind,'unclassified'> => kind !== 'unclassified')
|
||
const source=document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''
|
||
return <div className="modal-shade evidence-classification-shade"><section className="evidence-classification" role="dialog" aria-modal="true" aria-labelledby="evidence-classification-title">
|
||
<header><ClipboardCheck size={16}/><b>Classify new evidence</b><span/><button type="button" aria-label="Classify later" onClick={() => onChoose('unclassified')}><X size={14}/></button></header>
|
||
<div className="evidence-classification-body">
|
||
<div className="classification-source">{source ? <img src={source} alt=""/> : <ImageIcon size={36}/>}<div><small>EXHIBIT {document.displayNumber || '—'}</small><strong>{document.title}</strong></div></div>
|
||
<div className="classification-question"><small>NEW SOURCE DOCUMENT</small><h2 id="evidence-classification-title">What kind of evidence is this?</h2><p>This changes how it appears on the board. It does not alter the original file, OCR, or provenance.</p></div>
|
||
<div className="classification-options">{choices.map(kind => { const definition=documentCapture(kind); return <button type="button" key={kind} aria-label={`Classify as ${definition.label}`} onClick={() => onChoose(kind)}>
|
||
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>
|
||
</button> })}</div>
|
||
<button type="button" className="classify-later" onClick={() => onChoose('unclassified')}>NOT SURE · CLASSIFY LATER</button>
|
||
</div>
|
||
</section></div>
|
||
}
|
||
|
||
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||
const [title, setTitle] = useState(document.title)
|
||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
||
const [publishedDate,setPublishedDate]=useState(() => publishedDateParts(document.publishedAt).date)
|
||
const [publishedTime,setPublishedTime]=useState(() => publishedDateParts(document.publishedAt).time)
|
||
const [sourceCitation,setSourceCitation]=useState(document.sourceCitation || '')
|
||
const [sourceUri,setSourceUri]=useState(document.sourceUri || '')
|
||
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||
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=publishedDate ? `${publishedDate}T${publishedTime || '00:00'}:00.000Z` : undefined
|
||
const presentation=documentCapture(captureKind)
|
||
const resolvedTitle=title.trim() || (captureKind === 'clipping' ? sourceCitation.trim() : document.fileName || 'UNTITLED FILE')
|
||
onSave({ ...document,title:resolvedTitle,fileType,captureKind,publishedAt,
|
||
sourceCitation:sourceCitation.trim() || undefined,sourceUri:sourceUri.trim() || undefined,
|
||
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||
}
|
||
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 · DEFAULTS TO SOURCE FOR CLIPS</span><input aria-label="Document title" value={title} placeholder={captureKind === 'clipping' ? 'Uses Source / Publication when blank' : ''} 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>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
|
||
<div className="file-editor-grid published-fields">
|
||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED DATE</span><input aria-label="Published date" type="date" value={publishedDate} onChange={event => { setPublishedDate(event.target.value); if (!event.target.value) setPublishedTime('') }}/></label>
|
||
<label className="field"><span>TIME · OPTIONAL · UTC</span><input aria-label="Published time" type="time" step="60" disabled={!publishedDate} value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||
</div>
|
||
<div className="file-editor-grid">
|
||
<label className="field"><span>SOURCE / PUBLICATION</span><input aria-label="Source publication" value={sourceCitation} maxLength={1000} placeholder="e.g. Google Patents · GB695913A" onChange={event => setSourceCitation(event.target.value)}/></label>
|
||
<label className="field"><span>SOURCE URL</span><input aria-label="Source URL" type="url" value={sourceUri} maxLength={2000} placeholder="https://…" onChange={event => setSourceUri(event.target.value)}/></label>
|
||
</div>
|
||
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||
<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>
|
||
|
||
<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 LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||
const [flags, setFlags] = useState<LevelFlag[]>([])
|
||
const [newKey, setNewKey] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const load = useCallback(async () => {
|
||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags`)
|
||
if (!response.ok) throw new Error('Could not load level flags')
|
||
setFlags(await response.json())
|
||
}, [levelId])
|
||
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load flags')) }, [load])
|
||
const setEarned = async (key: string, earned: boolean) => {
|
||
setBusy(true); setError('')
|
||
try {
|
||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags/${encodeURIComponent(key)}`, { method: earned ? 'PUT' : 'DELETE' })
|
||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not update flag') }
|
||
await Promise.all([load(), onChanged()])
|
||
setNewKey('')
|
||
} catch (error) { setError(error instanceof Error ? error.message : 'Could not update flag') } finally { setBusy(false) }
|
||
}
|
||
const submit = (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
const key = newKey.trim().toLowerCase()
|
||
if (key) void setEarned(key, true)
|
||
}
|
||
return <div className="modal-shade"><section className="window flags-editor">
|
||
<header><Network size={16}/><b>Level flags</b><span/><button type="button" aria-label="Close level flags" onClick={onClose}><X size={14}/></button></header>
|
||
<div className="flags-editor-body"><small>ACHIEVEMENTS / DOCUMENT REVEALS</small>
|
||
<p>Documents remain server-hidden until every flag assigned in their metadata has been earned.</p>
|
||
<div className="flag-list">{flags.length === 0 && <div className="flag-empty">NO FLAGS OR DOCUMENT GATES IN THIS LEVEL</div>}{flags.map(flag => <div className={`flag-row ${flag.earnedAt ? 'earned' : ''}`} key={flag.key}><div><b>{flag.key}</b><small>{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}</small></div><button disabled={busy} onClick={() => void setEarned(flag.key, !flag.earnedAt)}>{flag.earnedAt ? 'REVOKE' : 'AWARD'}</button></div>)}</div>
|
||
<form className="flag-add" onSubmit={submit}><input aria-label="New flag key" value={newKey} placeholder="tip.received" pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setNewKey(event.target.value.toLowerCase())}/><button disabled={busy || !newKey.trim()} type="submit">AWARD FLAG</button></form>
|
||
{error && <p className="flag-error">{error}</p>}
|
||
</div>
|
||
</section></div>
|
||
}
|
||
|
||
type MatchRuleDraft = {
|
||
id?: string
|
||
name: string
|
||
sourceLabel: string
|
||
sourceUri: string
|
||
flagKey: string
|
||
minimumAnchorMatches: number
|
||
enabled: boolean
|
||
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
|
||
}
|
||
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', sourceLabel:'', sourceUri:'', flagKey: '', minimumAnchorMatches: 1, enabled: true,
|
||
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
|
||
|
||
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
|
||
const [rules, setRules] = useState<EvidenceMatchRuleDefinition[]>([])
|
||
const [draft, setDraft] = useState<MatchRuleDraft>(emptyMatchRule)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const load = useCallback(async () => {
|
||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules`)
|
||
if (!response.ok) throw new Error('Could not load evidence match rules')
|
||
setRules(await response.json())
|
||
}, [levelId])
|
||
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
|
||
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,sourceLabel:rule.sourceLabel || '',sourceUri:rule.sourceUri || '',flagKey:rule.flagKey,
|
||
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
|
||
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
|
||
const submit = async (event: React.FormEvent) => {
|
||
event.preventDefault(); setBusy(true); setError('')
|
||
try {
|
||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
|
||
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ name:draft.name,sourceLabel:draft.sourceLabel,sourceUri:draft.sourceUri,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
|
||
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
|
||
})
|
||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
|
||
await load(); setDraft(emptyMatchRule())
|
||
} catch (error) { setError(error instanceof Error ? error.message : 'Could not save evidence rule') } finally { setBusy(false) }
|
||
}
|
||
const remove = async (rule: EvidenceMatchRuleDefinition) => {
|
||
if (!window.confirm(`Delete evidence match rule “${rule.name}”?`)) return
|
||
setBusy(true); setError('')
|
||
try {
|
||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules/${encodeURIComponent(rule.id)}`, { method:'DELETE' })
|
||
if (!response.ok) throw new Error('Could not delete evidence rule')
|
||
await load(); if (draft.id === rule.id) setDraft(emptyMatchRule())
|
||
} catch (error) { setError(error instanceof Error ? error.message : 'Could not delete evidence rule') } finally { setBusy(false) }
|
||
}
|
||
const validAnchors = draft.anchors.filter(anchor => anchor.phrase.trim().length >= 12)
|
||
const canSave = draft.name.trim() && /^[a-z][a-z0-9_.-]{0,63}$/.test(draft.flagKey) && validAnchors.length === draft.anchors.length
|
||
&& draft.minimumAnchorMatches >= 1 && draft.minimumAnchorMatches <= draft.anchors.length
|
||
return <div className="modal-shade"><section className="window match-rules-editor">
|
||
<header><Search size={16}/><b>Evidence text matching</b><span/><button type="button" aria-label="Close evidence matching" onClick={onClose}><X size={14}/></button></header>
|
||
<div className="match-rules-body"><small>OCR / FUZZY PASSAGE RULES</small>
|
||
<p>When OCR from a player-uploaded source matches enough distinctive passages, the configured flag is awarded. Matching ignores case, punctuation, accents, and ordinary OCR noise.</p>
|
||
<div className="match-rule-layout"><div className="match-rule-list">
|
||
{rules.length === 0 && <div className="flag-empty">NO AUTOMATIC EVIDENCE RULES</div>}
|
||
{rules.map(rule => <div className={`match-rule-row ${rule.enabled ? '' : 'disabled'}`} key={rule.id}><div><b>{rule.name}</b><small>{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS</small></div><button type="button" onClick={() => edit(rule)}>EDIT</button><button type="button" disabled={busy} onClick={() => void remove(rule)}><Trash2 size={12}/></button></div>)}
|
||
</div>
|
||
<form className="match-rule-form" onSubmit={event => void submit(event)}>
|
||
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
|
||
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
|
||
<div className="match-rule-fields"><label className="field"><span>SOURCE LABEL · OPTIONAL</span><input value={draft.sourceLabel} maxLength={300} onChange={event => setDraft(value => ({ ...value,sourceLabel:event.target.value }))} placeholder="Google Patents · GB695913A"/></label>
|
||
<label className="field"><span>CANONICAL URL · OPTIONAL</span><input type="url" value={draft.sourceUri} maxLength={2000} onChange={event => setDraft(value => ({ ...value,sourceUri:event.target.value }))} placeholder="https://patents.google.com/…"/></label></div>
|
||
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
|
||
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
|
||
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
|
||
<div className="anchor-heading"><b>REFERENCE PASSAGES</b><button type="button" onClick={() => setDraft(value => ({ ...value,anchors:[...value.anchors,{ id:uid('anchor'),phrase:'',minimumSimilarity:.72 }] }))}><Plus size={12}/> ADD PASSAGE</button></div>
|
||
<div className="anchor-list">{draft.anchors.map((anchor,index) => <div className="anchor-row" key={anchor.id}><div><small>ANCHOR {index + 1}</small><textarea value={anchor.phrase} rows={3} placeholder="Paste a distinctive passage of at least 12 characters…" onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,phrase:event.target.value } : item) }))}/></div><label><span>SIMILARITY</span><input type="number" min="0.5" max="1" step="0.01" value={anchor.minimumSimilarity} onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,minimumSimilarity:Number(event.target.value) } : item) }))}/></label><button type="button" aria-label="Remove reference passage" disabled={draft.anchors.length === 1} onClick={() => setDraft(value => ({ ...value,minimumAnchorMatches:Math.min(value.minimumAnchorMatches,value.anchors.length - 1),anchors:value.anchors.filter(item => item.id !== anchor.id) }))}><Trash2 size={13}/></button></div>)}</div>
|
||
{error && <p className="flag-error">{error}</p>}
|
||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CLOSE</button><button className="primary" type="submit" disabled={busy || !canSave}>{busy ? 'SAVING…' : 'SAVE RULE'}</button></div>
|
||
</form></div>
|
||
</div>
|
||
</section></div>
|
||
}
|
||
|
||
const DOCUMENT_TYPE_MENU:Exclude<DocumentCaptureKind,'unclassified'>[] = ['full_page','scene','photo','clipping']
|
||
|
||
function DocumentWindow({ doc, onClose, onInfo, onDelete, onType, onExtract, extracted }: {
|
||
doc: CaseDocument
|
||
onClose: () => void
|
||
onInfo: () => void
|
||
onDelete: () => void
|
||
onType: (captureKind:Exclude<DocumentCaptureKind,'unclassified'>) => 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 [menu, setMenu] = useState<'file'|'type'|null>(null)
|
||
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
|
||
const menuRef = useRef<HTMLElement>(null)
|
||
useEffect(() => {
|
||
if (!menu) return
|
||
const closeMenu = (event:PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenu(null) }
|
||
document.addEventListener('pointerdown',closeMenu)
|
||
return () => document.removeEventListener('pointerdown',closeMenu)
|
||
},[menu])
|
||
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 ref={menuRef} className="document-menu-bar" aria-label="Document actions">
|
||
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'file'} onClick={() => setMenu(current => current === 'file' ? null : 'file')}>FILE</button>
|
||
{menu === 'file' && <div className="document-menu-items" role="menu">
|
||
<button type="button" role="menuitem" onClick={() => { setMenu(null);onInfo() }}><Info size={13}/><span><b>INFO</b><small>Metadata and provenance</small></span></button>
|
||
<button type="button" role="menuitem" className="danger" onClick={() => { setMenu(null);onDelete() }}><Trash2 size={13}/><span><b>DELETE</b><small>Remove from this board</small></span></button>
|
||
</div>}
|
||
</div>
|
||
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'type'} onClick={() => setMenu(current => current === 'type' ? null : 'type')}>TYPE</button>
|
||
{menu === 'type' && <div className="document-menu-items type-menu" role="menu">{DOCUMENT_TYPE_MENU.map(kind => { const definition=documentCapture(kind);return <button type="button" role="menuitemradio" aria-checked={doc.captureKind === kind} key={kind} onClick={() => { onType(kind);setMenu(null) }}>
|
||
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>{doc.captureKind === kind && <Check className="menu-check" size={13}/>}</button> })}</div>}
|
||
</div>
|
||
</nav>
|
||
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`} onPointerDown={() => setMenu(null)}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).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> }
|