feat: add immutable level template lifecycle

This commit is contained in:
2026-08-14 14:17:54 +02:00
parent 5599d330d8
commit c8a870549d
10 changed files with 386 additions and 26 deletions
+40 -3
View File
@@ -117,11 +117,48 @@ export function App() {
}
const reset = async () => {
if (!window.confirm('Reset the entire investigation board?')) return
if (!caseState?.sourceTemplateVersionId || !window.confirm('Reset this investigation to its original template version?')) return
const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' })
if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') }
}
const saveAsTemplate = async () => {
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
const name = window.prompt('Template name:', caseState.title)?.trim()
if (!name) return
window.clearTimeout(saveTimer.current)
setStatus('FREEZING TEMPLATE VERSION…')
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(caseState),
})
if (!saved.ok) { setStatus('LEVEL SAVE FAILED'); return }
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/templates?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }),
})
if (!response.ok) { setStatus('TEMPLATE SAVE FAILED'); return }
const template: { slug: string; currentVersion: number } = await response.json()
setStatus(`TEMPLATE ${template.slug.toUpperCase()} · VERSION ${template.currentVersion}`)
}
const instantiateTemplate = async () => {
if (!requestedEditMode || !caseState?.editingAllowed) return
const templatesResponse = await fetch('/api/templates')
if (!templatesResponse.ok) { setStatus('TEMPLATE ARCHIVE UNAVAILABLE'); return }
const templates: { slug: string; name: string; currentVersion: number }[] = await templatesResponse.json()
if (!templates.length) { setStatus('NO TEMPLATES SAVED'); return }
const templateSlug = window.prompt(`Template slug:\n${templates.map(item => `${item.slug} (v${item.currentVersion})`).join('\n')}`, templates[0].slug)?.trim()
if (!templateSlug) return
const title = window.prompt('Name the new investigation:', templates.find(item => item.slug === templateSlug)?.name || 'New Investigation')?.trim()
if (!title) return
setStatus('CLONING TEMPLATE…')
const response = await fetch(`/api/templates/${encodeURIComponent(templateSlug)}/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }),
})
if (!response.ok) { setStatus('TEMPLATE CLONE FAILED'); return }
const level: CaseState = await response.json()
window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`)
}
const uploadFiles = async (files: FileList | File[]) => {
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
const queue = Array.from(files)
@@ -161,7 +198,7 @@ export function App() {
<header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button><button onClick={() => setHelpOpen(true)}>HELP</button>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
</nav>
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
</header>
@@ -194,7 +231,7 @@ export function App() {
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
<button aria-label="Reset" title={caseState.sourceTemplateVersionId ? 'Reset to source template' : 'This level has no source template'} disabled={!caseState.sourceTemplateVersionId} onClick={reset}><RotateCcw size={17}/></button>
</div>
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
</div>
+1
View File
@@ -68,5 +68,6 @@ export interface CaseState {
viewport: Viewport
updatedAt?: string
levelStatus?: string
sourceTemplateVersionId?: string
editingAllowed?: boolean
}