Refine board notes, clipping evidence, and case reports
This commit is contained in:
@@ -2,11 +2,20 @@ import { expect, test } from '@playwright/test'
|
|||||||
|
|
||||||
test('pasting, connecting, and citing one patent screenshot completes the Scene 7 report lesson', async ({ page, request }) => {
|
test('pasting, connecting, and citing one patent screenshot completes the Scene 7 report lesson', async ({ page, request }) => {
|
||||||
test.setTimeout(60_000)
|
test.setTimeout(60_000)
|
||||||
|
const pageErrors:Error[]=[]
|
||||||
|
page.on('pageerror',error => pageErrors.push(error))
|
||||||
|
let sceneSeven:{ id:string } | undefined
|
||||||
|
await expect.poll(async () => {
|
||||||
const levels=await (await request.get('/api/levels')).json() as { id:string }[]
|
const levels=await (await request.get('/api/levels')).json() as { id:string }[]
|
||||||
const sceneSeven = levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-'))
|
sceneSeven=levels.find(level => level.id.startsWith('barricelli-inventor-proof-case-'))
|
||||||
|
return Boolean(sceneSeven)
|
||||||
|
},{ timeout:20_000,message:'Scene 7 playable clone should be seeded' }).toBe(true)
|
||||||
if (!sceneSeven) throw new Error('Scene 7 playable clone was not seeded')
|
if (!sceneSeven) throw new Error('Scene 7 playable clone was not seeded')
|
||||||
|
const sceneSevenId=sceneSeven.id
|
||||||
|
|
||||||
await page.goto(`/level/${sceneSeven.id}`)
|
await page.goto(`/level/${sceneSevenId}`)
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
if (pageErrors.length) throw new Error(`Scene 7 failed to render: ${pageErrors.map(error => error.message).join('; ')}`)
|
||||||
await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible()
|
await expect(page.getByRole('heading', { name:'The Barricelli Files' })).toBeVisible()
|
||||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||||
await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.brief-goals')).toContainText('Prove Nils Aall Barricelli was an inventor')
|
||||||
@@ -14,7 +23,7 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click()
|
await page.getByRole('button', { name:'BEGIN INVESTIGATION',exact:true }).click()
|
||||||
|
|
||||||
const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST'
|
const uploadResponse = page.waitForResponse(response => response.request().method() === 'POST'
|
||||||
&& response.url().includes(`/api/levels/${sceneSeven.id}/documents`) && response.status() === 201)
|
&& response.url().includes(`/api/levels/${sceneSevenId}/documents`) && response.status() === 201)
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = 1280
|
canvas.width = 1280
|
||||||
@@ -46,6 +55,7 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
await page.getByRole('button',{ name:'Classify as Clip' }).click()
|
await page.getByRole('button',{ name:'Classify as Clip' }).click()
|
||||||
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
||||||
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
||||||
|
await expect(page.locator('.source-file-widget > strong')).toHaveCount(0)
|
||||||
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
||||||
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
||||||
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||||
@@ -60,35 +70,47 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
|||||||
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
||||||
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
||||||
await expect(page.getByLabel('Board presentation')).toHaveValue('clipping')
|
await expect(page.getByLabel('Board presentation')).toHaveValue('clipping')
|
||||||
|
await expect(page.getByLabel('Document title')).toHaveValue('')
|
||||||
|
await page.getByLabel('Published date').fill('1953-08-19')
|
||||||
|
await expect(page.getByLabel('Published time')).toHaveValue('')
|
||||||
|
await page.getByLabel('Source publication').fill('Google Patents · GB695913A')
|
||||||
|
await page.getByLabel('Source URL').fill('https://patents.google.com/patent/GB695913A/en')
|
||||||
|
await page.getByRole('button',{ name:'SAVE METADATA' }).click()
|
||||||
|
await expect(page.locator('.file-editor')).toHaveCount(0)
|
||||||
|
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
||||||
|
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
||||||
|
await expect(page.getByLabel('Document title')).toHaveValue('Google Patents · GB695913A')
|
||||||
|
await expect(page.getByLabel('Source publication')).toHaveValue('Google Patents · GB695913A')
|
||||||
await page.getByRole('button',{ name:'Close file editor' }).click()
|
await page.getByRole('button',{ name:'Close file editor' }).click()
|
||||||
await page.getByRole('button',{ name:'Close document' }).click()
|
await page.getByRole('button',{ name:'Close document',exact:true }).click()
|
||||||
|
|
||||||
await page.locator('.evidence-card.claim').click()
|
await page.locator('.evidence-card.claim').click()
|
||||||
await page.getByRole('button',{ name:'Red thread' }).click()
|
await page.getByRole('button',{ name:'Red thread' }).click()
|
||||||
await page.locator('.source-file-widget').click()
|
await page.locator('.source-file-widget').click()
|
||||||
await expect(page.locator('.thread-editor')).toBeVisible()
|
await expect(page.locator('.thread-editor')).toBeVisible()
|
||||||
await expect(page.getByLabel('Thread tag')).toHaveValue('Proof that…')
|
await expect(page.getByLabel('Thread tag')).toHaveValue('Proof that…')
|
||||||
|
await page.getByLabel('Thread tag').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
|
||||||
await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click()
|
await page.getByRole('button',{ name:'ADD TAG & TIGHTEN' }).click()
|
||||||
|
|
||||||
await page.getByRole('button',{ name:'Case report' }).click()
|
await page.getByRole('button',{ name:'Case report' }).click()
|
||||||
await expect(page.locator('.case-report')).toBeVisible()
|
await expect(page.locator('.case-report')).toBeVisible()
|
||||||
await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor')
|
await expect(page.locator('.report-claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||||
await expect(page.locator('.report-evidence')).toContainText('Exhibit 1')
|
await expect(page.locator('.report-evidence')).toContainText('Exhibit 1')
|
||||||
await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click()
|
await expect(page.locator('.report-evidence')).toContainText('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
|
||||||
await expect(page.locator('.report-verdict')).toContainText("The evidence is good enough, but the report itself won't hold up in court")
|
await expect(page.locator('.report-evidence')).toContainText('1953-08-19')
|
||||||
|
await expect(page.locator('.report-evidence')).toContainText('Google Patents · GB695913A')
|
||||||
await page.getByLabel('Evidence statement for Exhibit 1').fill('Proof that Nils Aall Barricelli is named as inventor on patent GB695913A.')
|
await expect(page.locator('.report-evidence a')).toHaveAttribute('href','https://patents.google.com/patent/GB695913A/en')
|
||||||
await page.getByLabel('Date for Exhibit 1').fill('1953-08-19')
|
|
||||||
await page.getByLabel('Source for Exhibit 1').fill('Google Patents · GB695913A')
|
|
||||||
await page.getByLabel('Source link for Exhibit 1').fill('https://patents.google.com/patent/GB695913A/en')
|
|
||||||
await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click()
|
await page.getByRole('button',{ name:'SUBMIT CASE REPORT' }).click()
|
||||||
await expect(page.locator('.report-verdict')).toContainText('Case report accepted')
|
await expect(page.locator('.report-verdict')).toContainText('Case report accepted')
|
||||||
await expect(page.getByRole('button',{ name:/CLOSE CASE/ })).toBeVisible()
|
await expect(page.getByRole('button',{ name:/CLOSE CASE/ })).toBeVisible()
|
||||||
|
|
||||||
const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json()
|
const level = await (await request.get(`/api/levels/${sceneSevenId}`)).json()
|
||||||
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
||||||
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toEqual([
|
const savedDocuments=level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')
|
||||||
expect.objectContaining({ captureKind:'clipping',width:210,height:194 }),
|
expect(savedDocuments).toEqual([
|
||||||
|
expect.objectContaining({ title:'Google Patents · GB695913A',captureKind:'clipping',width:230,height:290 }),
|
||||||
])
|
])
|
||||||
|
expect(savedDocuments[0].rotation).toBeGreaterThanOrEqual(-10)
|
||||||
|
expect(savedDocuments[0].rotation).toBeLessThanOrEqual(10)
|
||||||
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE osint.note_exhibits
|
||||||
|
ADD COLUMN presentation_kind TEXT NOT NULL DEFAULT 'luggage'
|
||||||
|
CHECK (presentation_kind IN ('luggage', 'lined_sheet'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN osint.note_exhibits.presentation_kind IS
|
||||||
|
'Visual presentation of a note exhibit. Notebook tear-outs use lined_sheet; ordinary working notes use luggage.';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET width = 220,
|
||||||
|
height = 272,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND exhibit.width = 210
|
||||||
|
AND exhibit.height = 194;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
UPDATE osint.document_exhibits AS document
|
||||||
|
SET title = document.citation_text
|
||||||
|
WHERE document.capture_kind_id = 'clipping'
|
||||||
|
AND BTRIM(document.citation_text) <> ''
|
||||||
|
AND (
|
||||||
|
BTRIM(document.title) = ''
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM osint.assets AS asset
|
||||||
|
WHERE asset.id = document.asset_id
|
||||||
|
AND document.title = asset.original_name
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET height = 220,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND exhibit.width = 220
|
||||||
|
AND exhibit.height = 272;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET width = 220,
|
||||||
|
height = 220,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND (exhibit.width <> 220 OR exhibit.height <> 220);
|
||||||
|
|
||||||
|
UPDATE osint.document_exhibits AS document
|
||||||
|
SET title = document.citation_text
|
||||||
|
WHERE document.capture_kind_id = 'clipping'
|
||||||
|
AND BTRIM(document.citation_text) <> ''
|
||||||
|
AND (
|
||||||
|
BTRIM(document.title) = ''
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM osint.assets AS asset
|
||||||
|
WHERE asset.id = document.asset_id
|
||||||
|
AND document.title = asset.original_name
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
UPDATE osint.exhibits AS exhibit
|
||||||
|
SET width = 230,
|
||||||
|
height = 290,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM osint.document_exhibits AS document
|
||||||
|
WHERE document.exhibit_id = exhibit.id
|
||||||
|
AND document.capture_kind_id = 'clipping'
|
||||||
|
AND (exhibit.width <> 230 OR exhibit.height <> 290);
|
||||||
@@ -104,7 +104,7 @@ suite('normalized level persistence API', () => {
|
|||||||
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
|
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
|
||||||
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
|
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
|
||||||
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
||||||
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
const note: NoteExhibit = { id:randomUUID(),type:'note',title:'Extract',content:'Date matters',presentation:'lined_sheet',...placed(420,300,220,270) }
|
||||||
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
||||||
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
|
||||||
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
state.exhibits = [document, gatedDocument, folder, note, event, party]
|
||||||
@@ -123,6 +123,7 @@ suite('normalized level persistence API', () => {
|
|||||||
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||||
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
||||||
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
||||||
|
expect(loaded.exhibits.find(item => item.id === note.id)).toMatchObject({ presentation:'lined_sheet',width:220,height:270 })
|
||||||
expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
|
expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
|
||||||
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
||||||
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
||||||
@@ -234,6 +235,7 @@ suite('normalized level persistence API', () => {
|
|||||||
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
|
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
|
||||||
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
|
||||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
||||||
|
expect(clone.exhibits.find(item => item.type === 'note')).toMatchObject({ presentation:'lined_sheet',width:220,height:270 })
|
||||||
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
||||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||||
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
|
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
|
||||||
@@ -330,7 +332,7 @@ suite('normalized level persistence API', () => {
|
|||||||
relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200)
|
expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200)
|
||||||
const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId:relatedConnectionId,documentExhibitId:fatherDocument.id,relationText:'Proof that the Barricelli family included an inventor.' }],
|
investigatorName:'Test Player',
|
||||||
}) })).json()
|
}) })).json()
|
||||||
expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']),
|
expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']),
|
||||||
feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({
|
||||||
@@ -350,14 +352,24 @@ suite('normalized level persistence API', () => {
|
|||||||
reportState.connections.push({ id:connectionId,fromExhibitId:claim.id,toExhibitId:targetDocument.id,label:'Proof that…',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
reportState.connections.push({ id:connectionId,fromExhibitId:claim.id,toExhibitId:targetDocument.id,label:'Proof that…',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
||||||
const incomplete=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const incomplete=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Proof that…' }],
|
investigatorName:'Test Player',
|
||||||
|
// Legacy/forged report fields must not mutate board-owned provenance.
|
||||||
|
evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Forged complete statement',publishedAt:'1953-08-19',sourceCitation:'Forged source' }],
|
||||||
}) })
|
}) })
|
||||||
expect(incomplete.status).toBe(201)
|
expect(incomplete.status).toBe(201)
|
||||||
expect(await incomplete.json()).toMatchObject({ status:'evidence_accepted_report_incomplete',issues:expect.arrayContaining(['unfinished_relation','missing_date','missing_source']),
|
expect(await incomplete.json()).toMatchObject({ status:'evidence_accepted_report_incomplete',issues:expect.arrayContaining(['unfinished_relation','missing_date','missing_source']),
|
||||||
feedback:"The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it." })
|
feedback:"The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it." })
|
||||||
|
const targetOnBoard=reportState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === targetDocument.id)
|
||||||
|
if (!targetOnBoard || targetOnBoard.type !== 'document') throw new Error('Target document missing from board')
|
||||||
|
targetOnBoard.publishedAt='1953-08-19T00:00:00.000Z'
|
||||||
|
targetOnBoard.sourceCitation='Google Patents · GB695913A'
|
||||||
|
targetOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en'
|
||||||
|
const targetConnection=reportState.connections.find(connection => connection.id === connectionId)
|
||||||
|
if (!targetConnection) throw new Error('Target connection missing from board')
|
||||||
|
targetConnection.label='Proof that Barricelli is named as the inventor on patent GB695913A.'
|
||||||
|
expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
|
||||||
const accepted=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const accepted=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Proof that Barricelli is named as the inventor on patent GB695913A.',
|
investigatorName:'Test Player',
|
||||||
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
|
||||||
}) })
|
}) })
|
||||||
expect(accepted.status).toBe(201)
|
expect(accepted.status).toBe(201)
|
||||||
const acceptedBody=await accepted.json()
|
const acceptedBody=await accepted.json()
|
||||||
@@ -376,10 +388,14 @@ suite('normalized level persistence API', () => {
|
|||||||
const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState
|
const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState
|
||||||
const repeatedConnectionId=randomUUID()
|
const repeatedConnectionId=randomUUID()
|
||||||
repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
|
||||||
|
const repeatedOnBoard=repeatedState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === repeatedDocument.id)
|
||||||
|
if (!repeatedOnBoard || repeatedOnBoard.type !== 'document') throw new Error('Repeated document missing from board')
|
||||||
|
repeatedOnBoard.publishedAt='1953-08-19T00:00:00.000Z'
|
||||||
|
repeatedOnBoard.sourceCitation='Google Patents · GB695913A'
|
||||||
|
repeatedOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en'
|
||||||
expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200)
|
expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200)
|
||||||
const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
|
||||||
investigatorName:'Test Player',evidence:[{ connectionId:repeatedConnectionId,documentExhibitId:repeatedDocument.id,relationText:'Proof that Barricelli is named as the inventor on patent GB695913A.',
|
investigatorName:'Test Player',
|
||||||
publishedAt:'1953-08-19',sourceCitation:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en' }],
|
|
||||||
}) })).json()
|
}) })).json()
|
||||||
expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({
|
expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({
|
||||||
documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }),
|
documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }),
|
||||||
|
|||||||
@@ -140,10 +140,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
|||||||
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
|
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
|
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
|
||||||
|
|
||||||
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string }>(
|
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string; presentation_kind:'luggage'|'lined_sheet' }>(
|
||||||
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||||
for (const row of notes.rows) await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)',
|
for (const row of notes.rows) await client.query(
|
||||||
[mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text])
|
'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)',
|
||||||
|
[mapped(exhibitIds,row.exhibit_id,'note'),row.title,row.note_text,row.presentation_kind])
|
||||||
|
|
||||||
const claims = await client.query<{ exhibit_id:string;statement:string }>(
|
const claims = await client.query<{ exhibit_id:string;statement:string }>(
|
||||||
`SELECT claim.exhibit_id,claim.statement FROM osint.claim_exhibits claim
|
`SELECT claim.exhibit_id,claim.statement FROM osint.claim_exhibits claim
|
||||||
|
|||||||
@@ -5,18 +5,6 @@ import type { CaseReport, CaseReportEvidence, CaseReportSubmissionInput, CaseRep
|
|||||||
type LevelRef = { id:string; board_id:string }
|
type LevelRef = { id:string; board_id:string }
|
||||||
|
|
||||||
function trimmed(value: unknown, max: number) { return String(value || '').trim().slice(0,max) }
|
function trimmed(value: unknown, max: number) { return String(value || '').trim().slice(0,max) }
|
||||||
function optionalUrl(value: unknown) {
|
|
||||||
const candidate = trimmed(value,2_000)
|
|
||||||
if (!candidate) return null
|
|
||||||
try { return new URL(candidate).toString() } catch { throw new Error('Source links must be absolute URLs') }
|
|
||||||
}
|
|
||||||
function optionalTimestamp(value: unknown) {
|
|
||||||
const candidate = trimmed(value,100)
|
|
||||||
if (!candidate) return null
|
|
||||||
const date = new Date(candidate)
|
|
||||||
if (!Number.isFinite(date.getTime())) throw new Error('Evidence dates must be valid dates')
|
|
||||||
return date.toISOString()
|
|
||||||
}
|
|
||||||
function unfinishedRelation(value: string) {
|
function unfinishedRelation(value: string) {
|
||||||
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
return !value.trim() || /^proof\s+that(?:\s*(?:…|\.{3}))?\s*$/iu.test(value.trim())
|
||||||
}
|
}
|
||||||
@@ -130,29 +118,6 @@ export async function submitCaseReport(pool: Pool, levelSlug: string, rawInput:
|
|||||||
const report = (await client.query<{ board_id:string }>('SELECT board_id FROM osint.case_reports WHERE board_id=$1 FOR UPDATE', [level.board_id])).rows[0]
|
const report = (await client.query<{ board_id:string }>('SELECT board_id FROM osint.case_reports WHERE board_id=$1 FOR UPDATE', [level.board_id])).rows[0]
|
||||||
if (!report) throw new Error('This level does not have a case report')
|
if (!report) throw new Error('This level does not have a case report')
|
||||||
const investigatorName = trimmed(rawInput?.investigatorName,300) || 'Player'
|
const investigatorName = trimmed(rawInput?.investigatorName,300) || 'Player'
|
||||||
const drafts = Array.isArray(rawInput?.evidence) ? rawInput.evidence.slice(0,100) : []
|
|
||||||
if (new Set(drafts.map(item => item.connectionId)).size !== drafts.length) throw new Error('A report cannot submit the same connection twice')
|
|
||||||
for (const draft of drafts) {
|
|
||||||
const relationText = trimmed(draft.relationText,2_000)
|
|
||||||
const sourceCitation = trimmed(draft.sourceCitation,1_000)
|
|
||||||
const sourceUri = optionalUrl(draft.sourceUri)
|
|
||||||
const publishedAt = optionalTimestamp(draft.publishedAt)
|
|
||||||
const owned = (await client.query<{ connection_id:string; document_id:string }>(`SELECT connection.id AS connection_id,document.exhibit_id AS document_id
|
|
||||||
FROM osint.exhibit_connections connection
|
|
||||||
JOIN osint.claim_exhibits claim ON claim.exhibit_id=CASE
|
|
||||||
WHEN connection.from_exhibit_id=$3 THEN connection.to_exhibit_id ELSE connection.from_exhibit_id END
|
|
||||||
JOIN osint.document_exhibits document ON document.exhibit_id=CASE
|
|
||||||
WHEN connection.from_exhibit_id=$3 THEN connection.from_exhibit_id ELSE connection.to_exhibit_id END
|
|
||||||
WHERE connection.id=$1 AND connection.board_id=$2
|
|
||||||
AND $3::uuid IN (connection.from_exhibit_id,connection.to_exhibit_id)`, [draft.connectionId,level.board_id,draft.documentExhibitId])).rows[0]
|
|
||||||
if (!owned || owned.document_id !== draft.documentExhibitId) throw new Error('Report evidence must reference a claim-to-document connection on this board')
|
|
||||||
await client.query('UPDATE osint.exhibit_connections SET label=$2 WHERE id=$1', [draft.connectionId,relationText || null])
|
|
||||||
await client.query(`UPDATE osint.document_exhibits SET published_at=$2,citation_text=$3,source_uri=$4 WHERE exhibit_id=$1`,
|
|
||||||
[draft.documentExhibitId,publishedAt,sourceCitation,sourceUri])
|
|
||||||
await client.query(`INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
|
|
||||||
SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1
|
|
||||||
ON CONFLICT (board_id,exhibit_id) DO NOTHING`, [level.board_id,draft.documentExhibitId])
|
|
||||||
}
|
|
||||||
await client.query('UPDATE osint.case_reports SET investigator_name=$2,updated_at=NOW() WHERE board_id=$1', [level.board_id,investigatorName])
|
await client.query('UPDATE osint.case_reports SET investigator_name=$2,updated_at=NOW() WHERE board_id=$1', [level.board_id,investigatorName])
|
||||||
const assembled = (await loadCaseReport(client,level))!
|
const assembled = (await loadCaseReport(client,level))!
|
||||||
const pendingGoals = Number((await client.query<{ count:string }>(`SELECT COUNT(*)::text AS count FROM osint.level_goals goal
|
const pendingGoals = Number((await client.query<{ count:string }>(`SELECT COUNT(*)::text AS count FROM osint.level_goals goal
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ app.post('/api/levels/:id/report/submissions', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const report = await submitCaseReport(pool,String(req.params.id),{
|
const report = await submitCaseReport(pool,String(req.params.id),{
|
||||||
investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)),
|
investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)),
|
||||||
evidence:Array.isArray(req.body?.evidence) ? req.body.evidence : [],
|
|
||||||
})
|
})
|
||||||
report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' })
|
report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type ExhibitRow = {
|
|||||||
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
||||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||||
source_document_id: string | null; source_region_key: string | null
|
source_document_id: string | null; source_region_key: string | null
|
||||||
|
note_presentation_kind: 'luggage' | 'lined_sheet' | null
|
||||||
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
|
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +353,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
|
||||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||||
f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
||||||
|
n.presentation_kind AS note_presentation_kind,
|
||||||
ev.occurred_at,claim.statement,
|
ev.occurred_at,claim.statement,
|
||||||
p.party_kind, op.organization_kind,
|
p.party_kind, op.organization_kind,
|
||||||
a.original_name, a.mime_type, a.byte_size,
|
a.original_name, a.mime_type, a.byte_size,
|
||||||
@@ -448,7 +450,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
|
||||||
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
|
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
|
||||||
else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
|
else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
|
||||||
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
|
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note', presentation:row.note_presentation_kind || 'luggage' })
|
||||||
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
|
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
|
||||||
}
|
}
|
||||||
const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
|
const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
|
||||||
@@ -525,18 +527,21 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
|
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
|
||||||
|
|
||||||
for (const exhibit of state.exhibits) {
|
for (const exhibit of state.exhibits) {
|
||||||
|
const clipping=exhibit.type === 'document' && documentCaptureKind(exhibit.captureKind) === 'clipping'
|
||||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
|
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
|
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
|
||||||
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
|
[exhibit.id,level.board_id,exhibit.type,exhibit.x,exhibit.y,clipping ? 230 : exhibit.width,clipping ? 290 : exhibit.height,exhibit.rotation,exhibit.zIndex,exhibit.hidden])
|
||||||
}
|
}
|
||||||
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
||||||
for (const document of documents) {
|
for (const document of documents) {
|
||||||
|
const clippingTitle=document.captureKind === 'clipping' && document.sourceCitation?.trim()
|
||||||
|
&& (!document.title.trim() || document.title === document.fileName) ? document.sourceCitation.trim() : document.title
|
||||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||||
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
||||||
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
|
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
|
||||||
[document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, document.title,
|
[document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, clippingTitle,
|
||||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
||||||
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
||||||
if (!existingCitation.rows[0]) {
|
if (!existingCitation.rows[0]) {
|
||||||
@@ -560,7 +565,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
|||||||
if (isFolderExhibit(exhibit)) await client.query(
|
if (isFolderExhibit(exhibit)) await client.query(
|
||||||
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
||||||
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
|
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
|
||||||
if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
|
if (exhibit.type === 'note') await client.query(
|
||||||
|
'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)',
|
||||||
|
[exhibit.id,exhibit.title,exhibit.content,exhibit.presentation === 'lined_sheet' ? 'lined_sheet' : 'luggage'])
|
||||||
if (isEventExhibit(exhibit)) await client.query(
|
if (isEventExhibit(exhibit)) await client.query(
|
||||||
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
|
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
|
||||||
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
|
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVi
|
|||||||
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
||||||
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed }
|
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed }
|
||||||
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
||||||
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
|
const note: NoteExhibit = { id:'note',type:'note',title:'Note',content:'',presentation:'luggage',...placed }
|
||||||
const state: CaseState = {
|
const state: CaseState = {
|
||||||
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
relations: [{ id: 'source', type: 'source', fromExhibitId: note.id, toExhibitId: gated.id, sortOrder: 0 }],
|
relations: [{ id: 'source', type: 'source', fromExhibitId: note.id, toExhibitId: gated.id, sortOrder: 0 }],
|
||||||
|
|||||||
+62
-31
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
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 { 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 { AdminPanel } from './admin'
|
||||||
import { audio } from './audio'
|
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 { 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]),
|
...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()
|
...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) {
|
function connectionPoint(item: Exhibit) {
|
||||||
return exhibitWidget(item.type).connectionPorts(item)[0]
|
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')
|
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()
|
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
|
||||||
if (!content || !caseState) return
|
if (!content || !caseState) return
|
||||||
const { viewport } = caseState
|
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 size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 }
|
||||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 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)
|
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 classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||||||
const presentation=documentCapture(captureKind)
|
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'
|
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
||||||
? classify(exhibit)
|
? classify(exhibit)
|
||||||
: exhibit) }))
|
: exhibit) }))
|
||||||
@@ -529,12 +540,16 @@ export function App() {
|
|||||||
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
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 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 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}`
|
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 }
|
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) => {
|
const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
|
||||||
if (!a.eventDate) return b.eventDate ? 1 : 0
|
if (!a.eventDate) return b.eventDate ? 1 : 0
|
||||||
if (!b.eventDate) return -1
|
if (!b.eventDate) return -1
|
||||||
@@ -544,7 +559,7 @@ export function App() {
|
|||||||
return <main className="desktop">
|
return <main className="desktop">
|
||||||
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||||
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
<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>}
|
</Suspense>}
|
||||||
<header className="menubar">
|
<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>
|
<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}>
|
<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}/> })}
|
{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>
|
</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 }) }}
|
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) }}
|
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) } }}>
|
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>
|
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||||
<Widget exhibit={ev} context={widgetContext}/>
|
<Widget exhibit={ev} context={widgetContext}/>
|
||||||
</article>})}
|
</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 }) }}
|
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)}>
|
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>
|
<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>
|
<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>
|
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||||||
<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 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> })}
|
</article> })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1088,6 +1104,15 @@ function localDateTime(value?: string) {
|
|||||||
return local.toISOString().slice(0, 16)
|
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 }) {
|
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 dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
|
||||||
const [start, setStart] = useState(range?.start || dated[0] || '')
|
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
|
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
||||||
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
||||||
}) {
|
}) {
|
||||||
const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator)
|
const investigatorName=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 [busy,setBusy]=useState(false)
|
const [busy,setBusy]=useState(false)
|
||||||
const [error,setError]=useState('')
|
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) => {
|
const submit=async (event:React.FormEvent) => {
|
||||||
event.preventDefault();setBusy(true);setError('')
|
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') }
|
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
|
||||||
finally { setBusy(false) }
|
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)}>
|
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>
|
<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>
|
<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>
|
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||||||
<h4>EVIDENCE</h4>
|
<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.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>
|
<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>}
|
{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-reference"><span>EVIDENTIARY STATEMENT</span><p>{item.relationText || 'No evidentiary statement attached.'}</p></div>
|
||||||
<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>
|
<div className="report-fields"><div className="report-reference"><span>DATED</span><p>{item.publishedAt?.slice(0,10) || 'NOT RECORDED'}</p></div>
|
||||||
<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>
|
<div className="report-reference"><span>SOURCE / PUBLICATION</span><p>{item.sourceCitation || 'NOT RECORDED'}</p></div></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>SOURCE LINK · IF AVAILABLE</span>{item.sourceUri ? <a href={item.sourceUri} target="_blank" rel="noreferrer">{item.sourceUri}</a> : <p>NOT RECORDED</p>}</div>
|
||||||
</article> })}
|
</article> })}
|
||||||
</section>)}
|
</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>}
|
{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>}
|
{error && <p className="flag-error">{error}</p>}
|
||||||
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
<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 [title, setTitle] = useState(document.title)
|
||||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||||
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
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 [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
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)
|
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 : {}),
|
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||||||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
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])) })
|
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">
|
<div className="file-editor-body">
|
||||||
<small>SOURCE FILE WIDGET</small>
|
<small>SOURCE FILE WIDGET</small>
|
||||||
<div className="file-editor-grid">
|
<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>
|
<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>
|
</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>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>}
|
{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-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>
|
<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>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ describe('folder domain behavior', () => {
|
|||||||
|
|
||||||
describe('exhibit disposal', () => {
|
describe('exhibit disposal', () => {
|
||||||
it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => {
|
it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => {
|
||||||
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
const note = { ...folder,id:'note-1',type:'note' as const,title:'Working note',presentation:'luggage' as const }
|
||||||
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
||||||
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
||||||
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
||||||
|
|||||||
+5
-2
@@ -1,7 +1,9 @@
|
|||||||
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, NotePresentation, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||||
|
|
||||||
export interface BoardPoint { x: number; y: number }
|
export interface BoardPoint { x: number; y: number }
|
||||||
|
|
||||||
|
function notePresentation(value: unknown): NotePresentation { return value === 'lined_sheet' ? 'lined_sheet' : 'luggage' }
|
||||||
|
|
||||||
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
||||||
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
|
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
|
||||||
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
||||||
@@ -250,6 +252,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
||||||
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
||||||
|
...(exhibit.type === 'note' ? { presentation: notePresentation(exhibit.presentation) } : {}),
|
||||||
...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
...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,
|
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||||
@@ -280,7 +283,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
|||||||
if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record<string, unknown> | undefined)?.open === true }
|
if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record<string, unknown> | undefined)?.open === true }
|
||||||
if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined }
|
if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined }
|
||||||
if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] }
|
if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] }
|
||||||
return { ...common, type: 'note' }
|
return { ...common, type: 'note', presentation:notePresentation(item.presentation) }
|
||||||
})
|
})
|
||||||
const derivedRelations: ExhibitRelation[] = [
|
const derivedRelations: ExhibitRelation[] = [
|
||||||
...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })),
|
...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })),
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition>
|
|||||||
...exhibit.regions.flatMap(region => region.date ? [{ id:`${exhibit.id}:region:${region.id}`,exhibitId:exhibit.id,kind:'region_date' as const,start:region.date,label:region.label }] : []),
|
...exhibit.regions.flatMap(region => region.date ? [{ id:`${exhibit.id}:region:${region.id}`,exhibitId:exhibit.id,kind:'region_date' as const,start:region.date,label:region.label }] : []),
|
||||||
] : [],searchText:searchable,Component:DocumentWidget },
|
] : [],searchText:searchable,Component:DocumentWidget },
|
||||||
note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities,
|
note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities,
|
||||||
heading:() => 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
|
heading:exhibit => exhibit.type === 'note' && exhibit.presentation === 'lined_sheet' ? 'FIELD NOTE / TORN PAGE' : 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget },
|
||||||
event: { modelKind:'exhibit',visualType:'event',shell:'card',defaultSize:{width:270,height:174},capabilities:standardCapabilities,
|
event: { modelKind:'exhibit',visualType:'event',shell:'card',defaultSize:{width:270,height:174},capabilities:standardCapabilities,
|
||||||
heading:() => 'EVENT / THIS HAPPENED',connectionPorts:standardPorts,temporalFacts:exhibit => exhibit.type === 'event' && exhibit.eventDate
|
heading:() => 'EVENT / THIS HAPPENED',connectionPorts:standardPorts,temporalFacts:exhibit => exhibit.type === 'event' && exhibit.eventDate
|
||||||
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
|
? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget },
|
||||||
@@ -144,7 +144,7 @@ export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureD
|
|||||||
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
||||||
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
|
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:250} },
|
||||||
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
||||||
clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} },
|
clipping:{ label:'Clip',description:'An extract mounted on a physical evidence card.',defaultSize:{width:230,height:290} },
|
||||||
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
||||||
}
|
}
|
||||||
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
||||||
|
|||||||
+41
-21
@@ -166,7 +166,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
|
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
|
||||||
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
|
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
|
||||||
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
|
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
|
||||||
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
.source-file-footer > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
||||||
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
||||||
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
||||||
|
|
||||||
@@ -214,32 +214,36 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
|
.mugshot-caption span { position: relative; display: block; min-height: 1.1em; overflow: hidden; color: #171b18; text-overflow: clip; white-space: nowrap; letter-spacing: .01em; }
|
||||||
.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
|
.mugshot-caption.writing span::after { content: ''; display: inline-block; width: 4px; height: 3px; margin-left: 1px; border-radius: 50%; background: #171b18; box-shadow: 0 0 2px #171b18; transform: rotate(-18deg); animation: sharpie-nib .12s steps(2,end) infinite; }
|
||||||
@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
|
@keyframes sharpie-nib { 50% { transform: translateY(-2px) rotate(-18deg);opacity:.72; } }
|
||||||
.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; }
|
.source-file-widget.capture-kind-photo .source-file-footer > time { padding-right: 3px; text-align: right; }
|
||||||
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
||||||
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
||||||
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
||||||
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
||||||
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
||||||
.source-file-widget.capture-kind-clipping { padding: 9px 12px 11px; background: linear-gradient(103deg,#e7e2d3,#d9d2bf); clip-path: polygon(1% 2%,99% 0,98% 13%,100% 28%,98% 43%,100% 61%,98% 78%,99% 98%,84% 99%,68% 97%,52% 100%,35% 98%,19% 100%,1% 98%,2% 79%,0 62%,2% 45%,0 27%); }
|
.source-file-widget.capture-kind-clipping { padding:14px 13px 10px;overflow:visible;background:linear-gradient(105deg,#e2dfcf,#d3d1c2);border:2px solid #b99a68;box-shadow:8px 10px 0 #020b0980,0 0 0 2px #526059; }
|
||||||
.source-file-widget.capture-kind-clipping.open { transform: scale(1) rotate(.8deg); }
|
.source-file-widget.capture-kind-clipping::after { content:'';position:absolute;z-index:5;top:-7px;left:50%;width:13px;height:13px;translate:-50% 0;border-radius:50%;background:#9e392f;border:2px solid #5d1e1b;box-shadow:0 2px 2px #0006; }
|
||||||
.source-file-widget.capture-kind-clipping .source-file-preview { height: 105px; border-color: #b2a996; box-shadow: none; }
|
.source-file-widget.capture-kind-clipping.open { transform:scale(1); }
|
||||||
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit: cover; filter: grayscale(.12) contrast(1.08); }
|
.source-file-widget.capture-kind-clipping .source-file-preview { width:100%;height:218px;margin:3px 0 10px;border:2px solid #f4efe1;background:#c8c2b3;box-shadow:2px 3px 3px #10151170;transform:rotate(var(--clip-inset-rotation,.7deg));transform-origin:50% 48%; }
|
||||||
.source-file-widget.capture-kind-clipping > strong { font-size: 11px; }
|
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit:contain;filter:grayscale(.12) contrast(1.08); }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-footer { display:flex;align-items:flex-end;justify-content:space-between;gap:8px; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-footer > time { flex:0 0 auto;margin:0 0 2px;color:#775332; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-actions { flex:1;justify-content:flex-end;gap:9px;margin-top:0; }
|
||||||
|
.source-file-widget.capture-kind-clipping .source-file-actions button { color:#4e5d57;text-shadow:none; }
|
||||||
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
||||||
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
||||||
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
||||||
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
||||||
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
||||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
.evidence-card.note.luggage-tag { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
||||||
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
.evidence-card.note.luggage-tag header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
||||||
.evidence-card.note header i { display: none; }
|
.evidence-card.note.luggage-tag header i { display: none; }
|
||||||
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
.evidence-card.note.luggage-tag .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
||||||
.evidence-card.note h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
|
.evidence-card.note.luggage-tag h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
|
||||||
.evidence-card.note p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
|
.evidence-card.note.luggage-tag p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
|
||||||
.evidence-card.note.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
.evidence-card.note.luggage-tag.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
||||||
.evidence-card.note.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
.evidence-card.note.luggage-tag.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
||||||
.evidence-card.note.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
.evidence-card.note.luggage-tag.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
||||||
.evidence-card.note.selected h3 { font-size: 7px; }
|
.evidence-card.note.luggage-tag.selected h3 { font-size: 7px; }
|
||||||
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
||||||
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
||||||
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
||||||
@@ -351,6 +355,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.file-editor-body { padding: 24px 27px 22px; }
|
.file-editor-body { padding: 24px 27px 22px; }
|
||||||
.file-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
|
.file-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
|
||||||
.file-editor-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
|
.file-editor-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
|
||||||
|
.file-editor-grid.published-fields { grid-template-columns:minmax(0,1fr) 180px; }
|
||||||
.field > span { display: flex; align-items: center; gap: 6px; }
|
.field > span { display: flex; align-items: center; gap: 6px; }
|
||||||
.metadata-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
|
.metadata-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
|
||||||
.metadata-heading > div { display: grid; gap: 3px; }
|
.metadata-heading > div { display: grid; gap: 3px; }
|
||||||
@@ -380,8 +385,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }.report-evidence.rejected { border-left-color:#913d35;background:#e5d2c7cc; }
|
.report-evidence { margin-top:14px; padding:14px 16px 16px; border-left:4px solid #8b5b38; background:#e1ddcfcc; box-shadow:0 1px #fff8; }.report-evidence.verified { border-left-color:#46705c; }.report-evidence.rejected { border-left-color:#913d35;background:#e5d2c7cc; }
|
||||||
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
.report-evidence-heading { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:10px; margin-bottom:12px; }.report-evidence-heading b { color:#25332d; font:14px Special Elite; }.report-evidence-heading span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#646c67; font:8px IBM Plex Mono; text-transform:uppercase; }.report-evidence-heading em { padding:3px 5px; border:1px solid #577665; color:#436753; font:600 6px IBM Plex Mono; letter-spacing:.08em; font-style:normal; }
|
||||||
.report-evidence-heading em.rejected { border-color:#98534b;color:#813a34; }.report-evidence-diagnostic { margin:-3px 0 13px;padding:9px 10px;border:1px solid #b78376;background:#ead8cc;color:#6f302d;font:9px/1.5 IBM Plex Mono; }
|
.report-evidence-heading em.rejected { border-color:#98534b;color:#813a34; }.report-evidence-diagnostic { margin:-3px 0 13px;padding:9px 10px;border:1px solid #b78376;background:#ead8cc;color:#6f302d;font:9px/1.5 IBM Plex Mono; }
|
||||||
.report-evidence label,.report-investigator { display:grid; gap:4px; margin-top:9px; }.report-evidence label > span,.report-investigator > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-evidence input,.report-evidence textarea,.report-investigator input { width:100%; min-width:0; padding:7px 8px; border:0; border-bottom:1px solid #747b75; outline:0; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; resize:vertical; }.report-evidence input:focus,.report-evidence textarea:focus,.report-investigator input:focus { border-bottom-color:#9b582d; background:#f6f0dc; box-shadow:inset 3px 0 #b2784a; }
|
.report-reference { display:grid; gap:4px; margin-top:9px; }.report-reference > span { color:#5c655f; font:600 7px IBM Plex Mono; letter-spacing:.11em; }.report-reference > p,.report-reference > a { width:100%; min-width:0; min-height:31px; margin:0; padding:7px 8px; border-bottom:1px solid #747b75; background:#f2eee0a8; color:#1d2622; font:12px/1.45 Special Elite; overflow-wrap:anywhere; }.report-reference > a { color:#315b52; text-decoration-thickness:1px; text-underline-offset:3px; }
|
||||||
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator input { max-width:390px; }
|
.report-fields { display:grid; grid-template-columns:155px minmax(0,1fr); gap:13px; }.report-investigator { margin-top:30px; padding-top:13px; border-top:1px solid #4c554f; }.report-investigator p { max-width:390px; }
|
||||||
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
.report-verdict { margin-top:28px; padding:17px 19px; border:2px solid #874339; background:#e0c7b9; transform:rotate(-.25deg); }.report-verdict small { color:#7c352f; font:700 8px IBM Plex Mono; letter-spacing:.13em; }.report-verdict p { margin:9px 0 0; font:15px/1.5 Special Elite; }.report-verdict.accepted { border-color:#47705c; background:#d2ddcf; }.report-verdict.accepted small { color:#315b48; }
|
||||||
.case-report-actions { position:sticky; bottom:-34px; display:flex; justify-content:flex-end; gap:9px; margin:30px -12px -20px; padding:14px 12px 20px; background:linear-gradient(transparent,#ebe7d8 25%); }.case-report-actions button { border:1px outset #8c948e; padding:10px 13px; color:#34443e; background:#c8cbc4; cursor:pointer; font:8px IBM Plex Mono; letter-spacing:.06em; }.case-report-actions .primary { float:none; color:#fff; background:#174337; }.case-report-actions button:disabled { opacity:.55;cursor:progress; }
|
.case-report-actions { position:sticky; bottom:-34px; display:flex; justify-content:flex-end; gap:9px; margin:30px -12px -20px; padding:14px 12px 20px; background:linear-gradient(transparent,#ebe7d8 25%); }.case-report-actions button { border:1px outset #8c948e; padding:10px 13px; color:#34443e; background:#c8cbc4; cursor:pointer; font:8px IBM Plex Mono; letter-spacing:.06em; }.case-report-actions .primary { float:none; color:#fff; background:#174337; }.case-report-actions button:disabled { opacity:.55;cursor:progress; }
|
||||||
.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; }
|
.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; }
|
||||||
@@ -806,8 +811,23 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
||||||
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
||||||
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||||
/* A note torn to the board reads as handwriting too. */
|
/* Ordinary board notes retain the small luggage-tag treatment. */
|
||||||
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
.evidence-card.note.luggage-tag .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||||
|
/* Notebook pages stay recognizably part of the notebook after being torn out. */
|
||||||
|
.evidence-card.note.lined-sheet { box-sizing:border-box; min-height:270px; padding:18px 20px 20px 43px; overflow:hidden; color:#26356b;
|
||||||
|
border:1px solid #e8dec5; background:
|
||||||
|
linear-gradient(90deg,transparent 0 29px,#c8787880 30px,#c8787880 31px,transparent 32px),
|
||||||
|
repeating-linear-gradient(180deg,#f4ecd6 0 26px,#b3c1c777 27px,#f4ecd6 28px);
|
||||||
|
clip-path:polygon(0 0,100% 0,100% 97.5%,97% 99%,93% 98%,89% 100%,84% 98.5%,79% 100%,74% 98%,68% 99.5%,62% 98%,56% 100%,50% 98.5%,44% 100%,38% 98%,32% 99.5%,26% 98%,20% 100%,14% 98.5%,8% 100%,3% 98%,0 99%);
|
||||||
|
box-shadow:8px 10px 5px #020b0980,0 0 0 1px #6c756f; }
|
||||||
|
.evidence-card.note.lined-sheet::before { content:'';position:absolute;left:8px;top:19px;bottom:23px;width:10px;background:radial-gradient(circle at 50% 8px,#34433e 0 3px,#d7ceb8 3.5px 5px,transparent 5.5px) 0 0/10px 34px repeat-y;opacity:.82; }
|
||||||
|
.evidence-card.note.lined-sheet::after { display:none; }
|
||||||
|
.evidence-card.note.lined-sheet header { height:20px;padding-bottom:4px;border-color:#9ba8aa;color:#6a706b;font-size:7px; }
|
||||||
|
.evidence-card.note.lined-sheet header i { display:none; }
|
||||||
|
.evidence-card.note.lined-sheet .card-content { height:214px;overflow:hidden; }
|
||||||
|
.evidence-card.note.lined-sheet h3 { margin:8px 0 5px;color:#805748;font:600 7px IBM Plex Mono;letter-spacing:.12em; }
|
||||||
|
.evidence-card.note.lined-sheet .card-content p { display:block;margin:0;overflow:hidden;color:#26356b;font:24px/28px "Reenie Beanie",cursive;white-space:pre-wrap; }
|
||||||
|
.evidence-card.note.lined-sheet.selected { outline:2px solid #e49a4a;outline-offset:5px;filter:drop-shadow(7px 9px 4px #0007); }
|
||||||
|
|
||||||
/* === Player auth + character screen (src/play.tsx) ================= */
|
/* === Player auth + character screen (src/play.tsx) ================= */
|
||||||
.auth-plate { gap: 4px; }
|
.auth-plate { gap: 4px; }
|
||||||
|
|||||||
+3
-1
@@ -67,9 +67,12 @@ export interface UploadedCaseDocument extends DocumentExhibit {
|
|||||||
analysis: DocumentUploadAnalysis
|
analysis: DocumentUploadAnalysis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotePresentation = 'luggage' | 'lined_sheet'
|
||||||
|
|
||||||
export interface NoteExhibit extends ExhibitBase {
|
export interface NoteExhibit extends ExhibitBase {
|
||||||
type: 'note'
|
type: 'note'
|
||||||
content: string
|
content: string
|
||||||
|
presentation: NotePresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventExhibit extends ExhibitBase {
|
export interface EventExhibit extends ExhibitBase {
|
||||||
@@ -221,7 +224,6 @@ export interface CaseReport {
|
|||||||
|
|
||||||
export interface CaseReportSubmissionInput {
|
export interface CaseReportSubmissionInput {
|
||||||
investigatorName: string
|
investigatorName: string
|
||||||
evidence: Array<Pick<CaseReportEvidence, 'connectionId' | 'documentExhibitId' | 'relationText' | 'publishedAt' | 'sourceCitation' | 'sourceUri'>>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CaseState {
|
export interface CaseState {
|
||||||
|
|||||||
Reference in New Issue
Block a user