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
+27
View File
@@ -145,6 +145,33 @@ function requireEditing(res: express.Response) {
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => { app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) } 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) => { app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listNpcs()) } catch (error) { next(error) } try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
}) })
+35
View File
@@ -4,6 +4,7 @@ import { cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js' import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number } 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 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 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 } export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
@@ -46,6 +47,10 @@ export interface NarrativeRepository {
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null> getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
listMysteries(): Promise<MysterySummary[]> 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[]> listNpcs(): Promise<NpcDto[]>
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): 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> 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) })) 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() { async listNpcs() {
const npcs = await pool.query<{ id: string }>('SELECT id FROM osint.npcs WHERE mystery_id IS NULL ORDER BY name') 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) return (await Promise.all(npcs.rows.map(row => loadNpc(row.id)))).filter((npc): npc is NpcDto => npc !== null)
+85 -10
View File
@@ -13,7 +13,7 @@ async function json<T>(url: string, init?: RequestInit): Promise<T> {
export function AdminPanel() { export function AdminPanel() {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null) 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 [npcs, setNpcs] = useState<Npc[]>([])
const [mysteries, setMysteries] = useState<Mystery[]>([]) const [mysteries, setMysteries] = useState<Mystery[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
@@ -29,12 +29,13 @@ export function AdminPanel() {
setNpcs(list) 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)) 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(() => { useEffect(() => {
if (!isAdmin) return if (!isAdmin) return
reloadNpcs().catch(error => setStatus(String(error.message || error))) reloadNpcs().catch(error => setStatus(String(error.message || error)))
json<Mystery[]>('/api/admin/mysteries').then(setMysteries).catch(() => {}) void reloadMysteries()
}, [isAdmin, reloadNpcs]) }, [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 === 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> 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"> <nav className="admin-tabs">
<button className={tab === 'npcs' ? 'active' : ''} onClick={() => setTab('npcs')}>NPCS</button> <button className={tab === 'npcs' ? 'active' : ''} onClick={() => setTab('npcs')}>NPCS</button>
<button className={tab === 'mysteries' ? 'active' : ''} onClick={() => setTab('mysteries')}>MYSTERIES</button> <button className={tab === 'mysteries' ? 'active' : ''} onClick={() => setTab('mysteries')}>MYSTERIES</button>
<button className={tab === 'assets' ? 'active' : ''} onClick={() => setTab('assets')}>ASSETS</button>
</nav> </nav>
<a className="admin-link" href="/"> TERMINAL</a> <a className="admin-link" href="/"> TERMINAL</a>
</header> </header>
@@ -65,22 +67,95 @@ export function AdminPanel() {
</div>} </div>}
{tab === 'mysteries' && (editingMystery {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="admin-body">
<div className="mystery-list"> <div className="mystery-list">
{mysteries.length === 0 && <p className="admin-empty">No mysteries authored yet.</p>} <div className="mystery-list-head"><span>MYSTERIES</span><button onClick={() => newMystery(setStatus, reloadMysteries, setEditingMystery)}>+ NEW</button></div>
{mysteries.map(mystery => <button key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}> {mysteries.length === 0 && <p className="admin-empty">No mysteries yet create one to begin.</p>}
<strong>{mystery.title}</strong> {mysteries.map(mystery => <div key={mystery.id} className="mystery-row" onClick={() => setEditingMystery({ id: mystery.id, title: mystery.title })}>
<span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph </span> <div className="mystery-row-main"><strong>{mystery.title}</strong><span>{mystery.slug} · {mystery.nodes} node{mystery.nodes === 1 ? '' : 's'} · edit graph </span></div>
</button>)} <button className="mystery-del" title="Delete mystery" onClick={event => { event.stopPropagation(); void deleteMystery(mystery, setStatus, reloadMysteries) }}>×</button>
<p className="admin-note">Click a mystery to open its story-flow graph editor.</p> </div>)}
</div> </div>
</div>)} </div>)}
{tab === 'assets' && <AssetStore setStatus={setStatus} />}
<footer className="admin-foot">{status || 'READY'}</footer> <footer className="admin-foot">{status || 'READY'}</footer>
</div> </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>) { 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() const name = window.prompt('NPC display name (e.g. Prof. Almira Vetch)')?.trim()
if (!name) return if (!name) return
+5 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { UtteranceCanvas } from './utteranceCanvas' import { UtteranceCanvas } from './utteranceCanvas'
import { CUTSCENE_COMPONENT_KEYS } from './narrative'
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } 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> <option value=""> choose </option>
{templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)} {templates.map(t => <option key={t.versionId} value={t.versionId}>{t.name} (v{t.version})</option>)}
</select></label>} </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.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>} {(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> </div>
) )
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion } 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 }) { export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined 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 */ /* Utterance canvas vertical flow: input on top, output on the bottom */
.uinput { top: -6px; bottom: auto; left: 50%; right: auto; transform: translateX(-50%); } .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%); } .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; }