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:
+85
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user