Files
gupi-osint-board/src/admin.tsx
T
gitprovandClaude Opus 4.8 bbf34bab09 Phone/merit authoring UI: NPC contacts + phone node + terminal NPC picker
Admin NPC editor gains phone number and email fields. The mystery graph
editor gets Phone and Merit in the node palette; a phone node's terminals
each get an NPC picker (bind the callee you reach by dialing their number),
and merit nodes get an "awards achievement" field. Node summaries + type
colors for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 17:16:48 +02:00

245 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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' | 'assets'>('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))
}, [])
const reloadMysteries = useCallback(() => json<Mystery[]>('/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 <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>
<button className={tab === 'assets' ? 'active' : ''} onClick={() => setTab('assets')}>ASSETS</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); void reloadMysteries() }} setStatus={setStatus} />
: <div className="admin-body">
<div className="mystery-list">
<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>)}
</div>
</div>)}
{tab === 'assets' && <AssetStore setStatus={setStatus} />}
<footer className="admin-foot">{status || 'READY'}</footer>
</div>
}
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>
}
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 [phoneNumber, setPhoneNumber] = useState(npc.phoneNumber || '')
const [email, setEmail] = useState(npc.email || '')
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
|| (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 <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>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>
<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>
}