Allow undated reconstructed events

This commit is contained in:
2026-08-14 19:57:22 +02:00
parent 703b9652c9
commit d99deb3c06
10 changed files with 60 additions and 17 deletions
+3 -3
View File
@@ -148,7 +148,7 @@ CREATE TABLE osint.event_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE, exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
narrative_text TEXT NOT NULL, narrative_text TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL occurred_at TIMESTAMPTZ
); );
``` ```
@@ -159,10 +159,10 @@ The service validates that every base exhibit has exactly one subtype row matchi
An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time. An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time.
- `narrative_text` states what the investigator believes happened. - `narrative_text` states what the investigator believes happened.
- `occurred_at` places that assertion in reconstructed time. - Nullable `occurred_at` places that assertion in reconstructed time when known. It must never inherit the exhibit's creation timestamp.
- One event may cite several supporting exhibits. - One event may cite several supporting exhibits.
- One exhibit may support several events. - One exhibit may support several events.
- Events ordered by `occurred_at` form the emerging case narrative; there is no duplicated story-text record. - Dated events ordered by `occurred_at` form the temporal narrative. Undated events remain visible in the reconstructed story without affecting the timeline range; there is no duplicated story-text record.
Supporting evidence is an explicit normalized relationship: Supporting evidence is an explicit normalized relationship:
+3
View File
@@ -183,7 +183,10 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click()) await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
await page.reload() await page.reload()
await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected') await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected')
await expect(page.locator('.evidence-card.event')).toContainText('UNDATED')
await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder') await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder')
await expect(page.locator('.story-strip')).toContainText('UNDATED')
await expect(page.locator('.timeline .marker')).toHaveCount(1)
await expect(page.locator('.event-support-lines line')).toHaveCount(1) await expect(page.locator('.event-support-lines line')).toHaveCount(1)
page.once('dialog', dialog => dialog.accept('Browser Template')) page.once('dialog', dialog => dialog.accept('Browser Template'))
@@ -0,0 +1,4 @@
ALTER TABLE osint.event_exhibits
ALTER COLUMN occurred_at DROP NOT NULL;
COMMENT ON COLUMN osint.event_exhibits.occurred_at IS 'Optional reconstructed occurrence time; never inferred from exhibit creation time';
+15
View File
@@ -140,6 +140,21 @@ suite('level persistence API', () => {
metadata: { witness: 'Integration test', confidence: 'high' }, metadata: { witness: 'Integration test', confidence: 'high' },
}) })
const undatedState = structuredClone(afterMetadataSave)
const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)!
delete undatedEvent.eventDate
const undatedSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState),
})
expect(undatedSave.ok).toBe(true)
const loadedUndated = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined()
expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull()
loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z'
expect((await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated),
})).ok).toBe(true)
const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true) expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true)
const personPartyId = randomUUID() const personPartyId = randomUUID()
+1 -1
View File
@@ -68,7 +68,7 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
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('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)',
[mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text]) [mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text])
const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date }>( const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date | null }>(
`SELECT ev.* FROM osint.event_exhibits ev JOIN osint.exhibits e ON e.id=ev.exhibit_id WHERE e.board_id=$1`, [sourceBoardId]) `SELECT ev.* FROM osint.event_exhibits ev JOIN osint.exhibits e ON e.id=ev.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
for (const row of events.rows) await client.query( for (const row of events.rows) 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)',
+1 -1
View File
@@ -247,7 +247,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
if (canonicalType === '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 (canonicalType === '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 (canonicalType === 'event') await client.query( if (canonicalType === 'event') 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) || new Date().toISOString()]) [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
if (canonicalType === 'party') { if (canonicalType === 'party') {
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person' const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)', await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
+5 -3
View File
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = [] const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(11) expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(12)
const client = new Client({ connectionString: testDatabaseUrl }) const client = new Client({ connectionString: testDatabaseUrl })
await client.connect() await client.connect()
@@ -47,14 +47,16 @@ suite('PostgreSQL migrations', () => {
])) ]))
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs'])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('11') expect(ledger.rows[0].count).toBe('12')
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
expect(eventOccurrence.rows[0].is_nullable).toBe('YES')
await client.end() await client.end()
const secondRun: string[] = [] const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(11) expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(12)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
}) })
}) })
+26 -8
View File
@@ -40,6 +40,7 @@ export function App() {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null) const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null) const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null) const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null) const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null) const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
const [briefOpen, setBriefOpen] = useState(false) const [briefOpen, setBriefOpen] = useState(false)
@@ -143,9 +144,8 @@ export function App() {
const { viewport } = caseState const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 }) const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 } supportingEvidenceIds: [], ...position, width: 270 }
update(state => ({ ...state, evidence: [...state.evidence, event] })) setNewEventDraft(event)
setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id)
} }
const addParty = () => { const addParty = () => {
@@ -315,7 +315,11 @@ export function App() {
...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })), ...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })),
...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })), ...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })),
].sort((a, b) => dateValue(a.date) - dateValue(b.date)) ].sort((a, b) => dateValue(a.date) - dateValue(b.date))
const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!)) const storyEvents = caseState.evidence.filter(item => item.type === 'event').sort((a, b) => {
if (!a.eventDate) return b.eventDate ? 1 : 0
if (!b.eventDate) return -1
return dateValue(a.eventDate) - dateValue(b.eventDate)
})
return <main className="desktop"> return <main className="desktop">
<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>
@@ -355,7 +359,7 @@ export function App() {
onLocate={focusEvidence} onLocate={focusEvidence}
onEditParty={setEditingPartyId} onEditParty={setEditingPartyId}
/>} />}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate!.slice(0, 10)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>} {storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>} {!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
<div className="board-actions"> <div className="board-actions">
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button> <button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
@@ -388,6 +392,20 @@ export function App() {
onClose={() => setEditingEventId(null)} onClose={() => setEditingEventId(null)}
onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
/>} />}
{newEventDraft && <EventEditor
key={newEventDraft.id}
event={newEventDraft}
evidence={caseState.evidence}
documents={caseState.documents}
onClose={() => setNewEventDraft(null)}
onSave={event => {
update(state => ({ ...state, evidence: [...state.evidence, event] }))
setNewEventDraft(null)
setSelected(event.id)
setRecentlyCreatedExhibitId(event.id)
setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED')
}}
/>}
{editingPartyId && <PartyEditor {editingPartyId && <PartyEditor
key={editingPartyId} key={editingPartyId}
party={caseState.evidence.find(item => item.id === editingPartyId)!} party={caseState.evidence.find(item => item.id === editingPartyId)!}
@@ -840,7 +858,7 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id]) const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
const submit = (submitEvent: React.FormEvent) => { const submit = (submitEvent: React.FormEvent) => {
submitEvent.preventDefault() submitEvent.preventDefault()
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : new Date().toISOString() const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports }) onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports })
} }
return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}> return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
@@ -849,13 +867,13 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E
<small>EVENT EXHIBIT · THIS HAPPENED</small> <small>EVENT EXHIBIT · THIS HAPPENED</small>
<label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label> <label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
<label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label> <label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
<label className="field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL</span><input aria-label="Occurred at" type="datetime-local" required value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/></label> <label className="field event-date-field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL · OPTIONAL</span><span><input aria-label="Occurred at" type="datetime-local" value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/><button type="button" disabled={!occurredAt} onClick={() => setOccurredAt('')}>CLEAR DATE</button></span></label>
<div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div> <div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
<div className="folder-members event-support-list"> <div className="folder-members event-support-list">
{candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>} {candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
{candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })} {candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
</div> </div>
<p className="folder-editor-note">An event is an investigator assertion. Its occurrence time is separate from the publication time of the documents supporting it.</p> <p className="folder-editor-note">An event is an investigator assertion. Leave occurrence time blank when it is unknown; creation time is never used for timeline placement.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div> <div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
</div> </div>
</form></div> </form></div>
+1 -1
View File
@@ -37,7 +37,7 @@ function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) { function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
const supportCount = exhibit.supportingEvidenceIds?.length || 0 const supportCount = exhibit.supportingEvidenceIds?.length || 0
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p> return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{exhibit.eventDate && <time>{new Date(exhibit.eventDate).toLocaleString()}</time>} <time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div> <div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div>
</div> </div>
} }
+1
View File
@@ -234,6 +234,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; } .folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; } .folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
.event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; } .event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; }
.event-date-field > span:last-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }.event-date-field > span:last-child button { border: 1px outset #8c948e; background: #c8cbc4; color: #4d5853; padding: 0 10px; cursor: pointer; font: 8px IBM Plex Mono; }.event-date-field > span:last-child button:disabled { opacity: .45; cursor: default; }
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; } .folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
.folder-member.included { background: #e2d7bd; opacity: 1; } .folder-member.included { background: #e2d7bd; opacity: 1; }
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; } .folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }