Admin: create/delete mysteries, asset library, cutscene component picker

- Mysteries tab: "+ New" (slug + title) and per-row delete; opens the graph editor.
- Assets tab: upload images/audio/PDFs to the shared osint.assets store, grid with
  thumbnails/icons, copy id/url, delete (blocked with 409 when the asset is in use).
- Cutscene component_key is now a datalist of registered keys with a
  "not registered" warning, instead of free text.

Backend: DELETE /api/admin/mysteries/:id and POST/GET/DELETE /api/admin/assets
(reusing the deduplicated, MinIO-backed asset store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:05:29 +02:00
co-authored by Claude Opus 4.8
parent 3b72f38d00
commit 7fe8fadd8a
6 changed files with 179 additions and 11 deletions
+85 -10
View File
@@ -13,7 +13,7 @@ async function json<T>(url: string, init?: RequestInit): Promise<T> {
export function AdminPanel() {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
const [tab, setTab] = useState<'npcs' | 'mysteries'>('npcs')
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)
@@ -29,12 +29,13 @@ export function AdminPanel() {
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)))
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {})
}, [isAdmin, reloadNpcs])
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>
@@ -46,6 +47,7 @@ export function AdminPanel() {
<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>
@@ -65,22 +67,95 @@ export function AdminPanel() {
</div>}
{tab === 'mysteries' && (editingMystery
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => setEditingMystery(null)} setStatus={setStatus} />
? <MysteryGraphEditor mysteryId={editingMystery.id} title={editingMystery.title} onClose={() => { setEditingMystery(null); void reloadMysteries() }} 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 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