From 7fe8fadd8a3fcef6cb324ebd433f27b262b674ea Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Tue, 18 Aug 2026 19:05:29 +0200 Subject: [PATCH] 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 --- server/index.ts | 27 ++++++++++ server/narrativeRepository.ts | 35 +++++++++++++ src/admin.tsx | 95 +++++++++++++++++++++++++++++++---- src/mysteryGraph.tsx | 6 ++- src/narrative.tsx | 1 + src/styles.css | 26 ++++++++++ 6 files changed, 179 insertions(+), 11 deletions(-) diff --git a/server/index.ts b/server/index.ts index 303872f..8892472 100644 --- a/server/index.ts +++ b/server/index.ts @@ -145,6 +145,33 @@ function requireEditing(res: express.Response) { app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => { try { res.json(await narrative.listMysteries()) } catch (error) { next(error) } }) +app.delete('/api/admin/mysteries/:id', requireAdmin, async (req, res, next) => { + try { + if (!requireEditing(res)) return + const ok = await narrative.deleteMystery(String(req.params.id)) + ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Mystery not found' }) + } catch (error) { next(error) } +}) +// Shared asset library (images, audio, PDFs) — reuses the immutable, deduplicated +// osint.assets store; bytes served via GET /api/assets/:id. +app.get('/api/admin/assets', requireAdmin, async (_req, res, next) => { + try { res.json(await narrative.listAssets()) } catch (error) { next(error) } +}) +app.post('/api/admin/assets', requireAdmin, upload.single('file'), async (req, res, next) => { + try { + if (!requireEditing(res)) return + if (!req.file) return res.status(400).json({ error: 'A file is required' }) + res.status(201).json(await narrative.uploadAsset(req.file)) + } catch (error) { next(error) } +}) +app.delete('/api/admin/assets/:id', requireAdmin, async (req, res, next) => { + try { + if (!requireEditing(res)) return + const outcome = await narrative.deleteAsset(String(req.params.id)) + if (outcome === 'deleted') return res.json({ ok: true }) + res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'Asset is in use' : 'Asset not found' }) + } catch (error) { next(error) } +}) app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => { try { res.json(await narrative.listNpcs()) } catch (error) { next(error) } }) diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index 4000c81..8b995fb 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -4,6 +4,7 @@ import { cloneBoard } from './boardClone.js' import type { ObjectStorage } from './objectStorage.js' export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number } +export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string } export type PoseDto = { poseKey: string; assetId: string; url: string } export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean } export type MysterySummary = { id: string; slug: string; title: string; nodes: number } @@ -46,6 +47,10 @@ export interface NarrativeRepository { getCurrentPlaythrough(userId: string): Promise advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> listMysteries(): Promise + deleteMystery(id: string): Promise + uploadAsset(file: UploadedFile): Promise + listAssets(): Promise + deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'> listNpcs(): Promise createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise @@ -259,6 +264,36 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora return result.rows.map(row => ({ id: row.id, slug: row.slug, title: row.title, nodes: Number(row.nodes) })) }, + async deleteMystery(id) { + const result = await pool.query('DELETE FROM osint.mysteries WHERE id=$1', [id]) + return (result.rowCount ?? 0) > 0 + }, + + async uploadAsset(file) { + const id = await storeAsset(file) + const row = (await pool.query<{ original_name: string; mime_type: string; byte_size: string }>( + 'SELECT original_name,mime_type,byte_size FROM osint.assets WHERE id=$1', [id])).rows[0] + return { id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${id}` } + }, + + async listAssets() { + const result = await pool.query<{ id: string; original_name: string; mime_type: string; byte_size: string }>( + 'SELECT id,original_name,mime_type,byte_size FROM osint.assets ORDER BY created_at DESC') + return result.rows.map(row => ({ id: row.id, originalName: row.original_name, mimeType: row.mime_type, byteSize: Number(row.byte_size), url: `/api/assets/${row.id}` })) + }, + + async deleteAsset(id) { + // document_exhibits.asset_id is ON DELETE RESTRICT, so an in-use asset raises a + // foreign-key violation (23503) rather than deleting. + try { + const result = await pool.query('DELETE FROM osint.assets WHERE id=$1', [id]) + return (result.rowCount ?? 0) > 0 ? 'deleted' : 'not_found' + } catch (error) { + if ((error as { code?: string }).code === '23503') return 'in_use' + throw error + } + }, + async listNpcs() { const npcs = await pool.query<{ id: string }>('SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name') return (await Promise.all(npcs.rows.map(row => loadNpc(row.id)))).filter((npc): npc is NpcDto => npc !== null) diff --git a/src/admin.tsx b/src/admin.tsx index 595d3be..68cb633 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -13,7 +13,7 @@ async function json(url: string, init?: RequestInit): Promise { export function AdminPanel() { const [isAdmin, setIsAdmin] = useState(null) - const [tab, setTab] = useState<'npcs' | 'mysteries'>('npcs') + const [tab, setTab] = useState<'npcs' | 'mysteries' | 'assets'>('npcs') const [npcs, setNpcs] = useState([]) const [mysteries, setMysteries] = useState([]) const [selectedId, setSelectedId] = useState(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('/api/admin/mysteries').then(setMysteries).catch(() => {}), []) useEffect(() => { if (!isAdmin) return reloadNpcs().catch(error => setStatus(String(error.message || error))) - json('/api/admin/mysteries').then(setMysteries).catch(() => {}) - }, [isAdmin, reloadNpcs]) + void reloadMysteries() + }, [isAdmin, reloadNpcs, reloadMysteries]) if (isAdmin === null) return
GU

GLITCH UNIVERSITY · ADMIN

AUTHENTICATING…
if (!isAdmin) return
GU

ADMINISTRATOR ACCESS REQUIRED

← RETURN TO TERMINAL
@@ -46,6 +47,7 @@ export function AdminPanel() { ← TERMINAL @@ -65,22 +67,95 @@ export function AdminPanel() { } {tab === 'mysteries' && (editingMystery - ? setEditingMystery(null)} setStatus={setStatus} /> + ? { setEditingMystery(null); void reloadMysteries() }} setStatus={setStatus} /> :
- {mysteries.length === 0 &&

No mysteries authored yet.

} - {mysteries.map(mystery => )} -

Click a mystery to open its story-flow graph editor.

+
MYSTERIES
+ {mysteries.length === 0 &&

No mysteries yet — create one to begin.

} + {mysteries.map(mystery =>
setEditingMystery({ id: mystery.id, title: mystery.title })}> +
{mystery.title}{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph →
+ +
)}
)} + {tab === 'assets' && } +
{status || 'READY'}
} +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, 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('/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) { + 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([]) + const [busy, setBusy] = useState(false) + const fileRef = useRef(null) + const reload = useCallback(() => json('/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
+
+
ASSET LIBRARY · images · audio · pdf + +
+ {assets.length === 0 &&

No assets yet. Upload images, audio, or PDFs.

} +
+ {assets.map(asset =>
+
{asset.mimeType.startsWith('image/') + ? + : {asset.mimeType.startsWith('audio/') ? '♪' : asset.mimeType === 'application/pdf' ? 'PDF' : 'FILE'}}
+
{asset.originalName}
+ {formatSize(asset.byteSize)} +
+ + + +
+
)} +
+
+
+} + async function createNpc(setStatus: (message: string) => void, reload: (key?: string) => Promise) { const name = window.prompt('NPC display name (e.g. Prof. Almira Vetch)')?.trim() if (!name) return diff --git a/src/mysteryGraph.tsx b/src/mysteryGraph.tsx index ab80292..1ae2919 100644 --- a/src/mysteryGraph.tsx +++ b/src/mysteryGraph.tsx @@ -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, {templates.map(t => )} } - {usesComponent && } + {usesComponent && } {(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && } {(node.nodeType === 'dialogue' || node.hasUtterances) && } diff --git a/src/narrative.tsx b/src/narrative.tsx index ebd90c0..54dd63c 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -45,6 +45,7 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) = ) const CUTSCENE_REGISTRY: Record 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 diff --git a/src/styles.css b/src/styles.css index ebf4730..ddd50b0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }