165 lines
9.4 KiB
TypeScript
165 lines
9.4 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; 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'>('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))
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!isAdmin) return
|
|||
|
|
reloadNpcs().catch(error => setStatus(String(error.message || error)))
|
|||
|
|
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {})
|
|||
|
|
}, [isAdmin, reloadNpcs])
|
|||
|
|
|
|||
|
|
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>
|
|||
|
|
</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)} setStatus={setStatus} />
|
|||
|
|
: <div className="admin-body">
|
|||
|
|
<div className="mystery-list">
|
|||
|
|
{mysteries.length === 0 && <p className="admin-empty">No mysteries authored yet.</p>}
|
|||
|
|
{mysteries.map(mystery => <button key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
|
|||
|
|
<strong>{mystery.title}</strong>
|
|||
|
|
<span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →</span>
|
|||
|
|
</button>)}
|
|||
|
|
<p className="admin-note">Click a mystery to open its story-flow graph editor.</p>
|
|||
|
|
</div>
|
|||
|
|
</div>)}
|
|||
|
|
|
|||
|
|
<footer className="admin-foot">{status || 'READY'}</footer>
|
|||
|
|
</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 [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
|
|||
|
|
|
|||
|
|
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 }) })
|
|||
|
|
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>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>
|
|||
|
|
}
|