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
+5 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { UtteranceCanvas } from './utteranceCanvas'
import { CUTSCENE_COMPONENT_KEYS } from './narrative'
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
@@ -181,7 +182,10 @@ function NodeInspector({ node, graph, templates, onPatch, onSetEntry, onDelete,
<option value=""> choose </option>
{templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)}
</select></label>}
{usesComponent && <label className="ins-field"><span>Component key</span><input value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} /></label>}
{usesComponent && <label className="ins-field"><span>Component key{node.nodeType === 'cutscene' && componentKey && !CUTSCENE_COMPONENT_KEYS.includes(componentKey) && <b className="ins-warn"> · not registered</b>}</span>
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} />
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
</label>}
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances </button>}
+1
View File
@@ -45,6 +45,7 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
</div>
)
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
+26
View File
@@ -548,3 +548,29 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
/* Utterance canvas vertical flow: input on top, output on the bottom */
.uinput { top: -6px; bottom: auto; left: 50%; right: auto; transform: translateX(-50%); }
.uport.flow { bottom: -7px; top: auto; left: 50%; right: auto; transform: translateX(-50%); }
/* Admin: mystery list actions + asset library */
.mystery-list-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 0 10px; font: 600 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
.mystery-list-head button, .asset-upload-btn { background: #143229; border: 1px solid #3c5a52; color: #d79754; font: 9px IBM Plex Mono; padding: 6px 12px; cursor: pointer; }
.mystery-list-head button:hover, .asset-upload-btn:hover { background: #1c463a; }
.asset-upload-btn { display: inline-flex; align-items: center; letter-spacing: .1em; }
.asset-upload-btn input { display: none; }
.mystery-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px 6px; border: 0; border-bottom: 1px solid #1c352e; background: none; text-align: left; cursor: pointer; }
.mystery-row:hover { background: #12312a; }
.mystery-row-main { flex: 1; display: grid; gap: 3px; min-width: 0; }
.mystery-del { flex: 0 0 auto; background: none; border: 1px solid #3c5a52; color: #9bb0a9; width: 26px; height: 26px; font-size: 15px; cursor: pointer; }
.mystery-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; }
.ins-warn { color: #d78a7f; font-weight: 600; }
.asset-store { padding: 0 24px 24px; overflow: auto; }
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
.asset-card { margin: 0; border: 1px solid #35544c; background: #0e2a24; display: flex; flex-direction: column; }
.asset-thumb { aspect-ratio: 4 / 3; display: grid; place-items: center; overflow: hidden; background: #0a211d; }
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; }
.asset-icon { font: 600 20px IBM Plex Mono; color: #7f9a92; letter-spacing: .06em; }
.asset-card figcaption { padding: 7px 9px 2px; font: 10px IBM Plex Mono; color: #cfe0d9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.asset-card small { padding: 0 9px 6px; font: 8px IBM Plex Mono; color: #7f9a92; }
.asset-actions { display: flex; gap: 4px; padding: 0 8px 8px; margin-top: auto; }
.asset-actions button { flex: 1; background: #143229; border: 1px solid #3c5a52; color: #9bb0a9; font: 8px IBM Plex Mono; padding: 5px; cursor: pointer; }
.asset-actions button:hover { background: #1c463a; color: #e4e9e4; }
.asset-actions .asset-del { flex: 0 0 26px; }
.asset-actions .asset-del:hover { border-color: #d78a7f; color: #e5b3ab; background: #4a221d; }