feat(ext/popup): Document item type — form + signature-block detail
Form requires title + primary_attachment; the primary-row picker is compact in edit mode (dashed-border when empty, filename row when filled). Detail view promotes the primary to a gold signature block (48×60 thumb + filename + meta + ↓ download · 🔍 preview). For image- mime primaries, the thumb lazy-loads via decrypt + object-URL; the preview button toggles an inline expanded view. Supplementary attachments use the standard compact disclosure (Task 7) when present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../popup', async () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const setState = vi.fn();
|
||||||
|
const sendMessage = vi.fn();
|
||||||
|
const getState = vi.fn(() => ({
|
||||||
|
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
|
||||||
|
searchQuery: '', activeGroup: null, error: null, loading: false,
|
||||||
|
capturedTabId: null, capturedUrl: '', newType: 'document',
|
||||||
|
}));
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { renderForm } from '../document';
|
||||||
|
import { sendMessage } from '../../../popup';
|
||||||
|
import type { Item, AttachmentRef } from '../../../../shared/types';
|
||||||
|
|
||||||
|
const PRIMARY: AttachmentRef = {
|
||||||
|
id: 'primaryid',
|
||||||
|
filename: 'passport.pdf',
|
||||||
|
mime_type: 'application/pdf',
|
||||||
|
size: 240000,
|
||||||
|
created: 1700000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Document form save', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects save when primary_attachment is missing', async () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderForm(app, 'add', null);
|
||||||
|
(document.getElementById('f-title') as HTMLInputElement).value = 'Passport';
|
||||||
|
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||||
|
document.getElementById('save-btn')!.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(alertSpy).not.toHaveBeenCalled();
|
||||||
|
// setState called with the error
|
||||||
|
const { setState } = await import('../../../popup');
|
||||||
|
expect(vi.mocked(setState)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ error: expect.stringContaining('primary attachment') }),
|
||||||
|
);
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls;
|
||||||
|
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||||
|
expect(addCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends update_item with correct wire shape when valid', async () => {
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
|
||||||
|
const existingDraft: Item = {
|
||||||
|
id: 'docid',
|
||||||
|
type: 'document',
|
||||||
|
core: {
|
||||||
|
type: 'document',
|
||||||
|
filename: PRIMARY.filename,
|
||||||
|
mime_type: PRIMARY.mime_type,
|
||||||
|
primary_attachment: PRIMARY.id,
|
||||||
|
},
|
||||||
|
title: 'Passport',
|
||||||
|
tags: [],
|
||||||
|
favorite: false,
|
||||||
|
created: 0,
|
||||||
|
modified: 0,
|
||||||
|
trashed_at: undefined,
|
||||||
|
sections: [],
|
||||||
|
attachments: [PRIMARY],
|
||||||
|
field_history: {},
|
||||||
|
};
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderForm(app, 'edit', existingDraft);
|
||||||
|
document.getElementById('save-btn')!.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls;
|
||||||
|
const updateCall = calls.find(([msg]) => (msg as { type: string }).type === 'update_item');
|
||||||
|
expect(updateCall).toBeDefined();
|
||||||
|
const msg = updateCall![0] as { type: 'update_item'; item: any };
|
||||||
|
expect(msg.item.core.primary_attachment).toBe(PRIMARY.id);
|
||||||
|
expect(msg.item.core.type).toBe('document');
|
||||||
|
expect(msg.item.attachments).toEqual([PRIMARY]);
|
||||||
|
expect(msg.item.type).toBe('document');
|
||||||
|
});
|
||||||
|
});
|
||||||
376
extension/src/popup/components/types/document.ts
Normal file
376
extension/src/popup/components/types/document.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/// Document item type — title + REQUIRED primary attachment + optional
|
||||||
|
/// notes/tags + optional supplementary attachments.
|
||||||
|
/// Primary attachment is referenced by ID from the item's attachments array.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
|
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||||
|
import {
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
|
} from '../fields';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
if (activeFormEscHandler) {
|
||||||
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
|
activeFormEscHandler = null;
|
||||||
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (n: number): string => {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||||
|
teardown();
|
||||||
|
const state = getState();
|
||||||
|
const isEdit = mode === 'edit';
|
||||||
|
const c = existing?.core.type === 'document' ? existing.core : null;
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
let primaryId: string = c?.primary_attachment ?? '';
|
||||||
|
|
||||||
|
const renderPrimary = (): string => {
|
||||||
|
const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
|
||||||
|
if (!primaryRef) {
|
||||||
|
return `
|
||||||
|
<div class="document-primary-row document-primary-row--empty" id="primary-picker">
|
||||||
|
+ attach primary file
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="document-primary-row" id="primary-picker">
|
||||||
|
<span class="document-primary-row__thumb">📄</span>
|
||||||
|
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
|
||||||
|
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
|
||||||
|
<span class="document-primary-row__action">↑ change</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="detail-title" style="margin-bottom:16px;">${isEdit ? 'edit document' : 'new document'}</div>
|
||||||
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-title">title <span class="req">*</span></label>
|
||||||
|
<input id="f-title" type="text" value="${escapeHtml(existing?.title ?? '')}" placeholder="Passport, Lease, etc.">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label">primary attachment <span class="req">*</span></label>
|
||||||
|
${renderPrimary()}
|
||||||
|
<input type="file" id="primary-file-input" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-notes">notes</label>
|
||||||
|
<textarea id="f-notes" rows="3" placeholder="optional context...">${escapeHtml(existing?.notes ?? '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-tags">tags</label>
|
||||||
|
<input id="f-tags" type="text" value="${escapeHtml((existing?.tags ?? []).join(', '))}" placeholder="legal, official">
|
||||||
|
</div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const wirePrimaryPicker = (): void => {
|
||||||
|
const picker = document.getElementById('primary-picker');
|
||||||
|
const fileInput = document.getElementById('primary-file-input') as HTMLInputElement | null;
|
||||||
|
picker?.addEventListener('click', () => fileInput?.click());
|
||||||
|
fileInput?.addEventListener('change', async () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const resp = await sendMessage({
|
||||||
|
type: 'upload_attachment',
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
const data = resp.data as { attachment: AttachmentRef };
|
||||||
|
attachmentsDraft = [...attachmentsDraft.filter((a) => a.id !== primaryId), data.attachment];
|
||||||
|
primaryId = data.attachment.id;
|
||||||
|
const picker2 = document.getElementById('primary-picker');
|
||||||
|
if (picker2) {
|
||||||
|
picker2.outerHTML = renderPrimary();
|
||||||
|
wirePrimaryPicker();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`upload failed: ${resp?.error ?? 'service worker unavailable'}`);
|
||||||
|
}
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
wirePrimaryPicker();
|
||||||
|
|
||||||
|
const rerender = (): void => {
|
||||||
|
const disclosure = app.querySelector('.disclosure');
|
||||||
|
if (!disclosure) return;
|
||||||
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
};
|
||||||
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
// If the user removed the primary, clear primaryId so save validation will block
|
||||||
|
if (!attachmentsDraft.find((a) => a.id === primaryId)) primaryId = '';
|
||||||
|
const disc = app.querySelector('.attachments-disclosure');
|
||||||
|
if (disc) {
|
||||||
|
disc.outerHTML = renderAttachmentsDisclosure({
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
});
|
||||||
|
wireDisclosure();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
wireDisclosure();
|
||||||
|
|
||||||
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
|
setState({ error: null });
|
||||||
|
navigate(isEdit ? 'detail' : 'list');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
|
await saveDocument(mode, existing, primaryId, attachmentsDraft, sectionsDraft);
|
||||||
|
});
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setState({ error: null });
|
||||||
|
navigate(isEdit ? 'detail' : 'list');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
activeFormEscHandler = escHandler;
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDocument(
|
||||||
|
mode: 'add' | 'edit',
|
||||||
|
existing: Item | null,
|
||||||
|
primaryId: string,
|
||||||
|
attachmentsDraft: AttachmentRef[],
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
): Promise<void> {
|
||||||
|
const state = getState();
|
||||||
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
|
|
||||||
|
const primaryRef = attachmentsDraft.find((a) => a.id === primaryId);
|
||||||
|
if (!primaryId || !primaryRef) {
|
||||||
|
setState({ error: 'primary attachment required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesRaw = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim();
|
||||||
|
const tagsRaw = (document.getElementById('f-tags') as HTMLInputElement).value;
|
||||||
|
const tags = tagsRaw.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const core = {
|
||||||
|
type: 'document' as const,
|
||||||
|
filename: primaryRef.filename,
|
||||||
|
mime_type: primaryRef.mime_type,
|
||||||
|
primary_attachment: primaryId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const item: Item = {
|
||||||
|
id: existing?.id ?? '',
|
||||||
|
title,
|
||||||
|
type: 'document',
|
||||||
|
tags,
|
||||||
|
favorite: existing?.favorite ?? false,
|
||||||
|
group: existing?.group,
|
||||||
|
notes: notesRaw || undefined,
|
||||||
|
created: existing?.created ?? now,
|
||||||
|
modified: now,
|
||||||
|
trashed_at: undefined,
|
||||||
|
core,
|
||||||
|
sections: sectionsDraft,
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
field_history: existing?.field_history ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
setState({ loading: true, error: null });
|
||||||
|
const resp = mode === 'add'
|
||||||
|
? await sendMessage({ type: 'add_item', item })
|
||||||
|
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
|
||||||
|
if (resp.ok) {
|
||||||
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||||
|
} else navigate('list');
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||||
|
teardown();
|
||||||
|
if (item.core.type !== 'document') return;
|
||||||
|
const c = item.core;
|
||||||
|
const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment);
|
||||||
|
if (!primaryRef) {
|
||||||
|
app.innerHTML = `<div class="pad"><p>document missing primary attachment</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImageMime = primaryRef.mime_type.startsWith('image/');
|
||||||
|
const supplementary = item.attachments.filter((a) => a.id !== c.primary_attachment);
|
||||||
|
|
||||||
|
const tagsHtml = item.tags && item.tags.length > 0
|
||||||
|
? `<div class="form-group"><label class="label">tags</label><span>${item.tags.map((t) => escapeHtml(t)).join(', ')}</span></div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
|
||||||
|
|
||||||
|
<div class="document-signature-block" id="doc-sigblock">
|
||||||
|
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div>
|
||||||
|
<div class="document-signature-block__info">
|
||||||
|
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
|
||||||
|
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
|
||||||
|
<div class="document-signature-block__actions">
|
||||||
|
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
|
||||||
|
${isImageMime ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${item.notes ? `<div class="form-group"><label class="label">notes</label><p>${escapeHtml(item.notes)}</p></div>` : ''}
|
||||||
|
${tagsHtml}
|
||||||
|
|
||||||
|
${supplementary.length > 0 ? renderAttachmentsDisclosure({ itemId: item.id, attachments: supplementary, mode: 'view' }) : ''}
|
||||||
|
|
||||||
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
|
<button class="btn" id="back-btn">back</button>
|
||||||
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
|
<button class="btn danger" id="trash-btn">trash</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Lazy-load thumb for image-mime primaries
|
||||||
|
if (isImageMime) {
|
||||||
|
const thumb = document.querySelector('.document-signature-block__thumb') as HTMLElement;
|
||||||
|
sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id }).then((resp) => {
|
||||||
|
if (!resp || !resp.ok) return;
|
||||||
|
const data = resp.data as { bytes: ArrayBuffer };
|
||||||
|
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (thumb) thumb.innerHTML = `<img src="${url}" alt="" />`;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('doc-download')?.addEventListener('click', async () => {
|
||||||
|
const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
|
||||||
|
if (!resp || !resp.ok) { alert(`download failed: ${resp?.error ?? 'service worker unavailable'}`); return; }
|
||||||
|
const data = resp.data as { bytes: ArrayBuffer };
|
||||||
|
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = primaryRef.filename;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('doc-preview')?.addEventListener('click', async () => {
|
||||||
|
const sigblock = document.getElementById('doc-sigblock')!;
|
||||||
|
const existingPreview = sigblock.querySelector('.document-signature-block__preview');
|
||||||
|
if (existingPreview) { existingPreview.remove(); return; }
|
||||||
|
const resp = await sendMessage({ type: 'download_attachment', itemId: item.id, attachmentId: primaryRef.id });
|
||||||
|
if (!resp || !resp.ok) return;
|
||||||
|
const data = resp.data as { bytes: ArrayBuffer };
|
||||||
|
const blob = new Blob([data.bytes], { type: primaryRef.mime_type });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'document-signature-block__preview';
|
||||||
|
preview.innerHTML = `<img src="${url}" alt="" />`;
|
||||||
|
sigblock.appendChild(preview);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire supplementary disclosure
|
||||||
|
if (supplementary.length > 0) {
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: supplementary, mode: 'view' });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||||
|
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||||
|
document.getElementById('trash-btn')?.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||||||
|
teardown();
|
||||||
|
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||||
|
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||||
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||||
|
} else navigate('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = async (e: KeyboardEvent) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t instanceof HTMLElement) {
|
||||||
|
const tag = t.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||||
|
}
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape': teardown(); navigate('list'); break;
|
||||||
|
case 'e': teardown(); navigate('edit'); break;
|
||||||
|
case 'd':
|
||||||
|
e.preventDefault();
|
||||||
|
if (confirm(`Move "${item.title}" to trash?`)) {
|
||||||
|
teardown();
|
||||||
|
const resp = await sendMessage({ type: 'delete_item', id: item.id });
|
||||||
|
if (!resp.ok) { setState({ error: resp.error }); return; }
|
||||||
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||||
|
} else navigate('list');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
activeKeyHandler = handler;
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
}
|
||||||
@@ -892,3 +892,109 @@ textarea {
|
|||||||
border-color: #aa812a;
|
border-color: #aa812a;
|
||||||
color: #c9d1d9;
|
color: #c9d1d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Document type signature block + primary picker (γ₁) --- */
|
||||||
|
|
||||||
|
.document-signature-block {
|
||||||
|
border-left: 3px solid #aa812a;
|
||||||
|
background: #161b22;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.document-signature-block__thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(135deg, #b88a30, #7c5719);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff3cf;
|
||||||
|
}
|
||||||
|
.document-signature-block__thumb img {
|
||||||
|
width: 100%; height: 100%; object-fit: contain;
|
||||||
|
}
|
||||||
|
.document-signature-block__info { flex: 1; min-width: 0; }
|
||||||
|
.document-signature-block__name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #f1cf6e;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.document-signature-block__meta {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #8b949e;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.document-signature-block__actions {
|
||||||
|
font-size: 9px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.document-signature-block__preview {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.document-signature-block__preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document primary picker (form mode) */
|
||||||
|
.document-primary-row {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.document-primary-row--empty {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #aa812a;
|
||||||
|
color: #8b949e;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
.document-primary-row__thumb {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(135deg, #b88a30, #7c5719);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.document-primary-row__name {
|
||||||
|
flex: 1;
|
||||||
|
color: #c9d1d9;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.document-primary-row__meta {
|
||||||
|
color: #6e7681;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.document-primary-row__action {
|
||||||
|
color: #d2ab43;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user