test: add board and postgres safety net
This commit is contained in:
+9
-41
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, 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 { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
|
||||
|
||||
const BOARD_W = 2400
|
||||
const BOARD_H = 1500
|
||||
@@ -17,30 +18,6 @@ function connectionPoint(item: Evidence) {
|
||||
|
||||
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
|
||||
|
||||
function normalizeCase(state: CaseState): CaseState {
|
||||
const relations = Array.isArray(state.relations) ? state.relations : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
|
||||
const membership = new Map<string, string[]>()
|
||||
for (const relation of relations.filter(relation => relation.type === 'contains').sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))) membership.set(relation.fromWidgetId, [...(membership.get(relation.fromWidgetId) || []), relation.toWidgetId])
|
||||
return { ...state, relations,
|
||||
documents: state.documents.map(document => ({ ...document, fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), metadata: document.metadata || {} })),
|
||||
evidence: state.evidence.map(widget => ({ ...widget, type: widget.type === 'evidence' ? 'folder' : widget.type, config: widget.config || {}, containedDocumentIds: membership.get(widget.id) || [] })) }
|
||||
}
|
||||
|
||||
function containedIds(state: CaseState, widgetId: string) {
|
||||
return state.relations.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)).map(relation => relation.toWidgetId)
|
||||
}
|
||||
|
||||
function folderIsOpen(folder: Evidence) { return folder.config?.open === true }
|
||||
function relationPosition(state: CaseState, relation: WidgetRelation) {
|
||||
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
|
||||
const order = relation.sortOrder || 0
|
||||
const configuredX = Number(relation.config?.x), configuredY = Number(relation.config?.y)
|
||||
return {
|
||||
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
||||
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
||||
const [noLevels, setNoLevels] = useState(false)
|
||||
@@ -214,9 +191,9 @@ export function App() {
|
||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
||||
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
|
||||
<span />
|
||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
||||
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.min(1.5, s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
||||
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
||||
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
|
||||
</div>
|
||||
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
|
||||
@@ -259,7 +236,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, Math.min(1.5, s.viewport.zoom - event.deltaY * .006)) } }))
|
||||
update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } }))
|
||||
}
|
||||
board.addEventListener('wheel', handlePinch, { passive: false })
|
||||
return () => board.removeEventListener('wheel', handlePinch)
|
||||
@@ -276,9 +253,9 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
|
||||
if (!drag.current) return
|
||||
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
|
||||
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
|
||||
if (drag.current.kind === 'widget') update(s => ({ ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } : e) }))
|
||||
else if (drag.current.kind === 'relation') update(s => ({ ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } } : relation) }))
|
||||
else update(s => ({ ...s, viewport: { ...s.viewport, x: drag.current!.originX + dx, y: drag.current!.originY + dy } }))
|
||||
if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
|
||||
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
|
||||
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
||||
}
|
||||
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
|
||||
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
||||
@@ -350,19 +327,10 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey:
|
||||
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
|
||||
}
|
||||
|
||||
function dateValue(date: string) {
|
||||
const parsed = Date.parse(date)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
|
||||
const itemYears = items.map(item => Number(item.date.slice(0, 4))).filter(Number.isFinite)
|
||||
let startYear = itemYears.length ? Math.min(...itemYears) : new Date().getFullYear() - 2
|
||||
let endYear = itemYears.length ? Math.max(...itemYears) : startYear + 4
|
||||
if (endYear - startYear < 4) { const missing = 4 - (endYear - startYear); startYear -= Math.floor(missing / 2); endYear += Math.ceil(missing / 2) }
|
||||
const start = Date.UTC(startYear, 0, 1), end = Date.UTC(endYear, 11, 31)
|
||||
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date))
|
||||
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
|
||||
const position = (date: string) => Math.max(0, Math.min(100, ((dateValue(date) - start) / (end - start)) * 100))
|
||||
const position = (date: string) => timelinePositionPercent(date, { start, end })
|
||||
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)} — ${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user