feat: add brief concept party classification

This commit is contained in:
2026-08-14 14:44:58 +02:00
parent e333d3b634
commit d46a425401
17 changed files with 431 additions and 34 deletions
+117 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
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, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
@@ -33,6 +33,9 @@ export function App() {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -118,6 +121,23 @@ export function App() {
setSelected(event.id); setEditingEventId(event.id)
}
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
if (!caseState) return
const concept = caseState.brief.concepts.find(item => item.id === conceptId)
if (!concept) return
const existingId = concept.resolvedPartyExhibitId
const partyId = existingId || uid('party')
const { viewport } = caseState
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), width: 280 }
update(state => ({ ...state,
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
}))
setSelected(partyId); setBriefOpen(false); setEditingPartyId(partyId)
}
const handleCardClick = (id: string) => {
if (!linkFrom) { setSelected(current => current === id ? null : id); return }
if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) {
@@ -209,7 +229,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>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</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>
@@ -230,7 +250,16 @@ export function App() {
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { 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="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} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} />
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')}
canEdit={requestedEditMode && Boolean(caseState.editingAllowed)}
onClose={() => setBriefOpen(false)}
onEdit={() => setEditingBrief(true)}
onClassify={classifyConcept}
onLocate={focusEvidence}
/>}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate!.slice(0, 10)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
<div className="board-actions">
@@ -263,6 +292,27 @@ export function App() {
onClose={() => setEditingEventId(null)}
onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
/>}
{editingPartyId && <PartyEditor
key={editingPartyId}
party={caseState.evidence.find(item => item.id === editingPartyId)!}
evidence={caseState.evidence}
documents={caseState.documents}
onClose={() => setEditingPartyId(null)}
onSave={party => {
update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) }))
setEditingPartyId(null)
setStatus('PARTY DOSSIER UPDATED')
}}
/>}
{editingBrief && <BriefEditor
brief={caseState.brief}
onClose={() => setEditingBrief(false)}
onSave={brief => {
update(state => ({ ...state, brief }))
setEditingBrief(false)
setStatus('LEVEL BRIEF UPDATED')
}}
/>}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
</main>
}
@@ -282,7 +332,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, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void }) {
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
@@ -339,6 +389,20 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
return [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
</svg>
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => {
const evidence = byId.get(evidenceId)
let target = evidence ? connectionPoint(evidence) : undefined
if (!target) {
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
}
if (!target) return []
const origin = connectionPoint(party)
return [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
</svg>
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
</svg>
@@ -347,7 +411,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag() }}
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent}/>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
</article>})}
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.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={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
onPointerDown={event => { event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
@@ -411,6 +475,53 @@ function localDateTime(value?: string) {
return local.toISOString().slice(0, 16)
}
function BriefPanel({ brief, parties, canEdit, onClose, onEdit, onClassify, onLocate }: { brief: LevelBrief; parties: Evidence[]; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void }) {
const partyById = new Map(parties.map(party => [party.id, party]))
return <aside className="brief-panel"><header><div><small>LEVEL BRIEF</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={resolved ? 'resolved' : ''} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <button className="resolved-party" onClick={() => onLocate(resolved.id)}>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()} · LOCATE</button> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
</aside>
}
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
const [body, setBody] = useState(brief.body)
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
<div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
<div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
<p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE BRIEF</button></div>
</div>
</form></div>
}
function PartyEditor({ party, evidence, documents, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (party: Evidence) => void }) {
const [name, setName] = useState(party.title)
const [summary, setSummary] = useState(party.content)
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
const [related, setRelated] = useState(party.relatedEvidenceIds || [])
const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))]
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, title: name.trim() || party.title, content: summary.trim(), organizationKind: party.partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}>
<header>{party.partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>Edit {party.partyKind} dossier</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>PARTY EXHIBIT · {party.partyKind?.toUpperCase()}</small>
<label className="field"><span>DISPLAY NAME</span><input aria-label="Party name" value={name} onChange={event => setName(event.target.value)}/></label>
<label className="field"><span>DOSSIER SUMMARY</span><textarea aria-label="Party summary" rows={3} value={summary} onChange={event => setSummary(event.target.value)}/></label>
{party.partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
<label className="field"><span>ALIASES · ONE PER LINE</span><textarea aria-label="Party aliases" rows={2} value={aliases} onChange={event => setAliases(event.target.value)}/></label>
<div className="folder-members-heading"><div><b>ASSOCIATED EVIDENCE</b><small>{related.length} LINKED</small></div><span>PARTY DOSSIER</span></div>
<div className="folder-members">{candidates.map(candidate => { const included = related.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'ASSOCIATED' : 'NOT LINKED'}</span></div> })}</div>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE DOSSIER</button></div>
</div>
</form></div>
}
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, members: string[]) => void }) {
const [title, setTitle] = useState(folder.title)
const [content, setContent] = useState(folder.content)
+1
View File
@@ -27,6 +27,7 @@ const folder: Evidence = {
}
const state: CaseState = {
brief: { body: '', concepts: [] },
id: 'test-level',
title: 'Test',
subtitle: '',
+1
View File
@@ -79,6 +79,7 @@ export function normalizeCase(state: CaseState): CaseState {
const normalized = { ...state, relations }
return {
...normalized,
brief: state.brief || { body: '', concepts: [] },
documents: state.documents.map(document => ({
...document,
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'),
+1 -1
View File
@@ -4,7 +4,7 @@ import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetReg
describe('frontend exhibit registry', () => {
it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => {
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event']
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event', 'party']
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort())
expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder'))
expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED')
+12 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react'
import { BookOpen, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil } from 'lucide-react'
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
import { folderIsOpen } from './boardDomain'
@@ -10,6 +10,7 @@ export type ExhibitWidgetProps = {
onToggleFolder: (id: string) => void
onEditFolder: (id: string) => void
onEditEvent: (id: string) => void
onEditParty: (id: string) => void
}
export type ExhibitWidgetDefinition = {
@@ -41,6 +42,15 @@ function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
</div>
}
function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) {
const person = exhibit.partyKind === 'person'
return <div className="card-content"><div className="party-identity">{person ? <UserRound size={28}/> : <Building2 size={28}/>}<div><h3>{exhibit.title}</h3><small>{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_', ' ').toUpperCase()}</small></div></div>
<p>{exhibit.content || 'No dossier summary yet.'}</p>
{(exhibit.aliases?.length || 0) > 0 && <div className="party-aliases">AKA · {exhibit.aliases!.join(' · ')}</div>}
<div className="event-actions"><span>{exhibit.relatedEvidenceIds?.length || 0} ASSOCIATED EXHIBITS</span><button onClick={() => onEditParty(exhibit.id)}><Pencil size={12}/> EDIT DOSSIER</button></div>
</div>
}
const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 })
const folderDefinition: ExhibitWidgetDefinition = {
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`,
@@ -52,6 +62,7 @@ export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition
evidence: folderDefinition,
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget },
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget },
party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget },
}
export function exhibitWidget(type: EvidenceType) {
+16
View File
@@ -54,6 +54,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.party-association-lines line { stroke: #6fa694; stroke-width: 2; stroke-dasharray: 3 5; opacity: .52; filter: drop-shadow(1px 1px 0 #020907); }
.folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; }
.folder-bands line.closed { opacity: 0; }
@@ -77,6 +79,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.event-actions { clear: both; margin-top: 10px; padding-top: 7px; border-top: 1px dashed #858c84; display: flex; align-items: center; justify-content: space-between; }
.event-actions span { color: #6a736d; font: 7px IBM Plex Mono; }
.event-actions button { float: none; }
.evidence-card.party { min-height: 190px; background: linear-gradient(110deg, #d9d7c9, #c8c8bd); border-top: 5px solid #315a50; }
.evidence-card.party h3 { margin: 0 0 3px; color: #254d43; }
.party-identity { display: grid; grid-template-columns: 34px 1fr; gap: 8px; align-items: center; padding: 11px 0 8px; border-bottom: 1px solid #929a92; }
.party-identity > svg { color: #315a50; }
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -123,6 +132,13 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.story-strip time { grid-row: 1 / 3; color: #7b938b; font: 7px IBM Plex Mono; }
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
.brief-panel { position: absolute; z-index: 12; right: 18px; top: 18px; width: min(410px, 42vw); max-height: calc(100% - 36px); overflow: auto; background: #c6c9c1; color: #17231f; border: 2px solid #d8dbd4; box-shadow: 7px 9px 0 #020a08, 0 0 0 1px #46554f; }
.brief-panel > header { height: 44px; padding: 0 8px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; }
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { margin-left: auto; width: 25px; height: 24px; display: grid; place-items: center; }
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
.classify-actions { display: flex; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-party, .edit-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-party { margin-top: 8px; background: #bfcfbe; }.edit-brief { margin: 12px 15px; background: #234c41; color: white; }
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
+18 -1
View File
@@ -1,4 +1,6 @@
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event'
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event' | 'party'
export type PartyKind = 'person' | 'organization'
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
export interface DocumentRegion {
@@ -33,6 +35,10 @@ export interface Evidence {
sourceRegionId?: string
eventDate?: string
supportingEvidenceIds?: string[]
partyKind?: PartyKind
organizationKind?: OrganizationKind
aliases?: string[]
relatedEvidenceIds?: string[]
x: number
y: number
width: number
@@ -58,6 +64,16 @@ export interface Connection {
export interface Viewport { x: number; y: number; zoom: number }
export interface BriefConcept {
id: string
label: string
context: string
expectedPartyKind?: PartyKind
resolvedPartyExhibitId?: string
}
export interface LevelBrief { body: string; concepts: BriefConcept[] }
export interface CaseState {
id: string
title: string
@@ -67,6 +83,7 @@ export interface CaseState {
relations: WidgetRelation[]
connections: Connection[]
viewport: Viewport
brief: LevelBrief
updatedAt?: string
levelStatus?: string
sourceTemplateVersionId?: string