|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
|
|
|
|
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
|
|
|
|
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
|
|
|
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
|
|
|
|
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
|
|
|
|
|
|
|
|
|
const BOARD_W = 2400
|
|
|
|
@@ -217,6 +217,17 @@ export function App() {
|
|
|
|
|
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') }
|
|
|
|
@@ -331,7 +342,7 @@ export function App() {
|
|
|
|
|
|
|
|
|
|
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
|
|
|
|
<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)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
|
|
|
|
<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(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
|
|
|
|
{briefOpen && <BriefPanel
|
|
|
|
|
brief={caseState.brief}
|
|
|
|
|
parties={caseState.evidence.filter(item => item.type === 'party')}
|
|
|
|
@@ -452,7 +463,7 @@ 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, onConnectionTarget, onEditConnection, 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; 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, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, 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 }) {
|
|
|
|
|
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
|
|
|
|
const suppressClick = useRef(false)
|
|
|
|
|
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
|
|
|
@@ -460,6 +471,10 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|
|
|
|
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.evidence.map(e => [e.id, e])), [state.evidence])
|
|
|
|
|
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
|
|
|
|
const pointForId = (id: string) => {
|
|
|
|
@@ -501,6 +516,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|
|
|
|
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
|
|
|
|
|
const position = relation ? relationPosition(state, relation) : undefined
|
|
|
|
|
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
|
|
|
|
|
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) => {
|
|
|
|
@@ -539,6 +555,12 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|
|
|
|
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)
|
|
|
|
@@ -559,9 +581,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|
|
|
|
touchPoints.current.delete(event.pointerId)
|
|
|
|
|
if (touchPoints.current.size < 2) pinchDistance.current = null
|
|
|
|
|
}
|
|
|
|
|
if (drag.current) suppressClick.current = Boolean(drag.current.moved)
|
|
|
|
|
const completedDrag = drag.current
|
|
|
|
|
if (completedDrag) suppressClick.current = Boolean(completedDrag.moved)
|
|
|
|
|
drag.current = null
|
|
|
|
|
setDraggingThreadTagId(null)
|
|
|
|
|
setDraggingWidget(false)
|
|
|
|
|
setTrashActive(false)
|
|
|
|
|
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
|
|
|
|
trashTarget.current = false
|
|
|
|
|
}
|
|
|
|
|
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
|
|
|
|
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
|
|
|
@@ -573,6 +600,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
|
|
|
|
|
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}>
|
|
|
|
|