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:
+118
-23
@@ -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 & MYSTERY ADMIN</button>
|
||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||
@@ -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>
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { MysteryGraphEditor } from './mysteryGraph'
|
||||
|
||||
type Pose = { poseKey: string; assetId: string; url: string }
|
||||
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: Pose[]; inUse: boolean }
|
||||
type Mystery = { id: string; slug: string; title: string; nodes: number }
|
||||
|
||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function AdminPanel() {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
|
||||
const [tab, setTab] = useState<'npcs' | 'mysteries'>('npcs')
|
||||
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||
const [mysteries, setMysteries] = useState<Mystery[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [editingMystery, setEditingMystery] = useState<{ id: string; title: string } | null>(null)
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/session').then(r => r.json()).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
}, [])
|
||||
|
||||
const reloadNpcs = useCallback(async (selectKey?: string) => {
|
||||
const list = await json<Npc[]>('/api/admin/npcs')
|
||||
setNpcs(list)
|
||||
setSelectedId(current => selectKey ? (list.find(npc => npc.key === selectKey)?.id ?? current) : (current && list.some(npc => npc.id === current) ? current : list[0]?.id ?? null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
reloadNpcs().catch(error => setStatus(String(error.message || error)))
|
||||
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {})
|
||||
}, [isAdmin, reloadNpcs])
|
||||
|
||||
if (isAdmin === null) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY · ADMIN</p><small>AUTHENTICATING…</small></div>
|
||||
if (!isAdmin) return <div className="boot"><div className="seal">GU</div><p>ADMINISTRATOR ACCESS REQUIRED</p><small><a className="admin-link" href="/">← RETURN TO TERMINAL</a></small></div>
|
||||
|
||||
const selected = npcs.find(npc => npc.id === selectedId) || null
|
||||
return <div className="admin">
|
||||
<header className="admin-head">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ AUTHORING</em></span></div>
|
||||
<nav className="admin-tabs">
|
||||
<button className={tab === 'npcs' ? 'active' : ''} onClick={() => setTab('npcs')}>NPCS</button>
|
||||
<button className={tab === 'mysteries' ? 'active' : ''} onClick={() => setTab('mysteries')}>MYSTERIES</button>
|
||||
</nav>
|
||||
<a className="admin-link" href="/">← TERMINAL</a>
|
||||
</header>
|
||||
|
||||
{tab === 'npcs' && <div className="admin-body">
|
||||
<aside className="npc-list">
|
||||
<div className="npc-list-head"><span>NPC TEMPLATES</span><button onClick={() => createNpc(setStatus, reloadNpcs)}>+ NEW</button></div>
|
||||
{npcs.length === 0 && <p className="admin-empty">No NPC templates yet.</p>}
|
||||
{npcs.map(npc => <button key={npc.id} className={`npc-row${npc.id === selectedId ? ' selected' : ''}`} onClick={() => setSelectedId(npc.id)}>
|
||||
<span className="npc-avatar">{npc.poses[0] ? <img src={npc.poses[0].url} alt="" /> : npc.name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className="npc-row-text"><strong>{npc.name || npc.key}</strong><small>{npc.role || npc.key}</small></span>
|
||||
</button>)}
|
||||
</aside>
|
||||
{selected
|
||||
? <NpcEditor key={selected.id} npc={selected} onChanged={reloadNpcs} setStatus={setStatus} />
|
||||
: <div className="npc-editor empty">Select or create an NPC.</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'mysteries' && (editingMystery
|
||||
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => setEditingMystery(null)} setStatus={setStatus} />
|
||||
: <div className="admin-body">
|
||||
<div className="mystery-list">
|
||||
{mysteries.length === 0 && <p className="admin-empty">No mysteries authored yet.</p>}
|
||||
{mysteries.map(mystery => <button key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
|
||||
<strong>{mystery.title}</strong>
|
||||
<span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →</span>
|
||||
</button>)}
|
||||
<p className="admin-note">Click a mystery to open its story-flow graph editor.</p>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
<footer className="admin-foot">{status || 'READY'}</footer>
|
||||
</div>
|
||||
}
|
||||
|
||||
async function createNpc(setStatus: (message: string) => void, reload: (key?: string) => Promise<void>) {
|
||||
const name = window.prompt('NPC display name (e.g. Prof. Almira Vetch)')?.trim()
|
||||
if (!name) return
|
||||
const key = window.prompt('Short key (e.g. professor)', name.toLowerCase().split(/\s+/).pop() || '')?.trim()
|
||||
if (!key) return
|
||||
try {
|
||||
await json('/api/admin/npcs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key, name }) })
|
||||
await reload(key.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''))
|
||||
setStatus(`Created ${name}`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?: string) => Promise<void>; setStatus: (message: string) => void }) {
|
||||
const [name, setName] = useState(npc.name)
|
||||
const [role, setRole] = useState(npc.role)
|
||||
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
|
||||
const [poseKey, setPoseKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null }) })
|
||||
await onChanged(npc.key); setStatus(`Saved ${name}`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||
}
|
||||
const uploadPose = async (file: File) => {
|
||||
const key = (poseKey || file.name.replace(/\.[^.]+$/, '')).trim()
|
||||
setBusy(true)
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file); form.append('poseKey', key)
|
||||
await json(`/api/admin/npcs/${npc.id}/poses`, { method: 'POST', body: form })
|
||||
setPoseKey(''); if (fileRef.current) fileRef.current.value = ''
|
||||
await onChanged(npc.key); setStatus(`Uploaded pose “${key}”`)
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||
}
|
||||
const removePose = async (key: string) => {
|
||||
if (!window.confirm(`Remove pose “${key}”?`)) return
|
||||
try { await json(`/api/admin/npcs/${npc.id}/poses/${encodeURIComponent(key)}`, { method: 'DELETE' }); await onChanged(npc.key) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete NPC ${npc.name}? This cannot be undone.`)) return
|
||||
try { await json(`/api/admin/npcs/${npc.id}`, { method: 'DELETE' }); await onChanged() }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
return <section className="npc-editor">
|
||||
<div className="npc-editor-head">
|
||||
<h2>{npc.name || npc.key}</h2>
|
||||
<code>{npc.key}</code>
|
||||
<button className="danger" disabled={npc.inUse} title={npc.inUse ? 'Used by a cutscene' : 'Delete NPC'} onClick={remove}>DELETE</button>
|
||||
</div>
|
||||
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
|
||||
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
|
||||
<div className="admin-field"><label>Default pose</label>
|
||||
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{npc.poses.map(pose => <option key={pose.poseKey} value={pose.poseKey}>{pose.poseKey}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button className="admin-save" disabled={!dirty || busy} onClick={save}>{busy ? 'SAVING…' : dirty ? 'SAVE CHANGES' : 'SAVED'}</button>
|
||||
|
||||
<h3>Poses</h3>
|
||||
<div className="pose-grid">
|
||||
{npc.poses.map(pose => <figure key={pose.poseKey} className={`pose-card${pose.poseKey === defaultPose ? ' is-default' : ''}`}>
|
||||
<img src={pose.url} alt={pose.poseKey} />
|
||||
<figcaption>{pose.poseKey}</figcaption>
|
||||
<button className="pose-remove" title="Remove pose" onClick={() => removePose(pose.poseKey)}>×</button>
|
||||
</figure>)}
|
||||
{npc.poses.length === 0 && <p className="admin-empty">No poses yet. Upload a portrait below.</p>}
|
||||
</div>
|
||||
<div className="pose-upload">
|
||||
<input className="pose-key" value={poseKey} onChange={event => setPoseKey(event.target.value)} placeholder="pose key (e.g. neutral)" />
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={event => { const file = event.target.files?.[0]; if (file) void uploadPose(file) }} />
|
||||
</div>
|
||||
<small className="admin-hint">Poses referenced in dialogue fall back to the default pose, then to no artwork. Upload big portraits — they fill the screen in cutscenes.</small>
|
||||
</section>
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import { BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||
|
||||
export type WidgetCommand =
|
||||
| { type: 'open-document'; documentId: string }
|
||||
| { type: 'toggle-folder'; folderId: string }
|
||||
| { type: 'edit-folder'; folderId: string }
|
||||
| { type: 'edit-event'; eventId: string }
|
||||
| { type: 'edit-party'; partyId: string }
|
||||
@@ -40,13 +39,8 @@ const relationsFrom = (context: ExhibitWidgetContext, exhibitId: string, type: E
|
||||
|
||||
function FolderWidget({ exhibit, context }: ExhibitWidgetProps) {
|
||||
if (exhibit.type !== 'folder') return null
|
||||
const documents = relationsFrom(context, exhibit.id, 'contains').flatMap(relation => {
|
||||
const document = context.exhibits.find(candidate => candidate.id === relation.toExhibitId)
|
||||
return document?.type === 'document' ? [document] : []
|
||||
})
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
{!exhibit.isOpen && <div className="folder-documents">{documents.slice(0,3).map(document => <button key={document.id} onClick={() => context.dispatch({ type:'open-document',documentId:document.id })} title={document.title}><FileText size={12}/><span>{document.title}</span>{document.publishedAt && <time>{document.publishedAt.slice(0,10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
|
||||
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'toggle-folder',folderId:exhibit.id })}>{exhibit.isOpen ? <Folder size={12}/> : <FolderOpen size={12}/>} {exhibit.isOpen ? 'CLOSE' : 'OPEN'}</button><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={12}/> EDIT</button></div>
|
||||
<div className="folder-actions"><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={15}/> EDIT</button></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { UtteranceCanvas } from './utteranceCanvas'
|
||||
|
||||
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
||||
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
||||
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; terminals: Terminal[] }
|
||||
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
|
||||
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
|
||||
|
||||
// Vertical layout: input on top, output terminals along the bottom; flow runs downward.
|
||||
const NODE_W = 200, NODE_H = 88
|
||||
const TYPES: { type: StoryNodeType; label: string }[] = [
|
||||
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
|
||||
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
|
||||
]
|
||||
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
|
||||
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
|
||||
|
||||
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
|
||||
return response.json().catch(() => ({} as T))
|
||||
}
|
||||
|
||||
export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { mysteryId: string; title: string; onClose: () => void; setStatus: (message: string) => void }) {
|
||||
const [graph, setGraph] = useState<Graph | null>(null)
|
||||
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
||||
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
|
||||
const [utterancesNode, setUtterancesNode] = useState<StoryNode | null>(null)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{ kind: 'pan' | 'node'; id?: string; startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setGraph(await api<Graph>(`/api/admin/mysteries/${mysteryId}/graph`, 'GET')) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}, [mysteryId, setStatus])
|
||||
useEffect(() => { void reload(); api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {}) }, [reload])
|
||||
|
||||
const centerInBoard = () => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
const cx = rect ? rect.width / 2 : 300, cy = rect ? rect.height / 2 : 200
|
||||
return { x: (cx - view.x) / view.zoom, y: (cy - view.y) / view.zoom }
|
||||
}
|
||||
|
||||
const addNode = async (nodeType: StoryNodeType) => {
|
||||
const at = centerInBoard()
|
||||
try { const node = await api<StoryNode>(`/api/admin/mysteries/${mysteryId}/nodes`, 'POST', { nodeType, xpos: Math.round(at.x), ypos: Math.round(at.y) }); await reload(); setSelectedId(node.id) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const patchNode = async (id: string, body: Record<string, unknown>) => { try { await api(`/api/admin/story-nodes/${id}`, 'PATCH', body); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }
|
||||
const wire = async (terminalId: string, toNodeId: string | null) => { try { await api(`/api/admin/story-terminals/${terminalId}`, 'PATCH', { toNodeId }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }
|
||||
|
||||
// Pointer: pan on background, drag on node header.
|
||||
const onPointerDown = (event: React.PointerEvent) => {
|
||||
if (wiringFrom) { setWiringFrom(null); return }
|
||||
drag.current = { kind: 'pan', startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }
|
||||
setSelectedId(null)
|
||||
}
|
||||
const onNodePointerDown = (event: React.PointerEvent, node: StoryNode) => {
|
||||
event.stopPropagation()
|
||||
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
|
||||
drag.current = { kind: 'node', id: node.id, startX: event.clientX, startY: event.clientY, origX: node.xpos, origY: node.ypos }
|
||||
setSelectedId(node.id)
|
||||
}
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
const state = drag.current
|
||||
if (!state) return
|
||||
const dx = event.clientX - state.startX, dy = event.clientY - state.startY
|
||||
if (state.kind === 'pan') setView(v => ({ ...v, x: state.origX + dx, y: state.origY + dy }))
|
||||
else setGraph(g => g && { ...g, nodes: g.nodes.map(n => n.id === state.id ? { ...n, xpos: state.origX + dx / view.zoom, ypos: state.origY + dy / view.zoom } : n) })
|
||||
}
|
||||
const onPointerUp = async () => {
|
||||
const state = drag.current; drag.current = null
|
||||
if (state?.kind === 'node' && state.id) {
|
||||
const node = graph?.nodes.find(n => n.id === state.id)
|
||||
if (node) { try { await api(`/api/admin/story-nodes/${state.id}`, 'PATCH', { xpos: Math.round(node.xpos), ypos: Math.round(node.ypos) }) } catch { /* position persists next reload */ } }
|
||||
}
|
||||
}
|
||||
const onWheel = (event: React.WheelEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return
|
||||
const px = event.clientX - rect.left, py = event.clientY - rect.top
|
||||
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||
setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } })
|
||||
}
|
||||
|
||||
const onNodeClick = (event: React.MouseEvent, node: StoryNode) => {
|
||||
event.stopPropagation()
|
||||
if (wiringFrom) { void wire(wiringFrom, node.id); setWiringFrom(null); return }
|
||||
setSelectedId(node.id)
|
||||
}
|
||||
|
||||
if (!graph) return <div className="graph-loading">Loading graph…</div>
|
||||
const selected = graph.nodes.find(n => n.id === selectedId) || null
|
||||
const nodeById = new Map(graph.nodes.map(n => [n.id, n]))
|
||||
|
||||
return <div className="graph-editor">
|
||||
{utterancesNode && <UtteranceCanvas nodeId={utterancesNode.id} nodeLabel={utterancesNode.label} terminals={utterancesNode.terminals}
|
||||
onClose={() => { setUtterancesNode(null); void reload() }} setStatus={setStatus} />}
|
||||
<div className="graph-toolbar">
|
||||
<button className="graph-back" onClick={onClose}>← Mysteries</button>
|
||||
<strong>{title}</strong>
|
||||
<span className="graph-add-label">Add:</span>
|
||||
{TYPES.map(t => <button key={t.type} className="graph-add" onClick={() => addNode(t.type)}>{t.label}</button>)}
|
||||
{wiringFrom && <span className="graph-wiring">Click a target node to wire · click empty to cancel</span>}
|
||||
<span className="graph-zoom">{Math.round(view.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div className="graph-main">
|
||||
<div ref={canvasRef} className={`graph-canvas${wiringFrom ? ' wiring' : ''}`} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel}>
|
||||
<div className="graph-world" style={{ transform: `translate(${view.x}px,${view.y}px) scale(${view.zoom})` }}>
|
||||
<svg className="graph-wires" width="6000" height="6000">
|
||||
{graph.nodes.flatMap(node => node.terminals.filter(t => t.toNodeId && nodeById.has(t.toNodeId)).map(t => {
|
||||
const target = nodeById.get(t.toNodeId!)!
|
||||
const from = outPort(node, node.terminals.indexOf(t)), to = inPort(target)
|
||||
const dy = Math.max(40, Math.abs(to.y - from.y) / 2)
|
||||
const d = `M${from.x},${from.y} C${from.x},${from.y + dy} ${to.x},${to.y - dy} ${to.x},${to.y}`
|
||||
return [
|
||||
<path key={t.id + 'hit'} className="wire-hit" d={d} onClick={event => { event.stopPropagation(); void wire(t.id, null) }} />,
|
||||
<path key={t.id} className="graph-wire" d={d} />,
|
||||
]
|
||||
}))}
|
||||
</svg>
|
||||
{graph.nodes.map(node => <div key={node.id} className={`gnode type-${node.nodeType}${node.id === selectedId ? ' selected' : ''}${graph.entryNodeId === node.id ? ' entry' : ''}`}
|
||||
style={{ left: node.xpos, top: node.ypos, width: NODE_W, height: NODE_H }} onPointerDown={event => event.stopPropagation()} onClick={event => onNodeClick(event, node)}
|
||||
onDoubleClick={event => { event.stopPropagation(); if (node.nodeType === 'dialogue' || node.hasUtterances) setUtterancesNode(node) }}>
|
||||
<div className="ginput" />
|
||||
<div className="gnode-head" onPointerDown={event => onNodePointerDown(event, node)}>
|
||||
<span className="gnode-type">{node.nodeType}</span>
|
||||
<span className="gnode-label">{node.label}</span>
|
||||
{graph.entryNodeId === node.id && <span className="gnode-entry">▶</span>}
|
||||
</div>
|
||||
<div className="gnode-sub">{nodeSummary(node, templates)}</div>
|
||||
<div className="gnode-outs">
|
||||
{node.terminals.map(t => <div key={t.id} className="gout">
|
||||
<span className="gout-label">{t.label || t.terminalKey}</span>
|
||||
<button className={`gport${t.toNodeId ? ' wired' : ''}${wiringFrom === t.id ? ' active' : ''}`} title="Click to start a wire"
|
||||
onClick={event => { event.stopPropagation(); setWiringFrom(from => from === t.id ? null : t.id) }} />
|
||||
</div>)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
{graph.nodes.length === 0 && <div className="graph-empty">Empty graph — add a node to begin.</div>}
|
||||
</div>
|
||||
|
||||
{selected && <aside className="graph-inspector">
|
||||
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates}
|
||||
onEditUtterances={() => setUtterancesNode(selected)}
|
||||
onPatch={body => patchNode(selected.id, body)}
|
||||
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onDelete={async () => { if (!window.confirm('Delete this node?')) return; try { await api(`/api/admin/story-nodes/${selected.id}`, 'DELETE'); setSelectedId(null); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onAddTerminal={async () => { const key = window.prompt('Terminal key (e.g. proceed)'); if (!key) return; try { await api(`/api/admin/story-nodes/${selected.id}/terminals`, 'POST', { terminalKey: key, label: key }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onTerminalPatch={async (id, body) => { try { await api(`/api/admin/story-terminals/${id}`, 'PATCH', body); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||
onTerminalDelete={async id => { try { await api(`/api/admin/story-terminals/${id}`, 'DELETE'); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }} />
|
||||
</aside>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
||||
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
|
||||
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
|
||||
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
|
||||
return ''
|
||||
}
|
||||
|
||||
function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||
node: StoryNode; graph: Graph; templates: LevelTemplate[]
|
||||
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
|
||||
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
|
||||
}) {
|
||||
const [label, setLabel] = useState(node.label)
|
||||
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
||||
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
||||
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
|
||||
return <div className="inspector-body">
|
||||
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
|
||||
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
|
||||
{node.nodeType === 'level' && <label className="ins-field"><span>Level template</span>
|
||||
<select value={node.levelTemplateVersionId || ''} onChange={e => onPatch({ levelTemplateVersionId: e.target.value || null })}>
|
||||
<option value="">— choose —</option>
|
||||
{templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)}
|
||||
</select></label>}
|
||||
{usesComponent && <label className="ins-field"><span>Component key</span><input value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} /></label>}
|
||||
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
|
||||
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances →</button>}
|
||||
|
||||
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
|
||||
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
||||
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
|
||||
<span className="ins-terminal-to">→ {nodeName(t.toNodeId)}</span>
|
||||
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}>⊘</button>}
|
||||
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
|
||||
</div>)}
|
||||
|
||||
<button className="ins-delete" onClick={onDelete}>Delete node</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||
|
||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
if (!query) return
|
||||
const listener = (event: MediaQueryListEvent) => setReduced(event.matches)
|
||||
query.addEventListener('change', listener)
|
||||
return () => query.removeEventListener('change', listener)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
export function SplashScreen({ hasResume, busy, status, onNewGame, onResume }: {
|
||||
hasResume: boolean; busy: boolean; status?: string; onNewGame: () => void; onResume: () => void
|
||||
}) {
|
||||
return <div className="splash">
|
||||
<div className="splash-plate">
|
||||
<div className="seal">GU</div>
|
||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||
<p className="splash-sub">Glitch University</p>
|
||||
<div className="splash-actions">
|
||||
{hasResume && <button className="splash-button" disabled={busy} onClick={onResume}>RESUME</button>}
|
||||
<button className="splash-button primary" disabled={busy} onClick={onNewGame}>NEW GAME</button>
|
||||
</div>
|
||||
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status || 'GLITCH UNIVERSITY NETWORK TERMINAL'}</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Bespoke cutscene components, keyed by a node's component_key (mirrors the exhibit registry).
|
||||
const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) => (
|
||||
<div className="cutscene-card title-card" onClick={onComplete}>
|
||||
<div className="title-card-inner">
|
||||
<small>Greyhaven file 87-10</small>
|
||||
<h1>The Glass Harbour Diversion</h1>
|
||||
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Begin ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
||||
|
||||
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
||||
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
||||
if (Component) return <Component onComplete={onComplete} />
|
||||
return <div className="cutscene-card title-card" onClick={onComplete}>
|
||||
<div className="title-card-inner">
|
||||
<h1>{label}</h1>
|
||||
<small className="cutscene-missing">{componentKey ? `component "${componentKey}" not registered` : 'no component set'}</small>
|
||||
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Continue ▸</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||
export function DialoguePlayer({ node, onExit }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void }) {
|
||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||
const [currentId, setCurrentId] = useState<string | null>(node.rootId)
|
||||
const [charCount, setCharCount] = useState(0)
|
||||
const reduced = usePrefersReducedMotion()
|
||||
const current = currentId ? byId.get(currentId) ?? null : null
|
||||
const fullText = current?.text ?? ''
|
||||
const done = charCount >= fullText.length
|
||||
const children = current ? current.childIds.map(id => byId.get(id)).filter((c): c is RuntimeUtterance => Boolean(c)) : []
|
||||
const options = children.filter(c => c.utterer === 'player')
|
||||
const showChoices = done && options.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) { onExit(); return }
|
||||
if (reduced) { setCharCount(fullText.length); return }
|
||||
setCharCount(0)
|
||||
const id = window.setInterval(() => setCharCount(count => (count >= fullText.length ? count : count + 1)), 18)
|
||||
return () => window.clearInterval(id)
|
||||
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const pick = (choice: RuntimeUtterance) => {
|
||||
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
||||
else onExit(choice.terminalKey ?? undefined)
|
||||
}
|
||||
const proceedRef = useRef(() => {})
|
||||
proceedRef.current = () => {
|
||||
if (!current) { onExit(); return }
|
||||
if (!done) { setCharCount(fullText.length); return }
|
||||
if (children.length === 0) { onExit(current.terminalKey ?? undefined); return }
|
||||
if (options.length > 0) return // a branch — wait for a choice
|
||||
setCurrentId(children[0].id) // linear next line
|
||||
}
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (!showChoices && (event.key === ' ' || event.key === 'Enter' || event.key === 'ArrowRight')) { event.preventDefault(); proceedRef.current() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [showChoices])
|
||||
|
||||
if (!current) return null
|
||||
return <div className="dialogue" role="dialog" aria-label="Dialogue" onClick={() => { if (!showChoices) proceedRef.current() }}>
|
||||
<div className="dialogue-portrait">{current.poseUrl && <img src={current.poseUrl} alt={current.speaker.name} />}</div>
|
||||
<div className="dialogue-scrim" aria-hidden />
|
||||
<div className="dialogue-box">
|
||||
<div className="dialogue-panel">
|
||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
|
||||
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
||||
{showChoices
|
||||
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
||||
: <div className="dialogue-advance">{done ? 'CONTINUE ▸' : ''}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+274
-15
@@ -122,6 +122,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.evidence-card.folder::after { background: #80643e; }
|
||||
.evidence-card.folder header { border-color: #846e49; color: #594a33; }
|
||||
.evidence-card.folder h3 { color: #66401f; }
|
||||
.evidence-card.folder p { max-height: 58px; padding-right: 2px; overflow: hidden; }
|
||||
.evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
|
||||
.evidence-card.event h3 { color: #70401e; }
|
||||
.evidence-card.event p { font-size: 15px; }
|
||||
@@ -135,13 +136,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
||||
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
||||
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
||||
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
|
||||
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
|
||||
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.folder-documents button time { border: 0; padding: 0; font-size: 7px; }
|
||||
.folder-documents small { display: block; padding: 4px 0 1px 17px; color: #69573d; font: 7px IBM Plex Mono; }
|
||||
.folder-actions { clear: both; display: flex; justify-content: flex-end; gap: 12px; margin-top: 7px; border-top: 1px solid #9a7d50; padding-top: 5px; }
|
||||
.folder-actions button { float: none; color: #60401f; }
|
||||
.folder-actions { position: absolute; right: 13px; bottom: 10px; }
|
||||
.folder-actions button { float: none; min-height: 29px; padding: 6px 9px; gap: 6px; border: 1px solid #81633d; background: #d9bb82; color: #563617; font-size: 9px; box-shadow: 2px 2px #75552f66; }
|
||||
.folder-actions button:hover { background: #e5ca96; color: #321f0f; }
|
||||
.source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); }
|
||||
.source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; }
|
||||
.source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); }
|
||||
@@ -190,7 +187,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
|
||||
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
|
||||
.brief-panel { position: absolute; z-index: 12; right: 18px; top: 18px; width: min(410px, 42vw); max-height: calc(100% - 36px); overflow: auto; background: #c6c9c1; color: #17231f; border: 2px solid #d8dbd4; box-shadow: 7px 9px 0 #020a08, 0 0 0 1px #46554f; }
|
||||
.brief-panel > header { height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
||||
.brief-panel > header { position: sticky; z-index: 3; top: 0; height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
||||
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
|
||||
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
|
||||
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||
@@ -220,11 +217,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.marker.document i { border-radius: 50%; rotate: 0deg; width: 9px; height: 9px; border-color: #89948f; background: #263a34; }
|
||||
.marker.selected i { border-color: #eea458; background: #eea458; }
|
||||
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
|
||||
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.window { position: fixed; z-index: 30; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); overflow-y: auto; overscroll-behavior: contain; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-position-control { margin-top: 17px; }.thread-position-control output { margin-left: auto; color: #9a5c2f; }.thread-position-control input { accent-color: #9a5c2f; padding: 0; }.thread-position-control > small { color: #68736d; font: 7px/1.4 IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; }
|
||||
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
|
||||
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header { position: sticky; z-index: 3; top: 0; height: 31px; min-height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
|
||||
@@ -277,11 +274,273 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
||||
@media (max-width: 900px) {
|
||||
.menubar { grid-template-columns: 39px minmax(0, 1fr) 10px; gap: 5px; padding: 0 8px; }
|
||||
.brand { gap: 0; }
|
||||
.brand > span:not(.brand-mark) { display: none; }
|
||||
.brand-mark { width: 33px; height: 33px; }
|
||||
.menubar nav { min-width: 0; display: flex; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-inline: contain; -webkit-overflow-scrolling: touch; }
|
||||
.menubar nav::-webkit-scrollbar { display: none; }
|
||||
.menubar nav button { flex: 0 0 auto; padding: 0 10px; font-size: 8px; letter-spacing: .05em; white-space: nowrap; }
|
||||
.admin-menu { flex: 0 0 auto; }
|
||||
.menubar nav .admin-menu-items { position: fixed; top: 68px; right: 8px; width: min(235px, calc(100vw - 16px)); height: auto; }
|
||||
.terminal-status { justify-content: center; gap: 0; font-size: 0; }
|
||||
.terminal-status span { display: none; }
|
||||
.documents-panel { width: 275px; }
|
||||
.documents-panel.closed { margin-left: -275px; }
|
||||
.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }
|
||||
.timeline-key { display: none; }
|
||||
.timeline-track { margin: 0 23px; }
|
||||
.document-window { width: 80vw; }
|
||||
.case-heading { left: 18px; }
|
||||
.case-number { display: none; }
|
||||
.brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; }
|
||||
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
|
||||
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||
.board-actions > b { display: none; }
|
||||
.board-actions > span { margin: 0 2px; }
|
||||
}
|
||||
@media (max-width: 900px) and (orientation: portrait) {
|
||||
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 96px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: stretch; overflow-y: auto; }
|
||||
.board-actions button { flex: 0 0 36px; width: 100%; padding: 0 7px; justify-content: flex-start; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; }
|
||||
.board-actions > b { padding: 2px 0; text-align: center; }
|
||||
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 48px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: center; overflow-y: auto; }
|
||||
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
|
||||
/* Narrative layer: splash + NPC dialogue */
|
||||
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
||||
.splash-plate { display: grid; justify-items: center; padding: 20px; }
|
||||
.splash .seal { width: 78px; height: 78px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 15px IBM Plex Mono; margin-bottom: 30px; }
|
||||
.splash-title { margin: 0; color: #e9ede8; font: 40px Special Elite; letter-spacing: .06em; }
|
||||
.splash-sub { margin: 6px 0 0; font: 11px IBM Plex Mono; letter-spacing: .32em; color: #7f9a92; text-transform: uppercase; }
|
||||
.splash-actions { display: flex; gap: 14px; margin: 40px 0 22px; }
|
||||
.splash-button { background: #10362d; border: 1px solid #6f8f85; color: #d8ded9; padding: 13px 26px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
|
||||
.splash-button:hover:not(:disabled) { background: #1a493d; color: #fff; }
|
||||
.splash-button.primary { background: #a2551f; border-color: #d58a46; color: #f6ead9; }
|
||||
.splash-button.primary:hover:not(:disabled) { background: #b8622a; }
|
||||
.splash-button:disabled { opacity: .5; cursor: progress; }
|
||||
.splash-status { font: 9px IBM Plex Mono; letter-spacing: .2em; color: #5f7b73; }
|
||||
|
||||
/* Full-screen visual-novel cutscene: NPC art fills the viewport, a translucent
|
||||
panel floats at the bottom. One fluid layout for mobile and desktop. */
|
||||
.dialogue { position: fixed; inset: 0; z-index: 200; height: 100vh; height: 100dvh; overflow: hidden;
|
||||
display: flex; flex-direction: column; justify-content: flex-end; background: #06140f; cursor: pointer; animation: dialogue-in .25s ease; }
|
||||
@keyframes dialogue-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
/* The portrait is a full-bleed, top-cropped, centered cover image. Reserved (empty
|
||||
dark) when no art has been uploaded yet, so layout does not shift when it arrives. */
|
||||
.dialogue-portrait { position: absolute; inset: 0; }
|
||||
.dialogue-portrait img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
|
||||
/* Legibility gradient behind the panel, present with or without art. */
|
||||
.dialogue-scrim { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(to top, #04110ef2 0%, #04110ed0 20%, #04110e55 42%, #04110e00 68%); }
|
||||
.dialogue-skip { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(16px, env(safe-area-inset-right));
|
||||
background: #04110e99; border: 1px solid #3c5a52; color: #cfe0d9; padding: 8px 14px; font: 9px IBM Plex Mono; letter-spacing: .18em; cursor: pointer; z-index: 2; }
|
||||
.dialogue-skip:hover { border-color: #6f8f85; color: #fff; }
|
||||
.dialogue-box { position: relative; z-index: 1; width: 100%; padding: 0 clamp(14px, 4vw, 40px) max(20px, env(safe-area-inset-bottom)); }
|
||||
.dialogue-panel { width: min(860px, 100%); margin: 0 auto; border: 1px solid #40655b8c; border-bottom: 0;
|
||||
background: #0a211de6; backdrop-filter: blur(3px); box-shadow: 0 -8px 34px #000b; padding: clamp(16px, 3.4vw, 26px) clamp(18px, 4vw, 34px) clamp(20px, 4vw, 30px); }
|
||||
.dialogue-speaker { display: flex; align-items: baseline; gap: 12px; margin-bottom: 11px; flex-wrap: wrap; }
|
||||
.dialogue-speaker strong { color: #e7b57e; font: 600 clamp(13px, 1.1vw + .5rem, 16px) IBM Plex Mono; letter-spacing: .06em; }
|
||||
.dialogue-speaker em { color: #7f9a92; font: clamp(8px, .5vw + .3rem, 9px) IBM Plex Mono; letter-spacing: .16em; font-style: normal; text-transform: uppercase; }
|
||||
.dialogue-text { margin: 0; max-width: 62ch; min-height: 4.8em; color: #eef2ec; font: clamp(16px, 1vw + .7rem, 21px)/1.6 Special Elite; text-shadow: 0 1px 6px #0007; }
|
||||
.dialogue-caret { color: #d58a46; }
|
||||
.dialogue-advance { margin-top: 15px; text-align: right; min-height: 12px; color: #a9c7bd; font: 9px IBM Plex Mono; letter-spacing: .2em; animation: advance-pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes advance-pulse { 0%, 100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
/* Landscape phones: tiny height — keep the panel compact and never taller than the screen allows. */
|
||||
@media (orientation: landscape) and (max-height: 520px) {
|
||||
.dialogue-panel { padding: 12px 20px 14px; }
|
||||
.dialogue-text { min-height: 3.4em; max-height: 40vh; overflow-y: auto; }
|
||||
.dialogue-advance { margin-top: 8px; }
|
||||
}
|
||||
@media (max-width: 900px) { .splash-title { font-size: 30px; } .splash-actions { flex-direction: column; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dialogue { animation: none; } .dialogue-advance { animation: none; } }
|
||||
|
||||
/* Admin authoring panel */
|
||||
.admin { height: 100vh; height: 100dvh; display: grid; grid-template-rows: 56px 1fr 30px; background: #071916; color: #d8ded9; }
|
||||
.admin-head { display: flex; align-items: center; gap: 26px; padding: 0 22px; border-bottom: 1px solid #315049; background: #0a211d; }
|
||||
.admin-tabs { display: flex; gap: 4px; margin-left: 14px; }
|
||||
.admin-tabs button { background: none; border: 0; padding: 8px 16px; font: 500 11px IBM Plex Mono; letter-spacing: .12em; color: #94aaa4; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.admin-tabs button:hover { color: #e4e9e4; }
|
||||
.admin-tabs button.active { color: #fff; border-bottom-color: #d58a46; }
|
||||
.admin-link { margin-left: auto; color: #8fb0a6; text-decoration: none; font: 10px IBM Plex Mono; letter-spacing: .12em; }
|
||||
.admin-link:hover { color: #e7b57e; }
|
||||
.admin-body { display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
|
||||
.npc-list { border-right: 1px solid #243d36; background: #0b201b; overflow: auto; display: flex; flex-direction: column; }
|
||||
.npc-list-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 16px 10px; font: 600 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
|
||||
.npc-list-head button { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 5px 10px; cursor: pointer; }
|
||||
.npc-list-head button:hover { background: #1c463a; }
|
||||
.npc-row { display: flex; align-items: center; gap: 12px; padding: 11px 16px; border: 0; border-bottom: 1px solid #17302a; background: none; text-align: left; cursor: pointer; color: inherit; }
|
||||
.npc-row:hover { background: #12312a; }
|
||||
.npc-row.selected { background: #1b3d33; box-shadow: inset 3px 0 #d58a46; }
|
||||
.npc-avatar { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; overflow: hidden; border: 1px solid #3c5a52; background: #0e2a24; color: #6f9084; font: 600 15px Special Elite; }
|
||||
.npc-avatar img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
|
||||
.npc-row-text strong { display: block; font: 13px IBM Plex Mono; color: #dfe5e0; }
|
||||
.npc-row-text small { color: #7f9a92; font: 9px IBM Plex Mono; letter-spacing: .04em; }
|
||||
.npc-editor { padding: 26px 32px; overflow: auto; max-width: 720px; }
|
||||
.npc-editor.empty { display: grid; place-items: center; color: #607d75; font: 11px IBM Plex Mono; }
|
||||
.npc-editor-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 22px; }
|
||||
.npc-editor-head h2 { margin: 0; font: 500 24px Special Elite; color: #e9ede8; }
|
||||
.npc-editor-head code { color: #8fb0a6; font: 10px IBM Plex Mono; background: #0e2a24; padding: 3px 8px; border: 1px solid #2c473f; }
|
||||
.npc-editor-head .danger { margin-left: auto; background: none; border: 1px solid #7a3b34; color: #d78a7f; font: 9px IBM Plex Mono; padding: 6px 12px; cursor: pointer; }
|
||||
.npc-editor-head .danger:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.npc-editor-head .danger:not(:disabled):hover { background: #4a221d; }
|
||||
.admin-field { display: grid; gap: 5px; margin-bottom: 15px; }
|
||||
.admin-field label { font: 600 9px IBM Plex Mono; letter-spacing: .14em; color: #86a199; }
|
||||
.admin-field input, .admin-field select, .pose-key { background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 9px 11px; font: 12px IBM Plex Mono; }
|
||||
.admin-field input:focus, .admin-field select:focus, .pose-key:focus { outline: 0; border-color: #9a683d; box-shadow: inset 0 0 0 1px #6f4b2f; }
|
||||
.admin-save { margin-top: 4px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 10px 20px; font: 10px IBM Plex Mono; letter-spacing: .12em; cursor: pointer; }
|
||||
.admin-save:disabled { opacity: .45; cursor: default; background: #143229; border-color: #35544c; color: #8fb0a6; }
|
||||
.npc-editor h3 { margin: 30px 0 12px; font: 600 10px IBM Plex Mono; letter-spacing: .18em; color: #86a199; }
|
||||
.pose-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px; }
|
||||
.pose-card { position: relative; margin: 0; border: 1px solid #35544c; background: #0e2a24; }
|
||||
.pose-card.is-default { border-color: #d58a46; box-shadow: 0 0 0 1px #d58a4655; }
|
||||
.pose-card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; object-position: top center; display: block; }
|
||||
.pose-card figcaption { padding: 7px 9px; font: 10px IBM Plex Mono; color: #cfe0d9; letter-spacing: .04em; }
|
||||
.pose-card.is-default figcaption::after { content: ' · default'; color: #d58a46; }
|
||||
.pose-remove { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border: 0; border-radius: 2px; background: #04110ecc; color: #e5b3ab; font-size: 15px; line-height: 1; cursor: pointer; }
|
||||
.pose-remove:hover { background: #4a221d; color: #fff; }
|
||||
.pose-upload { display: flex; gap: 12px; align-items: center; margin-top: 16px; flex-wrap: wrap; }
|
||||
.pose-upload .pose-key { flex: 0 0 220px; }
|
||||
.pose-upload input[type=file] { font: 10px IBM Plex Mono; color: #9bb0a9; }
|
||||
.admin-hint, .admin-note { color: #6f8b83; font: 9px/1.5 IBM Plex Mono; }
|
||||
.admin-hint { display: block; margin-top: 16px; }
|
||||
.admin-note { margin-top: 20px; }
|
||||
.admin-empty { color: #607d75; font: 10px IBM Plex Mono; padding: 16px; }
|
||||
.mystery-list { padding: 26px 32px; overflow: auto; }
|
||||
.mystery-row { padding: 12px 0; border-bottom: 1px solid #1c352e; display: grid; gap: 3px; }
|
||||
.mystery-row strong { color: #dfe5e0; font: 14px IBM Plex Mono; }
|
||||
.mystery-row span { color: #7f9a92; font: 9px IBM Plex Mono; letter-spacing: .06em; }
|
||||
.admin-foot { display: flex; align-items: center; padding: 0 22px; border-top: 1px solid #243d36; background: #0a211d; font: 9px IBM Plex Mono; letter-spacing: .08em; color: #718d84; }
|
||||
.boot .admin-link { color: #8fb0a6; }
|
||||
@media (max-width: 760px) { .admin-body { grid-template-columns: 1fr; } .npc-list { max-height: 34vh; } }
|
||||
|
||||
/* Mystery story-flow graph editor */
|
||||
.mystery-row { width: 100%; border: 0; border-bottom: 1px solid #1c352e; background: none; text-align: left; cursor: pointer; }
|
||||
.mystery-row:hover { background: #12312a; }
|
||||
.graph-loading, .graph-empty { display: grid; place-items: center; height: 100%; color: #607d75; font: 11px IBM Plex Mono; }
|
||||
.graph-editor { display: flex; flex-direction: column; min-height: 0; background: #08191500; }
|
||||
.graph-toolbar { display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #243d36; background: #0a211d; flex-wrap: wrap; }
|
||||
.graph-toolbar strong { color: #e9ede8; font: 13px IBM Plex Mono; margin-right: 8px; }
|
||||
.graph-back { background: none; border: 1px solid #3c5a52; color: #9bb0a9; font: 9px IBM Plex Mono; padding: 6px 11px; cursor: pointer; }
|
||||
.graph-add-label { color: #78958d; font: 9px IBM Plex Mono; letter-spacing: .12em; margin-left: 8px; }
|
||||
.graph-add { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 6px 10px; cursor: pointer; }
|
||||
.graph-add:hover { background: #1c463a; }
|
||||
.graph-wiring { color: #e7b57e; font: 9px IBM Plex Mono; letter-spacing: .06em; }
|
||||
.graph-zoom { margin-left: auto; color: #6f8b83; font: 9px IBM Plex Mono; }
|
||||
.graph-main { flex: 1; display: flex; min-height: 0; }
|
||||
.graph-canvas { position: relative; flex: 1; overflow: hidden; background:
|
||||
radial-gradient(#49615a33 1px, transparent 1px), #0b1d19; background-size: 22px 22px; touch-action: none; cursor: grab; }
|
||||
.graph-canvas.wiring { cursor: crosshair; }
|
||||
.graph-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
|
||||
.graph-wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
|
||||
.graph-wire { fill: none; stroke: #c98a4c; stroke-width: 2; opacity: .8; }
|
||||
.gnode { position: absolute; background: #0e2a24; border: 1px solid #40655b; box-shadow: 4px 5px 0 #04110e66; user-select: none; }
|
||||
.gnode.selected { border-color: #e7b57e; box-shadow: 0 0 0 1px #e7b57e, 4px 5px 0 #04110e66; }
|
||||
.gnode.entry { box-shadow: -4px 0 0 #6fbf8b, 4px 5px 0 #04110e66; }
|
||||
.gnode.entry.selected { box-shadow: -4px 0 0 #6fbf8b, 0 0 0 1px #e7b57e; }
|
||||
.gnode-head { height: 30px; display: flex; align-items: center; gap: 7px; padding: 0 10px; cursor: grab; border-bottom: 1px solid #24413a; }
|
||||
.gnode-head:active { cursor: grabbing; }
|
||||
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
|
||||
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
|
||||
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
|
||||
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.gnode-entry { color: #6fbf8b; font-size: 10px; }
|
||||
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.gnode-terminals { padding-bottom: 4px; }
|
||||
.gterm { position: relative; display: flex; align-items: center; padding: 0 12px; }
|
||||
.gterm-label { font: 9px IBM Plex Mono; color: #b6c6c0; margin-left: auto; }
|
||||
.gport { position: absolute; right: -7px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; border-radius: 50%; border: 2px solid #6f8f85; background: #0b1d19; cursor: pointer; padding: 0; }
|
||||
.gport:hover { border-color: #e7b57e; }
|
||||
.gport.wired { background: #c98a4c; border-color: #c98a4c; }
|
||||
.gport.active { border-color: #e7b57e; box-shadow: 0 0 0 3px #e7b57e55; }
|
||||
.ginput { position: absolute; left: -6px; top: 20px; width: 11px; height: 11px; border-radius: 50%; background: #2c473f; border: 2px solid #6f8f85; }
|
||||
.graph-inspector { width: 280px; flex: 0 0 auto; border-left: 1px solid #243d36; background: #0b201b; overflow: auto; }
|
||||
.inspector-body { padding: 16px; display: grid; gap: 12px; }
|
||||
.inspector-head { display: flex; align-items: center; gap: 10px; }
|
||||
.ins-entry { margin-left: auto; background: #143229; border: 1px solid #3c5a52; color: #6fbf8b; font: 8px IBM Plex Mono; padding: 5px 9px; cursor: pointer; }
|
||||
.ins-field { display: grid; gap: 4px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #86a199; }
|
||||
.ins-field input, .ins-field select { background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 7px 9px; font: 11px IBM Plex Mono; }
|
||||
.ins-check { display: flex; align-items: center; gap: 8px; font: 10px IBM Plex Mono; color: #b6c6c0; }
|
||||
.ins-terminals-head { display: flex; justify-content: space-between; align-items: center; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #86a199; margin-top: 6px; }
|
||||
.ins-terminals-head button { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 8px IBM Plex Mono; padding: 4px 8px; cursor: pointer; }
|
||||
.ins-terminal { display: flex; align-items: center; gap: 6px; }
|
||||
.ins-terminal input { flex: 0 0 84px; background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 5px 7px; font: 10px IBM Plex Mono; }
|
||||
.ins-terminal-to { flex: 1; font: 9px IBM Plex Mono; color: #7f9a92; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ins-unwire, .ins-term-del { background: none; border: 1px solid #3c5a52; color: #9bb0a9; width: 22px; height: 24px; cursor: pointer; font-size: 12px; }
|
||||
.ins-unwire:hover, .ins-term-del:hover { border-color: #d78a7f; color: #e5b3ab; }
|
||||
.ins-delete { margin-top: 8px; background: none; border: 1px solid #7a3b34; color: #d78a7f; font: 9px IBM Plex Mono; padding: 8px; cursor: pointer; }
|
||||
.ins-delete:hover { background: #4a221d; }
|
||||
|
||||
/* Utterance sub-canvas (dialogue crafter) */
|
||||
.graph-editor { position: relative; }
|
||||
.utterance-overlay { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column; background: #071916; }
|
||||
.ins-utterances { background: #143229; border: 1px solid #3c5a52; color: #7fc7b6; font: 9px IBM Plex Mono; padding: 8px; cursor: pointer; }
|
||||
.ins-utterances:hover { background: #1c463a; }
|
||||
.ucard { position: absolute; background: #10241f; border: 1px solid #40655b; box-shadow: 3px 4px 0 #04110e66; user-select: none; display: flex; flex-direction: column; }
|
||||
.ucard.u-npc { border-left: 3px solid #7fc7b6; }
|
||||
.ucard.u-player { border-left: 3px solid #e7b57e; background: #18231d; }
|
||||
.ucard.selected { border-color: #e7b57e; box-shadow: 0 0 0 1px #e7b57e, 3px 4px 0 #04110e66; }
|
||||
.ucard-head { height: 24px; display: flex; align-items: center; gap: 6px; padding: 0 9px; cursor: grab; border-bottom: 1px solid #24413a; }
|
||||
.ucard-head:active { cursor: grabbing; }
|
||||
.ucard-badge { font: 600 8px IBM Plex Mono; letter-spacing: .08em; color: #cfe0d9; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ucard-pose { margin-left: auto; font: 8px IBM Plex Mono; color: #86a199; }
|
||||
.ucard-text { flex: 1; padding: 6px 9px; font: 12px/1.3 Special Elite; color: #dfe5e0; overflow: hidden; }
|
||||
.ucard-text em { color: #5f7b73; }
|
||||
.uinput { position: absolute; left: -6px; top: 50%; transform: translateY(-50%); width: 11px; height: 11px; border-radius: 50%; background: #2c473f; border: 2px solid #6f8f85; }
|
||||
.uport { position: absolute; width: 13px; height: 13px; border-radius: 50%; border: 2px solid #6f8f85; background: #0b1d19; cursor: pointer; padding: 0; }
|
||||
.uport.flow { right: -7px; top: 50%; transform: translateY(-50%); }
|
||||
.uport.options { bottom: -7px; left: 50%; transform: translateX(-50%); border-color: #c98a4c; }
|
||||
.uport:hover { border-color: #e7b57e; }
|
||||
.uport.wired { background: #c98a4c; border-color: #c98a4c; }
|
||||
.uport.active { box-shadow: 0 0 0 3px #e7b57e55; border-color: #e7b57e; }
|
||||
.usink { position: absolute; height: 40px; display: flex; align-items: center; padding: 0 12px; background: #241a12; border: 1px dashed #c98a4c; color: #e7b57e; cursor: pointer; }
|
||||
.usink:hover { background: #30241a; }
|
||||
.usink-label { font: 9px IBM Plex Mono; letter-spacing: .06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.graph-wire.exit { stroke: #e7b57e; }
|
||||
.graph-wire.option { stroke: #7fc7b6; stroke-dasharray: 5 4; opacity: .7; }
|
||||
.ins-text { min-height: 70px; resize: vertical; background: #0c231e; border: 1px solid #35544c; color: #e4e9e4; padding: 8px; font: 13px/1.4 Special Elite; }
|
||||
.ins-links { font: 9px IBM Plex Mono; color: #9bb0a9; display: grid; gap: 6px; }
|
||||
.ins-links > div { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Story-graph runtime: cutscene title card + report-back */
|
||||
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
||||
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
||||
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
||||
.title-card-inner h1 { margin: 0; font: 44px Special Elite; letter-spacing: .04em; color: #eef2ec; max-width: 16ch; }
|
||||
.cutscene-missing { color: #b56d30 !important; letter-spacing: .08em !important; }
|
||||
.cutscene-begin { margin-top: 10px; background: #a2551f; border: 1px solid #d58a46; color: #f6ead9; padding: 11px 24px; font: 11px IBM Plex Mono; letter-spacing: .16em; cursor: pointer; }
|
||||
.cutscene-begin:hover { background: #b8622a; }
|
||||
.menubar nav button.report-back { color: #f6ead9; background: #a2551f; }
|
||||
.menubar nav button.report-back:hover { background: #b8622a; }
|
||||
@media (max-width: 900px) { .title-card-inner h1 { font-size: 30px; } }
|
||||
|
||||
/* Utterance cards: expand to full content, ports anchored to top, colour by speaker */
|
||||
.ucard { height: auto; min-height: 58px; }
|
||||
.ucard.u-npc { background: #0f2a24; border-left: 3px solid #6fbfa9; }
|
||||
.ucard.u-player { background: #2b2113; border-left: 3px solid #e0a253; }
|
||||
.u-npc .ucard-badge { color: #9fe0cd; }
|
||||
.u-player .ucard-badge { color: #e8bd80; }
|
||||
.ucard-text { flex: none; overflow: visible; white-space: pre-wrap; min-height: 20px; }
|
||||
.uinput { top: 18px; transform: none; }
|
||||
.uport.flow { top: 18px; transform: none; }
|
||||
.uport.options { top: 44px; bottom: auto; left: auto; right: -7px; transform: none; }
|
||||
|
||||
/* Clickable wires (delete a connection by clicking it) */
|
||||
.wire-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: pointer; }
|
||||
.wire-hit:hover + .graph-wire { stroke-width: 4; filter: drop-shadow(0 0 3px #e7b57e); }
|
||||
|
||||
/* Branching dialogue: player choice buttons */
|
||||
.dialogue-choices { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.dialogue-choices button { text-align: left; background: #0e2a24e6; border: 1px solid #6f8f85; color: #eef2ec; padding: 12px 16px; font: 15px/1.4 Special Elite; cursor: pointer; min-height: 46px; }
|
||||
.dialogue-choices button:hover { background: #1a493d; border-color: #e7b57e; }
|
||||
|
||||
/* Vertical mystery-graph nodes: input top, output terminals along the bottom */
|
||||
.gnode { display: flex; flex-direction: column; }
|
||||
.ginput { top: -6px; left: 50%; right: auto; bottom: auto; transform: translateX(-50%); }
|
||||
.gnode-outs { display: flex; justify-content: space-around; align-items: flex-end; gap: 4px; margin-top: auto; padding: 2px 6px 0; }
|
||||
.gout { position: relative; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding-bottom: 9px; }
|
||||
.gout-label { font: 9px IBM Plex Mono; color: #b6c6c0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.gout .gport { position: absolute; bottom: -7px; left: 50%; top: auto; right: auto; transform: translateX(-50%); }
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'
|
||||
|
||||
type Utterer = 'npc' | 'player'
|
||||
type Utterance = {
|
||||
id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
|
||||
parentUtteranceId: string | null; advancesToUtteranceId: string | null; terminalId: string | null
|
||||
effect: string | null; xpos: number; ypos: number; sortOrder: number
|
||||
}
|
||||
type Terminal = { id: string; terminalKey: string; label: string }
|
||||
type Npc = { id: string; name: string; poses: { poseKey: string; url: string }[] }
|
||||
|
||||
const UW = 220, PORT_Y = 24, SINK_W = 150
|
||||
const sinkY = (index: number) => 30 + index * 74
|
||||
|
||||
async function api<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `Request failed (${response.status})`)
|
||||
return response.json().catch(() => ({} as T))
|
||||
}
|
||||
|
||||
export function UtteranceCanvas({ nodeId, nodeLabel, terminals, onClose, setStatus }: {
|
||||
nodeId: string; nodeLabel: string; terminals: Terminal[]; onClose: () => void; setStatus: (message: string) => void
|
||||
}) {
|
||||
const [utterances, setUtterances] = useState<Utterance[]>([])
|
||||
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||
const [view, setView] = useState({ x: 40, y: 40, zoom: 1 })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [wiringFrom, setWiringFrom] = useState<{ id: string } | null>(null)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<{ id: string; startX: number; startY: number; origX: number; origY: number } | 'pan' | null>(null)
|
||||
const panRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setUtterances(await api<Utterance[]>(`/api/admin/story-nodes/${nodeId}/utterances`, 'GET')) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}, [nodeId, setStatus])
|
||||
useEffect(() => { void reload(); api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {}) }, [reload])
|
||||
|
||||
// Undo stack of inverse operations (connection edits, Tab creation).
|
||||
const undoRef = useRef<Array<() => Promise<void>>>([])
|
||||
const pushUndo = (fn: () => Promise<void>) => { undoRef.current.push(fn); if (undoRef.current.length > 40) undoRef.current.shift() }
|
||||
const doUndo = async () => {
|
||||
const fn = undoRef.current.pop()
|
||||
if (!fn) { setStatus('Nothing to undo'); return }
|
||||
try { await fn(); await reload() } catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
// Keyboard (ignored while typing in a field): Ctrl/Cmd+Z undoes; Tab adds a child under
|
||||
// the selected utterance (one child = linear, a second makes them player options);
|
||||
// 1/2 set the selected utterance's speaker.
|
||||
const keyActionRef = useRef((_event: KeyboardEvent) => {})
|
||||
keyActionRef.current = (event: KeyboardEvent) => {
|
||||
const tag = (document.activeElement?.tagName || '').toLowerCase()
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); void doUndo(); return }
|
||||
if (!selectedId) return
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const parent = utterances.find(u => u.id === selectedId)
|
||||
if (!parent) return
|
||||
const siblings = utterances.filter(u => u.parentUtteranceId === parent.id)
|
||||
void (async () => {
|
||||
try {
|
||||
const created = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST',
|
||||
{ utterer: 'player', xpos: Math.round(parent.xpos + 270), ypos: Math.round(parent.ypos + siblings.length * 92), text: '' })
|
||||
await api(`/api/admin/utterances/${created.id}`, 'PATCH', { parentUtteranceId: parent.id })
|
||||
// 2+ children ⇒ player options; a lone child stays a linear NPC next line.
|
||||
if (siblings.length + 1 >= 2) for (const child of [...siblings, created]) await api(`/api/admin/utterances/${child.id}`, 'PATCH', { utterer: 'player', npcId: null })
|
||||
else await api(`/api/admin/utterances/${created.id}`, 'PATCH', { utterer: 'npc' })
|
||||
pushUndo(() => api(`/api/admin/utterances/${created.id}`, 'DELETE'))
|
||||
await reload(); setSelectedId(parent.id) // keep the parent selected to add more options
|
||||
} catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
})()
|
||||
} else if (event.key === '1') void patch(selectedId, { utterer: 'npc' })
|
||||
else if (event.key === '2') void patch(selectedId, { utterer: 'player', npcId: null })
|
||||
}
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => keyActionRef.current(event)
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const patch = async (id: string, body: Record<string, unknown>, silent = false) => {
|
||||
try { await api(`/api/admin/utterances/${id}`, 'PATCH', body); if (!silent) await reload() }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
const add = async (utterer: Utterer) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
const cx = ((rect ? rect.width / 2 : 250) - view.x) / view.zoom, cy = ((rect ? rect.height / 2 : 200) - view.y) / view.zoom
|
||||
try { const u = await api<Utterance>(`/api/admin/story-nodes/${nodeId}/utterances`, 'POST', { utterer, xpos: Math.round(cx), ypos: Math.round(cy) }); await reload(); setSelectedId(u.id) }
|
||||
catch (error) { setStatus(String((error as Error).message || error)) }
|
||||
}
|
||||
|
||||
const onBgPointerDown = (event: React.PointerEvent) => {
|
||||
if (wiringFrom) { setWiringFrom(null); return }
|
||||
panRef.current = { startX: event.clientX, startY: event.clientY, origX: view.x, origY: view.y }; drag.current = 'pan'; setSelectedId(null)
|
||||
}
|
||||
const onCardPointerDown = (event: React.PointerEvent, u: Utterance) => {
|
||||
;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
|
||||
drag.current = { id: u.id, startX: event.clientX, startY: event.clientY, origX: u.xpos, origY: u.ypos }; setSelectedId(u.id)
|
||||
}
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
if (drag.current === 'pan' && panRef.current) { const p = panRef.current; setView(v => ({ ...v, x: p.origX + event.clientX - p.startX, y: p.origY + event.clientY - p.startY })); return }
|
||||
if (drag.current && drag.current !== 'pan') { const d = drag.current; setUtterances(list => list.map(u => u.id === d.id ? { ...u, xpos: d.origX + (event.clientX - d.startX) / view.zoom, ypos: d.origY + (event.clientY - d.startY) / view.zoom } : u)) }
|
||||
}
|
||||
const onPointerUp = async () => {
|
||||
const state = drag.current; drag.current = null; panRef.current = null
|
||||
if (state && state !== 'pan') { const u = utterances.find(x => x.id === state.id); if (u) await patch(u.id, { xpos: Math.round(u.xpos), ypos: Math.round(u.ypos) }, true) }
|
||||
}
|
||||
const onWheel = (event: React.WheelEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return
|
||||
const px = event.clientX - rect.left, py = event.clientY - rect.top, factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||
setView(v => { const zoom = Math.min(2, Math.max(0.35, v.zoom * factor)); return { zoom, x: px - (px - v.x) * (zoom / v.zoom), y: py - (py - v.y) * (zoom / v.zoom) } })
|
||||
}
|
||||
|
||||
// A card's children are what come after it: dragging its port to another card makes
|
||||
// that card a child. One child ⇒ solid (linear next line); two or more ⇒ dotted
|
||||
// (player options). A card can instead exit the node by wiring to a terminal sink.
|
||||
const link = (id: string, body: Record<string, unknown>, undoBody: Record<string, unknown>) => {
|
||||
pushUndo(() => api(`/api/admin/utterances/${id}`, 'PATCH', undoBody)); void patch(id, body)
|
||||
}
|
||||
const targetCard = (u: Utterance) => {
|
||||
if (!wiringFrom) { setSelectedId(u.id); return }
|
||||
const source = wiringFrom.id; setWiringFrom(null)
|
||||
if (source === u.id) return
|
||||
link(u.id, { parentUtteranceId: source }, { parentUtteranceId: u.parentUtteranceId })
|
||||
}
|
||||
const targetSink = (terminalId: string) => {
|
||||
const source = wiringFrom ? utterances.find(x => x.id === wiringFrom.id) : null; setWiringFrom(null)
|
||||
if (source) link(source.id, { terminalId }, { terminalId: source.terminalId })
|
||||
}
|
||||
|
||||
const byId = new Map(utterances.map(u => [u.id, u]))
|
||||
const childCount = new Map<string, number>()
|
||||
for (const u of utterances) if (u.parentUtteranceId) childCount.set(u.parentUtteranceId, (childCount.get(u.parentUtteranceId) || 0) + 1)
|
||||
// Dock the exit sinks to the right of the utterances so flow reads left-to-right.
|
||||
const sinkX = Math.max(560, ...utterances.map(u => u.xpos + UW + 90))
|
||||
const npcName = (id: string | null) => npcs.find(n => n.id === id)?.name || 'NPC'
|
||||
const selected = utterances.find(u => u.id === selectedId) || null
|
||||
const flowPort = (u: Utterance) => ({ x: u.xpos + UW, y: u.ypos + PORT_Y })
|
||||
const cardInput = (u: Utterance) => ({ x: u.xpos, y: u.ypos + PORT_Y })
|
||||
const sinkInput = (i: number) => ({ x: sinkX, y: sinkY(i) + 20 })
|
||||
const curve = (a: { x: number; y: number }, b: { x: number; y: number }) => { const dx = Math.max(30, Math.abs(b.x - a.x) / 2); return `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}` }
|
||||
|
||||
return <div className="utterance-overlay">
|
||||
<div className="graph-toolbar">
|
||||
<button className="graph-back" onClick={onClose}>← Graph</button>
|
||||
<strong>{nodeLabel || 'Dialogue'} · utterances</strong>
|
||||
<span className="graph-add-label">Add:</span>
|
||||
<button className="graph-add" onClick={() => add('npc')}>NPC line</button>
|
||||
<button className="graph-add" onClick={() => add('player')}>Player choice</button>
|
||||
{wiringFrom && <span className="graph-wiring">Click the next card (2+ ⇒ options) or an exit ⇥ · click empty to cancel</span>}
|
||||
<span className="graph-zoom">{Math.round(view.zoom * 100)}%</span>
|
||||
</div>
|
||||
<div className="graph-main">
|
||||
<div ref={canvasRef} className={`graph-canvas${wiringFrom ? ' wiring' : ''}`} onPointerDown={onBgPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel}>
|
||||
<div className="graph-world" style={{ transform: `translate(${view.x}px,${view.y}px) scale(${view.zoom})` }}>
|
||||
<svg className="graph-wires" width="6000" height="6000">
|
||||
{utterances.flatMap(u => {
|
||||
const wires: ReactElement[] = []
|
||||
const wire = (key: string, d: string, cls: string, onDelete: () => void) => {
|
||||
wires.push(<path key={key + 'hit'} className="wire-hit" d={d} onClick={event => { event.stopPropagation(); onDelete() }} />)
|
||||
wires.push(<path key={key} className={`graph-wire ${cls}`} d={d} />)
|
||||
}
|
||||
if (u.parentUtteranceId && byId.has(u.parentUtteranceId)) {
|
||||
const parent = byId.get(u.parentUtteranceId)!
|
||||
const cls = (childCount.get(parent.id) || 0) >= 2 ? 'option' : ''
|
||||
wire(u.id + 'p', curve(flowPort(parent), cardInput(u)), cls, () => link(u.id, { parentUtteranceId: null }, { parentUtteranceId: parent.id }))
|
||||
}
|
||||
if (u.terminalId) { const idx = terminals.findIndex(t => t.id === u.terminalId); if (idx >= 0) wire(u.id + 't', curve(flowPort(u), sinkInput(idx)), 'exit', () => link(u.id, { terminalId: null }, { terminalId: u.terminalId })) }
|
||||
return wires
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{terminals.map((t, i) => <div key={t.id} className="usink" style={{ left: sinkX, top: sinkY(i), width: SINK_W }} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetSink(t.id) }}>
|
||||
<div className="uinput" />
|
||||
<span className="usink-label">⇥ {t.label || t.terminalKey}</span>
|
||||
</div>)}
|
||||
|
||||
{utterances.map(u => <div key={u.id} className={`ucard u-${u.utterer}${u.id === selectedId ? ' selected' : ''}`} style={{ left: u.xpos, top: u.ypos, width: UW }}
|
||||
onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); targetCard(u) }}>
|
||||
<div className="uinput" />
|
||||
<div className="ucard-head" onPointerDown={e => onCardPointerDown(e, u)}>
|
||||
<span className="ucard-badge">{u.utterer === 'npc' ? npcName(u.npcId) : 'PLAYER'}</span>
|
||||
{u.utterer === 'npc' && u.poseKey && <span className="ucard-pose">{u.poseKey}</span>}
|
||||
</div>
|
||||
<div className="ucard-text">{u.text || <em>(empty)</em>}</div>
|
||||
<button className={`uport flow${(childCount.get(u.id) || 0) > 0 || u.terminalId ? ' wired' : ''}${wiringFrom?.id === u.id ? ' active' : ''}`}
|
||||
title="Connect to the next line(s) or an exit ⇥ · 1 = linear, 2+ = options"
|
||||
onClick={e => { e.stopPropagation(); setWiringFrom(w => w?.id === u.id ? null : { id: u.id }) }} />
|
||||
</div>)}
|
||||
</div>
|
||||
{utterances.length === 0 && <div className="graph-empty">No utterances yet — add an NPC line or player choice.</div>}
|
||||
</div>
|
||||
|
||||
{selected && <aside className="graph-inspector">
|
||||
<div className="inspector-body">
|
||||
<div className="inspector-head"><span className={`gnode-type type-${selected.utterer === 'npc' ? 'dialogue' : 'level'}`}>{selected.utterer}</span></div>
|
||||
{selected.utterer === 'npc' && <>
|
||||
<label className="ins-field"><span>Speaker</span>
|
||||
<select value={selected.npcId || ''} onChange={e => patch(selected.id, { npcId: e.target.value || null, poseKey: null })}>
|
||||
<option value="">— choose NPC —</option>
|
||||
{npcs.map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select></label>
|
||||
<label className="ins-field"><span>Pose</span>
|
||||
<select value={selected.poseKey || ''} onChange={e => patch(selected.id, { poseKey: e.target.value || null })}>
|
||||
<option value="">— default / none —</option>
|
||||
{(npcs.find(n => n.id === selected.npcId)?.poses || []).map(p => <option key={p.poseKey} value={p.poseKey}>{p.poseKey}</option>)}
|
||||
</select></label>
|
||||
</>}
|
||||
<label className="ins-field"><span>{selected.utterer === 'npc' ? 'Line' : 'Choice text'}</span>
|
||||
<textarea className="ins-text" defaultValue={selected.text} onBlur={e => e.target.value !== selected.text && patch(selected.id, { text: e.target.value })} /></label>
|
||||
<div className="ins-links">
|
||||
<div>Next: {(childCount.get(selected.id) || 0) > 0 ? `${childCount.get(selected.id)} ${(childCount.get(selected.id) || 0) >= 2 ? 'options' : 'line'}` : selected.terminalId ? `⇥ ${terminals.find(t => t.id === selected.terminalId)?.label || 'exit'}` : '— none —'}
|
||||
{selected.terminalId && <button className="ins-unwire" onClick={() => link(selected.id, { terminalId: null }, { terminalId: selected.terminalId })}>⊘</button>}</div>
|
||||
{selected.parentUtteranceId && <div>Follows another utterance <button className="ins-unwire" onClick={() => link(selected.id, { parentUtteranceId: null }, { parentUtteranceId: selected.parentUtteranceId })}>⊘</button></div>}
|
||||
</div>
|
||||
<button className="ins-delete" onClick={async () => { if (!window.confirm('Delete utterance?')) return; try { await api(`/api/admin/utterances/${selected.id}`, 'DELETE'); setSelectedId(null); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}>Delete utterance</button>
|
||||
</div>
|
||||
</aside>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user