2026-08-18 15:37:46 +02:00
import { useCallback , useEffect , useRef , useState } from 'react'
import { UtteranceCanvas } from './utteranceCanvas'
2026-08-18 19:24:03 +02:00
import { CUTSCENE_COMPONENT_KEYS , DialoguePreview } from './narrative'
2026-08-18 15:37:46 +02:00
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id : string ; terminalKey : string ; label : string ; toNodeId : string | null ; sortOrder : number }
2026-08-18 19:51:44 +02:00
type StoryNode = { id : string ; nodeType : StoryNodeType ; label : string ; hasUtterances : boolean ; xpos : number ; ypos : number ; levelTemplateVersionId : string | null ; componentKey : string | null ; musicAssetId : string | null ; terminals : Terminal [] }
type AudioAsset = { id : string ; originalName : string ; mimeType : string }
2026-08-18 15:37:46 +02:00
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 [] >([])
2026-08-18 19:51:44 +02:00
const [ audioAssets , setAudioAssets ] = useState < AudioAsset [] >([])
2026-08-18 15:37:46 +02:00
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 ])
2026-08-18 19:51:44 +02:00
useEffect (() => {
void reload ()
api < LevelTemplate [] >( '/api/admin/level-templates' , 'GET' ). then ( setTemplates ). catch (() => {})
api < AudioAsset [] >( '/api/admin/assets' , 'GET' ). then ( list => setAudioAssets ( list . filter ( a => a . mimeType . startsWith ( 'audio/' )))). catch (() => {})
}, [ reload ])
2026-08-18 15:37:46 +02:00
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 >)}
2026-08-18 19:24:03 +02:00
{ selected && ( selected . nodeType === 'dialogue' || selected . hasUtterances ) &&
< div className = "graph-node-preview" style = {{ left : selected.xpos + NODE_W + 28 , top : selected.ypos }} onClick = { event => event . stopPropagation ()}>
< DialoguePreview nodeId = { selected . id } />
</ div >}
2026-08-18 15:37:46 +02:00
</ div >
{ graph . nodes . length === 0 && < div className = "graph-empty" > Empty graph — add a node to begin .</ div >}
</ div >
{ selected && < aside className = "graph-inspector" >
2026-08-18 19:51:44 +02:00
< NodeInspector key = { selected . id } node = { selected } graph = { graph } templates = { templates } audioAssets = { audioAssets }
2026-08-18 15:37:46 +02:00
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 ''
}
2026-08-18 19:51:44 +02:00
function NodeInspector ({ node , graph , templates , audioAssets , onPatch , onSetEntry , onDelete , onAddTerminal , onTerminalPatch , onTerminalDelete , onEditUtterances } : {
node : StoryNode ; graph : Graph ; templates : LevelTemplate []; audioAssets : AudioAsset []
2026-08-18 15:37:46 +02:00
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 >}
2026-08-18 19:05:29 +02:00
{ usesComponent && < label className = "ins-field" >< span > Component key { node . nodeType === 'cutscene' && componentKey && ! CUTSCENE_COMPONENT_KEYS . includes ( componentKey ) && < b className = "ins-warn" > · not registered </ b >}</ span >
< input list = { node . nodeType === 'cutscene' ? 'cutscene-components' : undefined } 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 })} />
{ node . nodeType === 'cutscene' && < datalist id = "cutscene-components" >{ CUTSCENE_COMPONENT_KEYS . map ( k => < option key = { k } value = { k } />)}</ datalist >}
</ label >}
2026-08-18 15:37:46 +02:00
{( 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 >}
2026-08-18 19:51:44 +02:00
< label className = "ins-field" >< span > Scene music </ span >
< select value = { node . musicAssetId || '' } onChange = { e => onPatch ({ musicAssetId : e.target.value || null })}>
< option value = "" > — none / inherit — </ option >
{ audioAssets . map ( asset => < option key = { asset . id } value = { asset . id }>{ asset . originalName }</ option >)}
</ select >
</ label >
2026-08-18 15:37:46 +02:00
< 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 >
}