feat: add immutable level template lifecycle
This commit is contained in:
+40
-3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user