2026-08-18 15:37:46 +02:00
import { useCallback , useEffect , useRef , useState } from 'react'
import { MysteryGraphEditor } from './mysteryGraph'
type Pose = { poseKey : string ; assetId : string ; url : string }
2026-08-22 17:16:48 +02:00
type Npc = { id : string ; key : string ; name : string ; role : string ; defaultPose : string | null ; phoneNumber : string | null ; email : string | null ; poses : Pose []; inUse : boolean }
2026-08-18 15:37:46 +02:00
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 )
2026-08-18 19:05:29 +02:00
const [ tab , setTab ] = useState < 'npcs' | 'mysteries' | 'assets' > ( 'npcs' )
2026-08-18 15:37:46 +02:00
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 ))
}, [])
2026-08-18 19:05:29 +02:00
const reloadMysteries = useCallback (() => json < Mystery [] >( '/api/admin/mysteries' ). then ( setMysteries ). catch (() => {}), [])
2026-08-18 15:37:46 +02:00
useEffect (() => {
if ( ! isAdmin ) return
reloadNpcs (). catch ( error => setStatus ( String ( error . message || error )))
2026-08-18 19:05:29 +02:00
void reloadMysteries ()
}, [ isAdmin , reloadNpcs , reloadMysteries ])
2026-08-18 15:37:46 +02:00
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 >
2026-08-18 19:05:29 +02:00
< button className = { tab === 'assets' ? 'active' : '' } onClick = {() => setTab ( 'assets' )}> ASSETS </ button >
2026-08-18 15:37:46 +02:00
</ 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
2026-08-18 19:05:29 +02:00
? < MysteryGraphEditor mysteryId = { editingMystery . id } title = { editingMystery . title } onClose = {() => { setEditingMystery ( null ); void reloadMysteries () }} setStatus = { setStatus } />
2026-08-18 15:37:46 +02:00
: < div className = "admin-body" >
< div className = "mystery-list" >
2026-08-18 19:05:29 +02:00
< div className = "mystery-list-head" >< span > MYSTERIES </ span >< button onClick = {() => newMystery ( setStatus , reloadMysteries , setEditingMystery )}> + NEW </ button ></ div >
{ mysteries . length === 0 && < p className = "admin-empty" > No mysteries yet — create one to begin .</ p >}
{ mysteries . map ( mystery => < div key = { mystery . id } className = "mystery-row" onClick = {() => setEditingMystery ({ id : mystery.id , title : mystery.title })}>
< div className = "mystery-row-main" >< strong >{ mystery . title }</ strong >< span >{ mystery . slug } · { mystery . nodes } node { mystery . nodes === 1 ? '' : 's' } · edit graph → </ span ></ div >
< button className = "mystery-del" title = "Delete mystery" onClick = { event => { event . stopPropagation (); void deleteMystery ( mystery , setStatus , reloadMysteries ) }}> × </ button >
</ div >)}
2026-08-18 15:37:46 +02:00
</ div >
</ div >)}
2026-08-18 19:05:29 +02:00
{ tab === 'assets' && < AssetStore setStatus = { setStatus } />}
2026-08-18 15:37:46 +02:00
< footer className = "admin-foot" >{ status || 'READY' }</ footer >
</ div >
}
2026-08-18 19:05:29 +02:00
function slugify ( value : string ) { return value . toLowerCase (). replace ( /[^a-z0-9-]+/g , '-' ). replace ( /^-+|-+$/g , '' ) }
function formatSize ( bytes : number ) { return bytes < 1024 ? ` ${ bytes } B` : bytes < 1048576 ? ` ${ ( bytes / 1024 ). toFixed ( 0 ) } KB` : ` ${ ( bytes / 1048576 ). toFixed ( 1 ) } MB` }
async function newMystery ( setStatus : ( m : string ) => void , reload : () => Promise < void >, open : ( m : { id : string ; title : string }) => void ) {
const title = window . prompt ( 'Mystery title (e.g. The Glass Harbour Diversion)' ) ? . trim ()
if ( ! title ) return
const slug = window . prompt ( 'URL slug' , slugify ( title )) ? . trim ()
if ( ! slug ) return
try {
await json ( '/api/mysteries?edit=1' , { method : 'POST' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ slug , title }) })
await reload ()
const created = ( await json < Mystery [] >( '/api/admin/mysteries' )). find ( m => m . slug === slugify ( slug ))
if ( created ) open ({ id : created.id , title : created.title })
setStatus ( `Created ${ title } ` )
} catch ( error ) { setStatus ( String (( error as Error ). message || error )) }
}
async function deleteMystery ( mystery : Mystery , setStatus : ( m : string ) => void , reload : () => Promise < void >) {
if ( ! window . confirm ( `Delete mystery “ ${ mystery . title } ”? This removes its whole story graph.` )) return
try { await json ( `/api/admin/mysteries/ ${ mystery . id } ` , { method : 'DELETE' }); await reload (); setStatus ( `Deleted ${ mystery . title } ` ) }
catch ( error ) { setStatus ( String (( error as Error ). message || error )) }
}
type Asset = { id : string ; originalName : string ; mimeType : string ; byteSize : number ; url : string }
function AssetStore ({ setStatus } : { setStatus : ( m : string ) => void }) {
const [ assets , setAssets ] = useState < Asset [] >([])
const [ busy , setBusy ] = useState ( false )
const fileRef = useRef < HTMLInputElement >( null )
const reload = useCallback (() => json < Asset [] >( '/api/admin/assets' ). then ( setAssets ). catch ( error => setStatus ( String (( error as Error ). message || error ))), [ setStatus ])
useEffect (() => { void reload () }, [ reload ])
const upload = async ( files : FileList ) => {
setBusy ( true )
try {
for ( const file of Array . from ( files )) { const form = new FormData (); form . append ( 'file' , file ); await json ( '/api/admin/assets' , { method : 'POST' , body : form }) }
if ( fileRef . current ) fileRef . current . value = ''
await reload (); setStatus ( `Uploaded ${ files . length } file ${ files . length === 1 ? '' : 's' } ` )
} catch ( error ) { setStatus ( String (( error as Error ). message || error )) } finally { setBusy ( false ) }
}
const remove = async ( asset : Asset ) => {
if ( ! window . confirm ( `Delete ${ asset . originalName } ?` )) return
try { await json ( `/api/admin/assets/ ${ asset . id } ` , { method : 'DELETE' }); await reload (); setStatus ( 'Deleted' ) }
catch ( error ) { setStatus ( String (( error as Error ). message || error )) }
}
const copy = ( text : string ) => { void navigator . clipboard ? . writeText ( text ); setStatus ( `Copied ${ text } ` ) }
return < div className = "admin-body" >
< div className = "asset-store" >
< div className = "mystery-list-head" >< span > ASSET LIBRARY · images · audio · pdf </ span >
< label className = "asset-upload-btn" >{ busy ? 'UPLOADING…' : '+ UPLOAD' }
< input ref = { fileRef } type = "file" accept = "image/*,audio/*,application/pdf" multiple onChange = { event => { if ( event . target . files ? . length ) void upload ( event . target . files ) }} />
</ label >
</ div >
{ assets . length === 0 && < p className = "admin-empty" > No assets yet . Upload images , audio , or PDFs .</ p >}
< div className = "asset-grid" >
{ assets . map ( asset => < figure key = { asset . id } className = "asset-card" >
< div className = "asset-thumb" >{ asset . mimeType . startsWith ( 'image/' )
? < img src = { asset . url } alt = "" />
: < span className = "asset-icon" >{ asset . mimeType . startsWith ( 'audio/' ) ? '♪' : asset . mimeType === 'application/pdf' ? 'PDF' : 'FILE' }</ span >}</ div >
< figcaption title = { asset . originalName }>{ asset . originalName }</ figcaption >
< small >{ formatSize ( asset . byteSize )}</ small >
< div className = "asset-actions" >
< button onClick = {() => copy ( asset . id )} title = "Copy asset id" > id </ button >
< button onClick = {() => copy ( asset . url )} title = "Copy URL" > url </ button >
< button className = "asset-del" onClick = {() => remove ( asset )} title = "Delete (blocked if in use)" > × </ button >
</ div >
</ figure >)}
</ div >
</ div >
</ div >
}
2026-08-18 15:37:46 +02:00
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 || '' )
2026-08-22 17:16:48 +02:00
const [ phoneNumber , setPhoneNumber ] = useState ( npc . phoneNumber || '' )
const [ email , setEmail ] = useState ( npc . email || '' )
2026-08-18 15:37:46 +02:00
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
2026-08-22 17:16:48 +02:00
|| ( phoneNumber || null ) !== npc . phoneNumber || ( email || null ) !== npc . email
2026-08-18 15:37:46 +02:00
const save = async () => {
setBusy ( true )
try {
2026-08-22 17:16:48 +02:00
await json ( `/api/admin/npcs/ ${ npc . id } ` , { method : 'PATCH' , headers : { 'content-type' : 'application/json' }, body : JSON.stringify ({ name , role , defaultPose : defaultPose || null , phoneNumber : phoneNumber || null , email : email || null }) })
2026-08-18 15:37:46 +02:00
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 >
2026-08-22 17:16:48 +02:00
< div className = "admin-field" >< label > Phone number </ label >< input value = { phoneNumber } onChange = { event => setPhoneNumber ( event . target . value )} placeholder = "55501" /></ div >
< div className = "admin-field" >< label > Email </ label >< input value = { email } onChange = { event => setEmail ( event . target . value )} placeholder = "hunter@glitch.university" /></ div >
2026-08-18 15:37:46 +02:00
< 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 >
}