Add story-graph narrative system (campaigns, editors, runtime)

Introduce the narrative layer as a directed story-flow graph: an authored
campaign a player walks node by node, replacing the interim slot/chapter model.

Schema (migrations 015-021):
- mysteries, global NPC templates + named poses, per-user playthroughs
- story_nodes, terminals, utterances (the flow graph and dialogue trees)
- clean cutover: retire slot cutscenes/chapters/seen_dialogue

Runtime:
- New Game creates a playthrough bound to the JWT identity (dev test-user fallback)
- advance() walks the graph cutscene -> dialogue -> level -> ..., auto-skipping gates
- branching dialogue: player choices route out through node terminals

Admin authoring:
- NPC editor: upload named poses to the gupi MinIO bucket
- mystery graph editor: vertical node canvas, wiring, entrypoint, delete-by-click
- dialogue crafter: utterance tree, Tab to add child, 1/2 speaker, undo

Content authored via the manifest importer / admin panel and seeded for Glass
Harbour. MinIO added to the dev stack; dev container runs in development mode.

Also includes a folder-widget simplification (removes open/close) and a
resolveUserId auth helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 15:37:46 +02:00
co-authored by Claude Opus 4.8
parent 0237da74cf
commit ddb3a386f0
30 changed files with 3016 additions and 55 deletions
+118 -23
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
import { AdminPanel } from './admin'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
@@ -60,38 +62,94 @@ export function App() {
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
const [splashOpen, setSplashOpen] = useState(false)
const [splashBusy, setSplashBusy] = useState(false)
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const adminMenuRef = useRef<HTMLDivElement>(null)
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
const adminRoute = window.location.pathname === '/admin'
const loadLevelBySlug = useCallback(async (slug: string, editQuery = '') => {
const response = await fetch(`/api/levels/${encodeURIComponent(slug)}${editQuery}`)
if (!response.ok) throw new Error('Level unavailable')
const data = normalizeCase(await response.json())
setCaseState(data)
return data
}, [])
useEffect(() => {
if (adminRoute) return
const params = new URLSearchParams(window.location.search)
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
fetch('/api/levels').then(r => {
if (!r.ok) throw new Error('Server unavailable')
return r.json()
}).then(async (levels: { id: string }[]) => {
const levelId = params.get('level') || levels[0]?.id
if (!levelId) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
if (!response.ok) throw new Error('Level unavailable')
const data = normalizeCase(await response.json())
setCaseState(data)
const deepLinkLevel = params.get('level')
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
const openLevel = async (slug: string) => {
const data = await loadLevelBySlug(slug, editQuery)
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
})
.catch(() => {
}
// Player campaign entry: a live playthrough resumes silently; none shows the splash.
// An explicit ?level= deep link (admin/authoring) bypasses the campaign entirely.
const boot = async () => {
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
const current = await fetch('/api/playthroughs/current')
if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
if (!current.ok) throw new Error('Playthrough unavailable')
const state: PlaythroughState = await current.json()
setPlaythrough(state.playthrough)
setRuntimeNode(state.node)
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
}
boot().catch(async () => {
try {
const levels = await (await fetch('/api/levels')).json() as { id: string }[]
if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
await openLevel(levels[0].id)
} catch {
const cached = localStorage.getItem('gupi-osint-board:last')
if (cached) setCaseState(normalizeCase(JSON.parse(cached)))
setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE')
})
}
})
const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
tick(); const timer = window.setInterval(tick, 30000)
return () => clearInterval(timer)
}, [])
}, [loadLevelBySlug, adminRoute])
const applyState = useCallback(async (state: PlaythroughState) => {
setPlaythrough(state.playthrough)
setRuntimeNode(state.node)
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
if (!state.node && state.playthrough.status === 'finished') { setSplashOpen(true); setStatus('CASE CLOSED · GREYHAVEN FILE 87-10') }
}, [loadLevelBySlug])
const startNewGame = useCallback(async () => {
setSplashBusy(true)
try {
const response = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
if (!response.ok) throw new Error('Could not start')
await applyState(await response.json())
setSplashOpen(false)
} catch { setStatus('COULD NOT OPEN CASE FILE') } finally { setSplashBusy(false) }
}, [applyState])
// Advance the story graph through a terminal (a dialogue supplies the chosen exit;
// cutscene/level advance through the node's single terminal).
const advance = useCallback(async (terminalKey?: string) => {
if (!playthrough) return
try {
const response = await fetch(`/api/playthroughs/${encodeURIComponent(playthrough.id)}/advance`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(terminalKey ? { terminalKey } : {}) })
if (!response.ok) throw new Error()
await applyState(await response.json())
} catch { setStatus('COULD NOT ADVANCE') }
}, [playthrough, applyState])
useEffect(() => {
if (!adminMenuOpen) return
@@ -325,6 +383,11 @@ export function App() {
}
}
if (adminRoute) return <AdminPanel />
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
if (runtimeNode?.kind === 'cutscene') return <CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} />
if (runtimeNode?.kind === 'dialogue') return <DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} />
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
@@ -355,9 +418,11 @@ export function App() {
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
<button onClick={() => setHelpOpen(true)}>HELP</button>
{runtimeNode?.kind === 'level' && <button className="report-back" onClick={() => advance()} title="Finish investigating and continue the story">REPORT BACK </button>}
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
{adminMenuOpen && <div className="admin-menu-items" role="menu">
<button role="menuitem" onClick={() => window.location.assign('/admin')}>NPC &amp; MYSTERY ADMIN</button>
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF &amp; CONCEPTS</button>
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
@@ -387,7 +452,7 @@ export function App() {
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
@@ -420,7 +485,7 @@ export function App() {
</div>
</section>
<DocumentLocatorBeam documentId={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('|')}`} />
<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) }}/>
}
@@ -544,11 +609,12 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
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, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
const pinchDistance = useRef<number | null>(null)
const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
@@ -584,6 +650,8 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
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
@@ -612,6 +680,11 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
}
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) {
@@ -653,6 +726,10 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
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
@@ -667,9 +744,27 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
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 === 'toggle-folder') toggleFolder(command.folderId)
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)
@@ -702,14 +797,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
<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 === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
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 && selected === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>