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>
245 lines
15 KiB
TypeScript
245 lines
15 KiB
TypeScript
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>
|
||
}
|