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; phoneNumber: string | null; email: string | null; poses: Pose[]; inUse: boolean } type Mystery = { id: string; slug: string; title: string; nodes: number } async function json(url: string, init?: RequestInit): Promise { 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(null) const [tab, setTab] = useState<'npcs' | 'mysteries' | 'assets'>('npcs') const [npcs, setNpcs] = useState([]) const [mysteries, setMysteries] = useState([]) const [selectedId, setSelectedId] = useState(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('/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)) }, []) const reloadMysteries = useCallback(() => json('/api/admin/mysteries').then(setMysteries).catch(() => {}), []) useEffect(() => { if (!isAdmin) return reloadNpcs().catch(error => setStatus(String(error.message || error))) void reloadMysteries() }, [isAdmin, reloadNpcs, reloadMysteries]) if (isAdmin === null) return
GU

GLITCH UNIVERSITY · ADMIN

AUTHENTICATING…
if (!isAdmin) return
GU

ADMINISTRATOR ACCESS REQUIRED

← RETURN TO TERMINAL
const selected = npcs.find(npc => npc.id === selectedId) || null return
GUOSINT BOARD / AUTHORING
← TERMINAL
{tab === 'npcs' &&
{selected ? :
Select or create an NPC.
}
} {tab === 'mysteries' && (editingMystery ? { setEditingMystery(null); void reloadMysteries() }} setStatus={setStatus} /> :
MYSTERIES
{mysteries.length === 0 &&

No mysteries yet — create one to begin.

} {mysteries.map(mystery =>
setEditingMystery({ id: mystery.id, title: mystery.title })}>
{mystery.title}{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →
)}
)} {tab === 'assets' && }
{status || 'READY'}
} 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, 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('/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) { 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([]) const [busy, setBusy] = useState(false) const fileRef = useRef(null) const reload = useCallback(() => json('/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
ASSET LIBRARY · images · audio · pdf
{assets.length === 0 &&

No assets yet. Upload images, audio, or PDFs.

}
{assets.map(asset =>
{asset.mimeType.startsWith('image/') ? : {asset.mimeType.startsWith('audio/') ? '♪' : asset.mimeType === 'application/pdf' ? 'PDF' : 'FILE'}}
{asset.originalName}
{formatSize(asset.byteSize)}
)}
} async function createNpc(setStatus: (message: string) => void, reload: (key?: string) => Promise) { 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; setStatus: (message: string) => void }) { const [name, setName] = useState(npc.name) const [role, setRole] = useState(npc.role) const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '') const [phoneNumber, setPhoneNumber] = useState(npc.phoneNumber || '') const [email, setEmail] = useState(npc.email || '') const [poseKey, setPoseKey] = useState('') const [busy, setBusy] = useState(false) const fileRef = useRef(null) const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose || (phoneNumber || null) !== npc.phoneNumber || (email || null) !== npc.email 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, phoneNumber: phoneNumber || null, email: email || 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

{npc.name || npc.key}

{npc.key}
setName(event.target.value)} />
setRole(event.target.value)} placeholder="Glitch University · Investigative Method" />
setPhoneNumber(event.target.value)} placeholder="55501" />
setEmail(event.target.value)} placeholder="hunter@glitch.university" />

Poses

{npc.poses.map(pose =>
{pose.poseKey}
{pose.poseKey}
)} {npc.poses.length === 0 &&

No poses yet. Upload a portrait below.

}
setPoseKey(event.target.value)} placeholder="pose key (e.g. neutral)" /> { const file = event.target.files?.[0]; if (file) void uploadPose(file) }} />
Poses referenced in dialogue fall back to the default pose, then to no artwork. Upload big portraits — they fill the screen in cutscenes.
}