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:
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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<PlaythroughState | null>
|
||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||
listMysteries(): Promise<MysterySummary[]>
|
||||
deleteMystery(id: string): Promise<boolean>
|
||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||
listAssets(): Promise<AssetDto[]>
|
||||
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||
listNpcs(): Promise<NpcDto[]>
|
||||
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
|
||||
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user