Refine board notes, clipping evidence, and case reports
This commit is contained in:
+62
-31
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, 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'
|
||||
@@ -28,6 +28,12 @@ function documentSearchText(document: CaseDocument) {
|
||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
||||
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
||||
}
|
||||
function clippingInsetRotation(id:string) {
|
||||
let hash=0
|
||||
for (const character of id) hash=(hash * 31 + character.charCodeAt(0)) >>> 0
|
||||
const degrees=((hash % 49) - 24) / 10
|
||||
return degrees === 0 ? .7 : degrees
|
||||
}
|
||||
function connectionPoint(item: Exhibit) {
|
||||
return exhibitWidget(item.type).connectionPorts(item)[0]
|
||||
}
|
||||
@@ -214,12 +220,14 @@ export function App() {
|
||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
}
|
||||
|
||||
const addNote = (preset?: string) => {
|
||||
const addNote = (preset?: string, presentation:NotePresentation = 'luggage') => {
|
||||
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
|
||||
if (!content || !caseState) return
|
||||
const { viewport } = caseState
|
||||
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) }
|
||||
const size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 }
|
||||
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width:size.width })
|
||||
const note: Evidence = { id: uid('note'), type:'note', title:presentation === 'lined_sheet' ? 'FIELD NOTE' : 'WORKING NOTE', content,presentation,
|
||||
...placement(position.x,position.y,size.width,size.height) }
|
||||
update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
||||
}
|
||||
|
||||
@@ -439,7 +447,10 @@ export function App() {
|
||||
|
||||
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||||
const presentation=documentCapture(captureKind)
|
||||
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height })
|
||||
const initialClipRotation=Math.round((Math.random() * 20 - 10) * 10) / 10
|
||||
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height,
|
||||
title:captureKind === 'clipping' && document.captureKind === 'unclassified' && document.title === document.fileName ? '' : document.title,
|
||||
rotation:captureKind === 'clipping' && document.captureKind === 'unclassified' ? initialClipRotation : document.rotation })
|
||||
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
||||
? classify(exhibit)
|
||||
: exhibit) }))
|
||||
@@ -529,12 +540,16 @@ export function App() {
|
||||
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
|
||||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
|
||||
const widget=exhibitWidget(exhibit.type)
|
||||
if (!widget) throw new Error(`No widget is registered for exhibit type “${String(exhibit.type)}”`)
|
||||
return widget.temporalFacts(exhibit).map(fact => {
|
||||
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
|
||||
const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
|
||||
const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
|
||||
return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
|
||||
})).sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
||||
})
|
||||
}).sort((a, b) => dateValue(a.date) - dateValue(b.date))
|
||||
const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
|
||||
if (!a.eventDate) return b.eventDate ? 1 : 0
|
||||
if (!b.eventDate) return -1
|
||||
@@ -544,7 +559,7 @@ export function App() {
|
||||
return <main className="desktop">
|
||||
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||||
onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||
onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||
</Suspense>}
|
||||
<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>
|
||||
@@ -949,20 +964,21 @@ 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; 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 }}
|
||||
{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)); const notePresentation=ev.type === 'note' ? ev.presentation === 'lined_sheet' ? 'lined-sheet' : 'luggage-tag' : ''; 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} ${notePresentation} ${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 presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); 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}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${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 }}
|
||||
{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 presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); 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}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${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,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
||||
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>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : <strong>{document.title}</strong>}<time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||||
<div className="source-file-footer"><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div></div>
|
||||
</article> })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1088,6 +1104,15 @@ function localDateTime(value?: string) {
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function publishedDateParts(value?: string) {
|
||||
if (!value) return { date:'',time:'' }
|
||||
const parsed=new Date(value)
|
||||
if (!Number.isFinite(parsed.getTime())) return { date:'',time:'' }
|
||||
const iso=parsed.toISOString()
|
||||
const time=iso.slice(11,16)
|
||||
return { date:iso.slice(0,10),time:time === '00:00' ? '' : time }
|
||||
}
|
||||
|
||||
function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
|
||||
const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
|
||||
const [start, setStart] = useState(range?.start || dated[0] || '')
|
||||
@@ -1106,21 +1131,15 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
||||
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
||||
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
||||
}) {
|
||||
const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator)
|
||||
const [evidence,setEvidence]=useState<CaseReportSubmissionInput['evidence']>(() => report.claims.flatMap(claim => claim.evidence.map(item => ({
|
||||
connectionId:item.connectionId,documentExhibitId:item.documentExhibitId,relationText:item.relationText || 'Proof that…',
|
||||
publishedAt:item.publishedAt?.slice(0,10),sourceCitation:item.sourceCitation,sourceUri:item.sourceUri,
|
||||
}))))
|
||||
const investigatorName=report.investigatorName || defaultInvestigator
|
||||
const [busy,setBusy]=useState(false)
|
||||
const [error,setError]=useState('')
|
||||
const updateEvidence=(connectionId:string,patch:Partial<CaseReportSubmissionInput['evidence'][number]>) => setEvidence(items => items.map(item => item.connectionId === connectionId ? { ...item,...patch } : item))
|
||||
const submit=async (event:React.FormEvent) => {
|
||||
event.preventDefault();setBusy(true);setError('')
|
||||
try { await onSubmit({ investigatorName,evidence }) }
|
||||
try { await onSubmit({ investigatorName }) }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const draftByConnection=new Map(evidence.map(item => [item.connectionId,item]))
|
||||
return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
|
||||
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI–7 / EVIDENTIARY FINDING</span></div>
|
||||
@@ -1128,16 +1147,16 @@ function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubm
|
||||
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||||
<h4>EVIDENCE</h4>
|
||||
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
||||
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
||||
: claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
|
||||
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted ? <em>CONTENT VERIFIED</em> : rejected ? <em className="rejected">NOT VERIFIED</em> : null}</div>
|
||||
{rejected && <p className="report-evidence-diagnostic">{item.verification.detail}</p>}
|
||||
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
|
||||
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
|
||||
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
|
||||
<label><span>SOURCE LINK · IF AVAILABLE</span><input aria-label={`Source link for Exhibit ${item.displayNumber}`} type="url" value={draft.sourceUri || ''} placeholder="https://…" onChange={event => updateEvidence(item.connectionId,{ sourceUri:event.target.value })}/></label>
|
||||
<div className="report-reference"><span>EVIDENTIARY STATEMENT</span><p>{item.relationText || 'No evidentiary statement attached.'}</p></div>
|
||||
<div className="report-fields"><div className="report-reference"><span>DATED</span><p>{item.publishedAt?.slice(0,10) || 'NOT RECORDED'}</p></div>
|
||||
<div className="report-reference"><span>SOURCE / PUBLICATION</span><p>{item.sourceCitation || 'NOT RECORDED'}</p></div></div>
|
||||
<div className="report-reference"><span>SOURCE LINK · IF AVAILABLE</span>{item.sourceUri ? <a href={item.sourceUri} target="_blank" rel="noreferrer">{item.sourceUri}</a> : <p>NOT RECORDED</p>}</div>
|
||||
</article> })}
|
||||
</section>)}
|
||||
<label className="report-investigator"><span>INVESTIGATOR</span><input aria-label="Investigator name" value={investigatorName} maxLength={300} onChange={event => setInvestigatorName(event.target.value)}/></label>
|
||||
<div className="report-investigator report-reference"><span>INVESTIGATOR</span><p>{investigatorName}</p></div>
|
||||
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
|
||||
{error && <p className="flag-error">{error}</p>}
|
||||
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
||||
@@ -1324,14 +1343,19 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||
const [publishedDate,setPublishedDate]=useState(() => publishedDateParts(document.publishedAt).date)
|
||||
const [publishedTime,setPublishedTime]=useState(() => publishedDateParts(document.publishedAt).time)
|
||||
const [sourceCitation,setSourceCitation]=useState(document.sourceCitation || '')
|
||||
const [sourceUri,setSourceUri]=useState(document.sourceUri || '')
|
||||
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
|
||||
const publishedAt=publishedDate ? `${publishedDate}T${publishedTime || '00:00'}:00.000Z` : undefined
|
||||
const presentation=documentCapture(captureKind)
|
||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt,
|
||||
const resolvedTitle=title.trim() || (captureKind === 'clipping' ? sourceCitation.trim() : document.fileName || 'UNTITLED FILE')
|
||||
onSave({ ...document,title:resolvedTitle,fileType,captureKind,publishedAt,
|
||||
sourceCitation:sourceCitation.trim() || undefined,sourceUri:sourceUri.trim() || undefined,
|
||||
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||||
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])) })
|
||||
@@ -1341,11 +1365,18 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
||||
<div className="file-editor-body">
|
||||
<small>SOURCE FILE WIDGET</small>
|
||||
<div className="file-editor-grid">
|
||||
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
||||
<label className="field"><span>TITLE · DEFAULTS TO SOURCE FOR CLIPS</span><input aria-label="Document title" value={title} placeholder={captureKind === 'clipping' ? 'Uses Source / Publication when blank' : ''} onChange={event => setTitle(event.target.value)}/></label>
|
||||
<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>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
|
||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||
<div className="file-editor-grid published-fields">
|
||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED DATE</span><input aria-label="Published date" type="date" value={publishedDate} onChange={event => { setPublishedDate(event.target.value); if (!event.target.value) setPublishedTime('') }}/></label>
|
||||
<label className="field"><span>TIME · OPTIONAL · UTC</span><input aria-label="Published time" type="time" step="60" disabled={!publishedDate} value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||
</div>
|
||||
<div className="file-editor-grid">
|
||||
<label className="field"><span>SOURCE / PUBLICATION</span><input aria-label="Source publication" value={sourceCitation} maxLength={1000} placeholder="e.g. Google Patents · GB695913A" onChange={event => setSourceCitation(event.target.value)}/></label>
|
||||
<label className="field"><span>SOURCE URL</span><input aria-label="Source URL" type="url" value={sourceUri} maxLength={2000} placeholder="https://…" onChange={event => setSourceUri(event.target.value)}/></label>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user