diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts
index ecaa9b2..e777603 100644
--- a/e2e/mystery.acceptance.spec.ts
+++ b/e2e/mystery.acceptance.spec.ts
@@ -92,6 +92,10 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
await waitForSave(page, () => page.getByRole('button', { name: 'Zoom out', exact: true }).click())
await waitForSave(page, () => movement.getByRole('button', { name: 'OPEN', exact: true }).click())
const manifest = page.locator('.source-file-widget.open').filter({ hasText: 'Carrier Dispatch Manifest' })
+ await expect(manifest.locator('.text-source-excerpt')).toContainText('HARBOR & FELL LOGISTICS')
+ await expect(manifest.locator('.source-file-preview svg')).toHaveCount(0)
+ const manifestCue = page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })
+ await waitForSave(page, () => manifestCue.fill('ELIAS · H&F 14'))
await elias.click()
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
const boardBox = await page.locator('.board-viewport').boundingBox()
@@ -135,6 +139,7 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
await expect(page.locator('.evidence-card.event')).toHaveCount(3)
await expect(page.locator('.evidence-card.note')).toHaveCount(1)
+ await expect(page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })).toHaveValue('ELIAS · H&F 14')
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
await expect(page.locator('.connections path')).toHaveCount(3)
diff --git a/src/App.tsx b/src/App.tsx
index ec6ab6e..d0b6aa6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -331,7 +331,7 @@ export function App() {
{ if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}{caseState.title}
{caseState.subtitle || caseState.id.toUpperCase()}
{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
-
setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
+ setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && item.type === 'party')}
@@ -452,7 +452,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
return GU
GLITCH UNIVERSITY LEVEL ARCHIVENo investigations found.
The database is ready, but no authored level exists yet.
{canEdit ? : Add ?edit=1 and enable level editing on the server to begin authoring.
}
}
-function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
+function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const touchPoints = useRef(new Map())
@@ -622,7 +622,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
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: 'relation', id: relation.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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
-
+ onUpdateDocumentCue(document.id, cue)}/>
{document.title}
})}
diff --git a/src/exhibitRegistry.tsx b/src/exhibitRegistry.tsx
index f9cab0d..c291df4 100644
--- a/src/exhibitRegistry.tsx
+++ b/src/exhibitRegistry.tsx
@@ -69,7 +69,7 @@ export function exhibitWidget(type: EvidenceType) {
return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note
}
-type DocumentWidgetProps = { document: CaseDocument; source: string }
+type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
export type DocumentWidgetDefinition = {
label: string
Preview: ComponentType
@@ -82,6 +82,14 @@ function ImagePreview({ document, source }: DocumentWidgetProps) {
function GenericPreview({ document }: DocumentWidgetProps) {
return {document.kind}
}
+function TextPreview({ document, onMemoryCue }: DocumentWidgetProps) {
+ const excerpt = document.body.filter(Boolean).slice(0, 2).join(' ')
+ return
+
{excerpt || document.title}
+
+}
function ImageAsset({ document, source }: DocumentWidgetProps) {
return
}
@@ -96,7 +104,7 @@ const genericDocument = (label: string): DocumentWidgetDefinition => ({ label, P
export const documentWidgetRegistry: Record = {
image: { label: 'Image', Preview: ImagePreview, Asset: ImageAsset },
pdf: { label: 'PDF', Preview: GenericPreview, Asset: FrameAsset },
- text: { label: 'Text document', Preview: GenericPreview, Asset: FrameAsset },
+ text: { label: 'Text document', Preview: TextPreview, Asset: FrameAsset },
web_capture: genericDocument('Web capture'),
email: genericDocument('Email'),
article: genericDocument('Article'),
diff --git a/src/styles.css b/src/styles.css
index 1ffa841..e3b8585 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -142,6 +142,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.source-file-preview img { width: 100%; height: 100%; object-fit: cover; }
.source-file-preview > div { display: grid; justify-items: center; gap: 5px; color: #b8c2bd; }
.source-file-preview small { font: 7px IBM Plex Mono; }
+.source-file-widget.file-type-text .source-file-preview { height: 108px; display: block; background: #e8e4d7; border-color: #a19b8b; }
+.text-document-preview { width: 100%; height: 100%; display: grid !important; grid-template-rows: 42px 1fr; justify-items: stretch !important; gap: 0 !important; color: #202722 !important; }
+.text-source-excerpt { margin: 0; padding: 6px 7px; overflow: hidden; border-bottom: 1px solid #a29d8e; background: #d5d1c4; font: 6px/1.35 IBM Plex Mono; text-transform: uppercase; }
+.text-document-preview textarea { width: 100%; min-height: 0; resize: none; overflow: hidden; border: 0; outline: 0; padding: 8px 7px 5px; background: repeating-linear-gradient(#ede9dc 0 20px, #c9c5b855 21px); color: #151b18; font: 900 15px/1.05 "Marker Felt", "Comic Sans MS", cursive; letter-spacing: .015em; text-align: center; transform: rotate(-1deg); }
+.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
+.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 > 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; }