Adding migrations and lot of work. Starting work on the demo scope
This commit is contained in:
+196
-78
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types'
|
||||
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState, type PlaythroughSummary, type RuntimeNode } from './narrative'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { AdminPanel } from './admin'
|
||||
import { audio } from './audio'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
|
||||
@@ -14,6 +12,11 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
||||
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
||||
|
||||
function uid(_prefix: string) { return crypto.randomUUID() }
|
||||
function screenshotFile(file: File, index: number) {
|
||||
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png'
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19)
|
||||
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
|
||||
}
|
||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||
function documentSearchText(document: CaseDocument) {
|
||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
|
||||
@@ -63,11 +66,9 @@ export function App() {
|
||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||
const [splashOpen, setSplashOpen] = useState(false)
|
||||
const [splashBusy, setSplashBusy] = useState(false)
|
||||
const [playthrough, setPlaythrough] = useState<PlaythroughSummary | null>(null)
|
||||
const [runtimeNode, setRuntimeNode] = useState<RuntimeNode | null>(null)
|
||||
const [muted, setMuted] = useState(audio.isMuted())
|
||||
const [flagsOpen, setFlagsOpen] = useState(false)
|
||||
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -80,6 +81,13 @@ export function App() {
|
||||
if (!response.ok) throw new Error('Level unavailable')
|
||||
const data = normalizeCase(await response.json())
|
||||
setCaseState(data)
|
||||
const arrivals = data.newlyVisibleDocumentIds || []
|
||||
if (arrivals.length) {
|
||||
setArrivingExhibitIds(arrivals)
|
||||
void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}, [])
|
||||
|
||||
@@ -95,18 +103,11 @@ export function App() {
|
||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
}
|
||||
// Player campaign entry: a live playthrough resumes silently; none shows the splash.
|
||||
// An explicit ?level= deep link (admin/authoring) bypasses the campaign entirely.
|
||||
const boot = async () => {
|
||||
if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
|
||||
const current = await fetch('/api/playthroughs/current')
|
||||
if (current.status === 204) { setSplashOpen(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
||||
if (!current.ok) throw new Error('Playthrough unavailable')
|
||||
const state: PlaythroughState = await current.json()
|
||||
setPlaythrough(state.playthrough)
|
||||
setRuntimeNode(state.node)
|
||||
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
const levels = await (await fetch('/api/levels')).json() as { id: string }[]
|
||||
if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
|
||||
await openLevel(levels[0].id)
|
||||
}
|
||||
boot().catch(async () => {
|
||||
try {
|
||||
@@ -125,40 +126,6 @@ export function App() {
|
||||
return () => clearInterval(timer)
|
||||
}, [loadLevelBySlug, adminRoute])
|
||||
|
||||
const applyState = useCallback(async (state: PlaythroughState) => {
|
||||
setPlaythrough(state.playthrough)
|
||||
setRuntimeNode(state.node)
|
||||
if (state.node?.kind === 'level' && state.node.levelSlug) await loadLevelBySlug(state.node.levelSlug)
|
||||
if (!state.node && state.playthrough.status === 'finished') { setSplashOpen(true); setStatus('CASE CLOSED · GREYHAVEN FILE 87-10') }
|
||||
}, [loadLevelBySlug])
|
||||
|
||||
const startNewGame = useCallback(async () => {
|
||||
setSplashBusy(true)
|
||||
try {
|
||||
const response = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
||||
if (!response.ok) throw new Error('Could not start')
|
||||
await applyState(await response.json())
|
||||
setSplashOpen(false)
|
||||
} catch { setStatus('COULD NOT OPEN CASE FILE') } finally { setSplashBusy(false) }
|
||||
}, [applyState])
|
||||
|
||||
// Advance the story graph through a terminal (a dialogue supplies the chosen exit;
|
||||
// cutscene/level advance through the node's single terminal).
|
||||
const advance = useCallback(async (terminalKey?: string) => {
|
||||
if (!playthrough) return
|
||||
try {
|
||||
const response = await fetch(`/api/playthroughs/${encodeURIComponent(playthrough.id)}/advance`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(terminalKey ? { terminalKey } : {}) })
|
||||
if (!response.ok) throw new Error()
|
||||
await applyState(await response.json())
|
||||
} catch { setStatus('COULD NOT ADVANCE') }
|
||||
}, [playthrough, applyState])
|
||||
|
||||
// Scene music follows the current node (null inherits; a finished playthrough stops).
|
||||
useEffect(() => {
|
||||
if (runtimeNode?.musicUrl) audio.setMusic(runtimeNode.musicUrl, runtimeNode.musicVolume)
|
||||
else if (playthrough?.status === 'finished') audio.setMusic(null)
|
||||
}, [runtimeNode, playthrough])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminMenuOpen) return
|
||||
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
|
||||
@@ -178,6 +145,12 @@ export function App() {
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [recentlyCreatedConnectionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!arrivingExhibitIds.length) return
|
||||
const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [arrivingExhibitIds])
|
||||
|
||||
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
||||
setCaseState(current => {
|
||||
if (!current) return current
|
||||
@@ -371,33 +344,65 @@ export function App() {
|
||||
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: FileList | File[]) => {
|
||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
||||
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
||||
if (!caseState) return
|
||||
const queue = Array.from(files)
|
||||
setUploading(queue.length)
|
||||
setDraggingFiles(false)
|
||||
for (const file of queue) {
|
||||
for (const [queueIndex, file] of queue.entries()) {
|
||||
const position = nextOpenBoardPosition(caseState.exhibits, {
|
||||
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
|
||||
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
|
||||
}, { width: 174, height: 145 })
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('x', String(position.x))
|
||||
form.append('y', String(position.y))
|
||||
try {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form })
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
||||
const document: CaseDocument = await response.json()
|
||||
update(s => ({ ...s, exhibits: [...s.exhibits, document] }))
|
||||
setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
|
||||
const uploaded: UploadedCaseDocument = await response.json()
|
||||
const { analysis, ...document } = uploaded
|
||||
update(s => {
|
||||
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
||||
})
|
||||
setSelected(document.id)
|
||||
setArrivingExhibitIds(current => [...new Set([...current, document.id])])
|
||||
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||||
})
|
||||
if (analysis.awardedFlags.length) {
|
||||
window.clearTimeout(saveTimer.current)
|
||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||
await loadLevelBySlug(caseState.id, editQuery)
|
||||
setSelected(document.id)
|
||||
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||||
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||||
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
||||
} finally { setUploading(count => count - 1) }
|
||||
}
|
||||
}
|
||||
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||||
|
||||
const audioToggle = playthrough ? <button className={`audio-toggle${muted ? ' muted' : ''}`} title={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted(audio.toggleMute())}>♪</button> : null
|
||||
useEffect(() => {
|
||||
if (!caseState) return
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const images = Array.from(event.clipboardData?.items || []).flatMap(item => {
|
||||
const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null
|
||||
return file ? [file] : []
|
||||
})
|
||||
if (!images.length) return
|
||||
event.preventDefault()
|
||||
void uploadFiles(images.map(screenshotFile), 'clipboard')
|
||||
}
|
||||
window.addEventListener('paste', handlePaste)
|
||||
return () => window.removeEventListener('paste', handlePaste)
|
||||
}, [caseState, uploadFiles])
|
||||
|
||||
if (adminRoute) return <AdminPanel />
|
||||
if (splashOpen) return <SplashScreen hasResume={false} busy={splashBusy} status={status} onNewGame={startNewGame} onResume={() => setSplashOpen(false)} />
|
||||
// Story-graph runtime: cutscene and dialogue nodes play full-screen (no board).
|
||||
if (runtimeNode?.kind === 'cutscene') return <>{audioToggle}<CutsceneHost componentKey={runtimeNode.componentKey} label={runtimeNode.label} onComplete={() => advance()} /></>
|
||||
if (runtimeNode?.kind === 'dialogue') return <>{audioToggle}<DialoguePlayer node={{ utterances: runtimeNode.utterances || [], rootId: runtimeNode.rootId ?? null }} onExit={advance} /></>
|
||||
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||
|
||||
@@ -421,7 +426,6 @@ export function App() {
|
||||
})
|
||||
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||
return <main className="desktop">
|
||||
{audioToggle}
|
||||
<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>
|
||||
@@ -429,13 +433,13 @@ export function App() {
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
{runtimeNode?.kind === 'level' && <button className="report-back" onClick={() => advance()} title="Finish investigating and continue the story">REPORT BACK ▸</button>}
|
||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||||
<button role="menuitem" onClick={() => window.location.assign('/admin')}>NPC & MYSTERY ADMIN</button>
|
||||
<button role="menuitem" onClick={() => { setFlagsOpen(true); setAdminMenuOpen(false) }}>LEVEL FLAGS</button>
|
||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||
<button role="menuitem" onClick={() => { setMatchRulesOpen(true); setAdminMenuOpen(false) }}>EVIDENCE MATCHING</button>
|
||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
||||
@@ -450,9 +454,9 @@ export function App() {
|
||||
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
||||
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
||||
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
|
||||
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
|
||||
<><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></>
|
||||
<div className="doc-list">
|
||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||||
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||
</button>)}
|
||||
@@ -461,9 +465,9 @@ export function App() {
|
||||
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
||||
</aside>
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||
@@ -521,7 +525,7 @@ export function App() {
|
||||
setStatus('FOLDER UPDATED')
|
||||
}}
|
||||
/>}
|
||||
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||
}
|
||||
{editingEventId && <EventEditor
|
||||
key={editingEventId}
|
||||
@@ -602,6 +606,8 @@ export function App() {
|
||||
onRemove={() => removeThread(threadDraft.id)}
|
||||
/>}
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||||
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||||
</main>
|
||||
}
|
||||
|
||||
@@ -620,7 +626,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||
}
|
||||
|
||||
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const suppressClick = useRef(false)
|
||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||
@@ -808,14 +814,14 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
|
||||
</svg>
|
||||
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||
{evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
|
||||
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
|
||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||
<Widget exhibit={ev} context={widgetContext}/>
|
||||
</article>})}
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
@@ -1072,15 +1078,18 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt,
|
||||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||||
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||
}
|
||||
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
|
||||
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
|
||||
@@ -1091,6 +1100,7 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
|
||||
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
||||
@@ -1099,6 +1109,114 @@ function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onC
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||
const [flags, setFlags] = useState<LevelFlag[]>([])
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const load = useCallback(async () => {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags`)
|
||||
if (!response.ok) throw new Error('Could not load level flags')
|
||||
setFlags(await response.json())
|
||||
}, [levelId])
|
||||
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load flags')) }, [load])
|
||||
const setEarned = async (key: string, earned: boolean) => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags/${encodeURIComponent(key)}`, { method: earned ? 'PUT' : 'DELETE' })
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not update flag') }
|
||||
await Promise.all([load(), onChanged()])
|
||||
setNewKey('')
|
||||
} catch (error) { setError(error instanceof Error ? error.message : 'Could not update flag') } finally { setBusy(false) }
|
||||
}
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const key = newKey.trim().toLowerCase()
|
||||
if (key) void setEarned(key, true)
|
||||
}
|
||||
return <div className="modal-shade"><section className="window flags-editor">
|
||||
<header><Network size={16}/><b>Level flags</b><span/><button type="button" aria-label="Close level flags" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="flags-editor-body"><small>ACHIEVEMENTS / DOCUMENT REVEALS</small>
|
||||
<p>Documents remain server-hidden until every flag assigned in their metadata has been earned.</p>
|
||||
<div className="flag-list">{flags.length === 0 && <div className="flag-empty">NO FLAGS OR DOCUMENT GATES IN THIS LEVEL</div>}{flags.map(flag => <div className={`flag-row ${flag.earnedAt ? 'earned' : ''}`} key={flag.key}><div><b>{flag.key}</b><small>{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}</small></div><button disabled={busy} onClick={() => void setEarned(flag.key, !flag.earnedAt)}>{flag.earnedAt ? 'REVOKE' : 'AWARD'}</button></div>)}</div>
|
||||
<form className="flag-add" onSubmit={submit}><input aria-label="New flag key" value={newKey} placeholder="tip.received" pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setNewKey(event.target.value.toLowerCase())}/><button disabled={busy || !newKey.trim()} type="submit">AWARD FLAG</button></form>
|
||||
{error && <p className="flag-error">{error}</p>}
|
||||
</div>
|
||||
</section></div>
|
||||
}
|
||||
|
||||
type MatchRuleDraft = {
|
||||
id?: string
|
||||
name: string
|
||||
flagKey: string
|
||||
minimumAnchorMatches: number
|
||||
enabled: boolean
|
||||
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
|
||||
}
|
||||
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
|
||||
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
|
||||
|
||||
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
|
||||
const [rules, setRules] = useState<EvidenceMatchRuleDefinition[]>([])
|
||||
const [draft, setDraft] = useState<MatchRuleDraft>(emptyMatchRule)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const load = useCallback(async () => {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules`)
|
||||
if (!response.ok) throw new Error('Could not load evidence match rules')
|
||||
setRules(await response.json())
|
||||
}, [levelId])
|
||||
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
|
||||
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
|
||||
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
|
||||
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault(); setBusy(true); setError('')
|
||||
try {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
|
||||
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
|
||||
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
|
||||
})
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
|
||||
await load(); setDraft(emptyMatchRule())
|
||||
} catch (error) { setError(error instanceof Error ? error.message : 'Could not save evidence rule') } finally { setBusy(false) }
|
||||
}
|
||||
const remove = async (rule: EvidenceMatchRuleDefinition) => {
|
||||
if (!window.confirm(`Delete evidence match rule “${rule.name}”?`)) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules/${encodeURIComponent(rule.id)}`, { method:'DELETE' })
|
||||
if (!response.ok) throw new Error('Could not delete evidence rule')
|
||||
await load(); if (draft.id === rule.id) setDraft(emptyMatchRule())
|
||||
} catch (error) { setError(error instanceof Error ? error.message : 'Could not delete evidence rule') } finally { setBusy(false) }
|
||||
}
|
||||
const validAnchors = draft.anchors.filter(anchor => anchor.phrase.trim().length >= 12)
|
||||
const canSave = draft.name.trim() && /^[a-z][a-z0-9_.-]{0,63}$/.test(draft.flagKey) && validAnchors.length === draft.anchors.length
|
||||
&& draft.minimumAnchorMatches >= 1 && draft.minimumAnchorMatches <= draft.anchors.length
|
||||
return <div className="modal-shade"><section className="window match-rules-editor">
|
||||
<header><Search size={16}/><b>Evidence text matching</b><span/><button type="button" aria-label="Close evidence matching" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="match-rules-body"><small>OCR / FUZZY PASSAGE RULES</small>
|
||||
<p>When OCR from a player-uploaded source matches enough distinctive passages, the configured flag is awarded. Matching ignores case, punctuation, accents, and ordinary OCR noise.</p>
|
||||
<div className="match-rule-layout"><div className="match-rule-list">
|
||||
{rules.length === 0 && <div className="flag-empty">NO AUTOMATIC EVIDENCE RULES</div>}
|
||||
{rules.map(rule => <div className={`match-rule-row ${rule.enabled ? '' : 'disabled'}`} key={rule.id}><div><b>{rule.name}</b><small>{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS</small></div><button type="button" onClick={() => edit(rule)}>EDIT</button><button type="button" disabled={busy} onClick={() => void remove(rule)}><Trash2 size={12}/></button></div>)}
|
||||
</div>
|
||||
<form className="match-rule-form" onSubmit={event => void submit(event)}>
|
||||
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
|
||||
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
|
||||
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
|
||||
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
|
||||
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
|
||||
<div className="anchor-heading"><b>REFERENCE PASSAGES</b><button type="button" onClick={() => setDraft(value => ({ ...value,anchors:[...value.anchors,{ id:uid('anchor'),phrase:'',minimumSimilarity:.72 }] }))}><Plus size={12}/> ADD PASSAGE</button></div>
|
||||
<div className="anchor-list">{draft.anchors.map((anchor,index) => <div className="anchor-row" key={anchor.id}><div><small>ANCHOR {index + 1}</small><textarea value={anchor.phrase} rows={3} placeholder="Paste a distinctive passage of at least 12 characters…" onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,phrase:event.target.value } : item) }))}/></div><label><span>SIMILARITY</span><input type="number" min="0.5" max="1" step="0.01" value={anchor.minimumSimilarity} onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,minimumSimilarity:Number(event.target.value) } : item) }))}/></label><button type="button" aria-label="Remove reference passage" disabled={draft.anchors.length === 1} onClick={() => setDraft(value => ({ ...value,minimumAnchorMatches:Math.min(value.minimumAnchorMatches,value.anchors.length - 1),anchors:value.anchors.filter(item => item.id !== anchor.id) }))}><Trash2 size={13}/></button></div>)}</div>
|
||||
{error && <p className="flag-error">{error}</p>}
|
||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CLOSE</button><button className="primary" type="submit" disabled={busy || !canSave}>{busy ? 'SAVING…' : 'SAVE RULE'}</button></div>
|
||||
</form></div>
|
||||
</div>
|
||||
</section></div>
|
||||
}
|
||||
|
||||
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
||||
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
|
||||
+4
-1
@@ -210,6 +210,7 @@ type LegacyCaseState = {
|
||||
sourceTemplateVersionId?: string
|
||||
editingAllowed?: boolean
|
||||
revision?: number
|
||||
newlyVisibleDocumentIds?: string[]
|
||||
exhibits?: Exhibit[]
|
||||
views?: BoardView[]
|
||||
documents?: Array<Record<string, unknown>>
|
||||
@@ -240,6 +241,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,5 +279,6 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
|
||||
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
|
||||
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0),
|
||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed }
|
||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
|
||||
}
|
||||
|
||||
+51
-11
@@ -228,16 +228,16 @@ function PhoneDevice({ open, onKey }: { open: boolean; onKey: (k: string) => voi
|
||||
return <div ref={stageRef} className="phone-canvas" />
|
||||
}
|
||||
|
||||
// ---- directory (spike stub) ---------------------------------------------------
|
||||
// Real version: numbers are the phone node's terminals -> dialogue nodes; `requires`
|
||||
// are the target node's flag requirements, checked against the playthrough flags.
|
||||
// ---- directory ----------------------------------------------------------------
|
||||
// The number->node directory stays authored config for now; `requires` are the
|
||||
// target node's flag requirements, checked live against the player's ACHIEVEMENTS
|
||||
// (the real playthrough case-state). Wiring numbers to actual dialogue nodes and
|
||||
// moving the directory server-side is the next slice.
|
||||
type Contact = { name: string; requires: string[] }
|
||||
const DIRECTORY: Record<string, Contact> = {
|
||||
'55501': { name: 'Elias Board', requires: [] }, // always enabled -> connects
|
||||
'55502': { name: 'Voss Antiquities', requires: ['found_voss_number'] }, // enabled below -> connects
|
||||
'55503': { name: 'Preservation Soc.', requires: ['society_clearance'] }, // not enabled -> voicemail
|
||||
'55501': { name: 'Elias Board', requires: ['elias_number_callable'] }, // dev-grant unlocks -> connects
|
||||
'55502': { name: 'Voss Antiquities', requires: ['voss_number_known'] }, // not earned -> voicemail
|
||||
}
|
||||
const FLAGS = new Set<string>(['found_voss_number']) // toggle to demo connect vs. voicemail
|
||||
|
||||
type Mode = 'home' | 'dial' | 'calling' | 'unknown' | 'voicemail' | 'connected'
|
||||
|
||||
@@ -270,6 +270,9 @@ export function PhonePreview() {
|
||||
const [mode, setMode] = useState<Mode>('home')
|
||||
const [dialed, setDialed] = useState('')
|
||||
const [callee, setCallee] = useState('')
|
||||
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
|
||||
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
||||
const [called, setCalled] = useState<Set<string>>(new Set())
|
||||
const callTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -277,6 +280,38 @@ export function PhonePreview() {
|
||||
setScreenOn(false); setMode('home'); setDialed('')
|
||||
}, [open])
|
||||
|
||||
const refreshAchievements = async (id: string) => {
|
||||
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
||||
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
||||
}
|
||||
// Attach to the player's live playthrough (or create one) and load its case-state.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
let id: string | null = null
|
||||
const cur = await fetch('/api/playthroughs/current')
|
||||
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
||||
if (!id) {
|
||||
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||
if (made.ok) id = (await made.json())?.playthrough?.id ?? null
|
||||
}
|
||||
if (cancelled || !id) return
|
||||
setPlaythroughId(id)
|
||||
await refreshAchievements(id)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
|
||||
// Glow the handset when an enabled, not-yet-called number is waiting.
|
||||
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
||||
|
||||
const grantElias = async () => {
|
||||
if (!playthroughId) return
|
||||
await fetch(`/api/playthroughs/${playthroughId}/achievements`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ flagKey: 'elias_number_callable' }) })
|
||||
await refreshAchievements(playthroughId)
|
||||
}
|
||||
|
||||
const resolveCall = (num: string) => {
|
||||
sfx.ring()
|
||||
setMode('calling')
|
||||
@@ -285,8 +320,8 @@ export function PhonePreview() {
|
||||
callTimer.current = window.setTimeout(() => {
|
||||
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||
setCallee(contact.name)
|
||||
const enabled = contact.requires.every(f => FLAGS.has(f))
|
||||
if (enabled) setMode('connected')
|
||||
setCalled(prev => new Set(prev).add(num))
|
||||
if (connectable(num)) setMode('connected')
|
||||
else { sfx.voicemail(); setMode('voicemail') }
|
||||
}, 950)
|
||||
}
|
||||
@@ -319,7 +354,12 @@ export function PhonePreview() {
|
||||
<PhoneDevice open={open} onKey={press} />
|
||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||
</div>
|
||||
<button className="phone-open-btn" onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||
<p className="phone-hint">spike — dial <code>55501</code> connects · <code>55503</code> voicemail · anything else unobtainable</p>
|
||||
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||
<div className="phone-dev">
|
||||
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
||||
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
||||
</button>
|
||||
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+22
-2
@@ -110,7 +110,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
|
||||
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
||||
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||
.evidence-card.arriving, .source-file-widget.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||
.source-file-widget.arriving::before { content: 'NEW EVIDENCE'; position: absolute; z-index: 4; top: -19px; right: -8px; padding: 4px 6px; border: 1px solid #f0c16f; background: #9a3c2e; color: #fff4d6; box-shadow: 2px 3px #02090799; font: 600 7px IBM Plex Mono; letter-spacing: .08em; }
|
||||
.doc-row.arriving { animation: document-row-arrival 1.15s ease both; }
|
||||
@keyframes document-row-arrival { 0% { background: #a05328; box-shadow: inset 5px 0 #ffd48a; } 100% { background: transparent; box-shadow: inset 0 0 transparent; } }
|
||||
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
||||
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
||||
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
||||
@@ -272,6 +275,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
|
||||
.metadata-row input { padding: 6px; font-size: 9px; }
|
||||
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
|
||||
.gate-field { margin: 18px 0; padding: 12px; border: 1px dashed #9b6638; background: #d2c8af; }.gate-field small { color: #6f5840; font: 8px/1.4 IBM Plex Mono; }
|
||||
.flags-editor { width: min(560px, 90vw); }.flags-editor-body { padding: 24px 27px 26px; }.flags-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.flags-editor-body > p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }
|
||||
.flag-list { max-height: 290px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }.flag-empty { padding: 28px 16px; text-align: center; color: #6d7771; font: 8px IBM Plex Mono; }
|
||||
.flag-row { min-height: 55px; padding: 8px 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #a0a69f; }.flag-row.earned { background: #d9dfce; box-shadow: inset 4px 0 #3f755f; }.flag-row > div { flex: 1; min-width: 0; display: grid; gap: 4px; }.flag-row b { overflow: hidden; text-overflow: ellipsis; color: #273d36; font: 600 10px IBM Plex Mono; }.flag-row small { color: #737d77; font: 7px IBM Plex Mono; }.flag-row button, .flag-add button { border: 1px outset #89938d; background: #c6cbc4; color: #30463f; padding: 7px 9px; cursor: pointer; font: 8px IBM Plex Mono; }.flag-row.earned button { color: #783f2e; }
|
||||
.flag-add { margin-top: 13px; display: grid; grid-template-columns: 1fr auto; gap: 7px; }.flag-add input { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 8px 9px; font: 10px IBM Plex Mono; }.flag-add button { background: #244c41; color: white; }.flag-row button:disabled, .flag-add button:disabled { opacity: .5; cursor: default; }.flags-editor-body .flag-error { margin: 10px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||
.match-rules-editor { width: min(1000px, 94vw); max-height: min(820px, 92vh); }
|
||||
.match-rules-body { padding: 22px 25px 25px; overflow: auto; }.match-rules-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }.match-rules-body > p { max-width: 760px; margin: 10px 0 17px; font: 11px/1.5 Special Elite; }
|
||||
.match-rule-layout { display: grid; grid-template-columns: minmax(250px, .75fr) minmax(390px, 1.25fr); gap: 13px; align-items: start; }.match-rule-list { max-height: 560px; overflow: auto; border: 1px solid #8b938d; background: #d3d4cc; }
|
||||
.match-rule-row { min-height: 58px; display: grid; grid-template-columns: minmax(0, 1fr) auto 28px; align-items: center; gap: 6px; padding: 7px; border-bottom: 1px solid #a0a69f; }.match-rule-row.disabled { opacity: .55; }.match-rule-row > div { min-width: 0; display: grid; gap: 4px; }.match-rule-row b, .match-rule-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.match-rule-row b { color: #273d36; font: 600 9px IBM Plex Mono; }.match-rule-row small { color: #737d77; font: 7px IBM Plex Mono; }.match-rule-row button, .match-rule-form button { min-height: 27px; border: 1px outset #89938d; background: #c6cbc4; color: #30463f; cursor: pointer; font: 8px IBM Plex Mono; }.match-rule-row button:last-child { display: grid; place-items: center; color: #783f2e; }
|
||||
.match-rule-form { padding: 13px; border: 1px solid #8b938d; background: #cecfc7; }.match-rule-form-heading, .anchor-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 11px; color: #44504c; font: 8px IBM Plex Mono; letter-spacing: .08em; }.match-rule-form-heading button, .anchor-heading button { display: flex; align-items: center; gap: 4px; padding: 5px 8px; }.match-rule-fields { display: grid; grid-template-columns: minmax(0, 1fr) 110px; gap: 9px; }.match-rule-enabled { display: flex; align-items: center; gap: 7px; margin: 10px 0 15px; color: #59645e; font: 8px IBM Plex Mono; }.anchor-heading { margin: 0; padding: 8px 0; border-bottom: 2px solid #59625d; }.anchor-list { max-height: 295px; overflow: auto; border: 1px solid #929991; border-top: 0; background: #d7d8d0; }.anchor-row { display: grid; grid-template-columns: minmax(0, 1fr) 88px 28px; align-items: end; gap: 7px; padding: 8px; border-bottom: 1px solid #a1a69f; }.anchor-row > div, .anchor-row label { display: grid; gap: 4px; }.anchor-row small, .anchor-row label span { color: #6b756f; font: 7px IBM Plex Mono; }.anchor-row textarea { min-width: 0; resize: vertical; padding: 7px; background: #eeeadd; font: 9px/1.4 IBM Plex Mono; }.anchor-row input { min-width: 0; padding: 7px 4px; font: 8px IBM Plex Mono; }.anchor-row > button { display: grid; place-items: center; color: #783f2e; }.match-rule-form .folder-editor-actions { margin-top: 12px; }.match-rule-form .folder-editor-actions button { padding: 8px 11px; }.match-rule-form .folder-editor-actions button:disabled, .match-rule-row button:disabled, .anchor-row > button:disabled { opacity: .45; cursor: default; }.match-rule-form .flag-error { margin: 9px 0 0; color: #8a342e; font: 8px IBM Plex Mono; }
|
||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||
@media (max-width: 900px) {
|
||||
@@ -298,6 +311,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
|
||||
.brief-panel.minimized { inset: 82px 8px auto; width: auto; height: 48px; max-height: 48px; border: 2px solid #d8dbd4; box-shadow: 5px 6px 0 #020a08; }
|
||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||
.board-actions > b { display: none; }
|
||||
.board-actions > span { margin: 0 2px; }
|
||||
@@ -307,7 +321,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
|
||||
/* Narrative layer: splash + NPC dialogue */
|
||||
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
||||
@@ -628,3 +642,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.phone-open-btn:hover { border-color: #cdea6a; }
|
||||
.phone-hint { color: #5f7b73; font-family: ui-monospace, monospace; font-size: 11px; margin: 0; }
|
||||
.phone-hint code { color: #8fae4a; }
|
||||
.phone-open-btn.glow { border-color: #cdea6a; color: #eafaa0; box-shadow: 0 0 14px #9fd02088, inset 0 0 8px #9fd02044; animation: phone-glow 1.4s ease-in-out infinite; }
|
||||
@keyframes phone-glow { 50% { box-shadow: 0 0 22px #cdea6acc, inset 0 0 12px #9fd02066; } }
|
||||
.phone-dev { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; }
|
||||
.phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; }
|
||||
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface FolderExhibit extends ExhibitBase {
|
||||
|
||||
export interface DocumentExhibit extends ExhibitBase {
|
||||
type: 'document'
|
||||
/** Author-mode reveal requirements. Omitted from play-mode payloads. */
|
||||
requiredFlags?: string[]
|
||||
publishedAt?: string
|
||||
capturedAt?: string
|
||||
sourceUri?: string
|
||||
@@ -47,6 +49,17 @@ export interface DocumentExhibit extends ExhibitBase {
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DocumentUploadAnalysis {
|
||||
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
|
||||
matchedFlags: string[]
|
||||
awardedFlags: string[]
|
||||
}
|
||||
|
||||
export interface UploadedCaseDocument extends DocumentExhibit {
|
||||
/** Transient upload response data; it is not part of persisted exhibit state. */
|
||||
analysis: DocumentUploadAnalysis
|
||||
}
|
||||
|
||||
export interface NoteExhibit extends ExhibitBase {
|
||||
type: 'note'
|
||||
content: string
|
||||
@@ -152,6 +165,31 @@ export interface CaseState {
|
||||
levelStatus?: string
|
||||
sourceTemplateVersionId?: string
|
||||
editingAllowed?: boolean
|
||||
/** Visible documents that have not previously played their arrival flourish. */
|
||||
newlyVisibleDocumentIds?: string[]
|
||||
}
|
||||
|
||||
export interface LevelFlag {
|
||||
key: string
|
||||
earnedAt?: string
|
||||
gatedDocumentCount: number
|
||||
}
|
||||
|
||||
export interface EvidenceMatchAnchorDefinition {
|
||||
id: string
|
||||
phrase: string
|
||||
minimumSimilarity: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface EvidenceMatchRuleDefinition {
|
||||
id: string
|
||||
name: string
|
||||
flagKey: string
|
||||
matcherVersion: 'char_trigram_v1'
|
||||
minimumAnchorMatches: number
|
||||
enabled: boolean
|
||||
anchors: EvidenceMatchAnchorDefinition[]
|
||||
}
|
||||
|
||||
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
|
||||
|
||||
Reference in New Issue
Block a user