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) => {
|
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) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user