feat(ext/popup): wire attachments disclosure into 6 type forms + 📎 list indicator
Each existing type form (Login, SecureNote, Identity, Card, Key, TOTP) renders + wires the attachments-disclosure in both edit and view modes. Form save reads from attachmentsDraft; teardown revokes any image object URLs. Item-list rows show a 📎 glyph for items with at least one attachment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ export function renderItemList(app: HTMLElement): void {
|
|||||||
const rowsHtml = filtered.length > 0
|
const rowsHtml = filtered.length > 0
|
||||||
? filtered.map(([id, e], i) => `
|
? filtered.map(([id, e], i) => `
|
||||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}</span>
|
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}</span>
|
||||||
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('')
|
`).join('')
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
/// Detail view has a styled card-silhouette signature block.
|
/// Detail view has a styled card-silhouette signature block.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, ManifestEntry, CardKind, Section } from '../../../shared/types';
|
import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types';
|
||||||
import {
|
import {
|
||||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
renderSectionsEditor, wireSectionsEditor,
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
|
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
|
||||||
|
|
||||||
@@ -15,6 +20,7 @@ let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
|||||||
let sectionsExpanded = false;
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
activeKeyHandler = null;
|
activeKeyHandler = null;
|
||||||
@@ -83,6 +89,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''}
|
${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''}
|
||||||
${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''}
|
${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''}
|
||||||
${renderSections(item, 'card')}
|
${renderSections(item, 'card')}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -94,6 +101,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
// The card-number reveal lives inside the signature block, so wireFieldHandlers
|
// The card-number reveal lives inside the signature block, so wireFieldHandlers
|
||||||
// picks it up alongside the cvv/pin rows.
|
// picks it up alongside the cvv/pin rows.
|
||||||
wireFieldHandlers(app);
|
wireFieldHandlers(app);
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||||
@@ -146,6 +154,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const sectionsDraft: Section[] = existing
|
const sectionsDraft: Section[] = existing
|
||||||
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
: [];
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
const monthOptions = Array.from({ length: 12 }, (_, i) => {
|
const monthOptions = Array.from({ length: 12 }, (_, i) => {
|
||||||
const m = String(i + 1).padStart(2, '0');
|
const m = String(i + 1).padStart(2, '0');
|
||||||
@@ -184,6 +193,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<div class="form-group"><label class="label" for="f-kind">kind</label>
|
<div class="form-group"><label class="label" for="f-kind">kind</label>
|
||||||
<select id="f-kind">${kindOptions}</select></div>
|
<select id="f-kind">${kindOptions}</select></div>
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -200,12 +210,33 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
wireSectionsEditor(app, sectionsDraft, rerender);
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveCard(mode, existing, sectionsDraft);
|
await saveCard(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
@@ -220,7 +251,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCard(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
async function saveCard(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -260,7 +291,7 @@ async function saveCard(mode: 'add' | 'edit', existing: Item | null, sectionsDra
|
|||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: sectionsDraft,
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: attachmentsDraft,
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
/// Detail view shows a "profile card" signature block + plain rows.
|
/// Detail view shows a "profile card" signature block + plain rows.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, ManifestEntry, Section } from '../../../shared/types';
|
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||||
import {
|
import {
|
||||||
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
renderSectionsEditor, wireSectionsEditor,
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let sectionsExpanded = false;
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
activeKeyHandler = null;
|
activeKeyHandler = null;
|
||||||
@@ -61,6 +67,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
|
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
|
||||||
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
|
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
|
||||||
${renderSections(item, 'identity')}
|
${renderSections(item, 'identity')}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -70,6 +77,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
wireFieldHandlers(app);
|
wireFieldHandlers(app);
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||||
@@ -121,6 +129,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const sectionsDraft: Section[] = existing
|
const sectionsDraft: Section[] = existing
|
||||||
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
: [];
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
@@ -139,6 +148,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
|
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
|
||||||
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
|
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -155,12 +165,33 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
wireSectionsEditor(app, sectionsDraft, rerender);
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveIdentity(mode, existing, sectionsDraft);
|
await saveIdentity(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
@@ -175,7 +206,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -202,7 +233,7 @@ async function saveIdentity(mode: 'add' | 'edit', existing: Item | null, section
|
|||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: sectionsDraft,
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: attachmentsDraft,
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,23 @@
|
|||||||
/// since <textarea type="password"> isn't a thing.
|
/// since <textarea type="password"> isn't a thing.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, ManifestEntry, Section } from '../../../shared/types';
|
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||||
import {
|
import {
|
||||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
renderSectionsEditor, wireSectionsEditor,
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let sectionsExpanded = false;
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
activeKeyHandler = null;
|
activeKeyHandler = null;
|
||||||
@@ -46,6 +52,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
|
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
|
||||||
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
|
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
|
||||||
${renderSections(item, 'key')}
|
${renderSections(item, 'key')}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -55,6 +62,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
wireFieldHandlers(app);
|
wireFieldHandlers(app);
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||||
@@ -110,6 +118,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const sectionsDraft: Section[] = existing
|
const sectionsDraft: Section[] = existing
|
||||||
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
: [];
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
@@ -129,6 +138,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<div class="form-group"><label class="label" for="f-public-key">public key</label>
|
<div class="form-group"><label class="label" for="f-public-key">public key</label>
|
||||||
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
|
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -145,6 +155,27 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
wireSectionsEditor(app, sectionsDraft, rerender);
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
const disc = app.querySelector('.attachments-disclosure');
|
||||||
|
if (disc) {
|
||||||
|
disc.outerHTML = renderAttachmentsDisclosure({
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
});
|
||||||
|
wireDisclosure();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
wireDisclosure();
|
||||||
|
|
||||||
// Show/hide toggle for the key_material textarea.
|
// Show/hide toggle for the key_material textarea.
|
||||||
let revealed = false;
|
let revealed = false;
|
||||||
document.getElementById('key-show-btn')?.addEventListener('click', () => {
|
document.getElementById('key-show-btn')?.addEventListener('click', () => {
|
||||||
@@ -159,7 +190,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveKey(mode, existing, sectionsDraft);
|
await saveKey(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
@@ -174,7 +205,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveKey(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
async function saveKey(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -203,7 +234,7 @@ async function saveKey(mode: 'add' | 'edit', existing: Item | null, sectionsDraf
|
|||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: sectionsDraft,
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: attachmentsDraft,
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/// field helpers introduced in Slice 2.
|
/// field helpers introduced in Slice 2.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig } from '../../../shared/types';
|
import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig, AttachmentRef } from '../../../shared/types';
|
||||||
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
||||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||||
import {
|
import {
|
||||||
@@ -15,10 +15,16 @@ import {
|
|||||||
wireSectionsEditor,
|
wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
/// Called by the dispatcher before each render. Stops any in-flight
|
/// Called by the dispatcher before each render. Stops any in-flight
|
||||||
/// tickers / intervals / listeners the previous view may have attached.
|
/// tickers / intervals / listeners the previous view may have attached.
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
stopTotpTicker();
|
stopTotpTicker();
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
@@ -68,6 +74,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
` : ''}
|
` : ''}
|
||||||
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
|
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
|
||||||
${renderSections(item, 'login')}
|
${renderSections(item, 'login')}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -78,6 +85,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
wireFieldHandlers(app);
|
wireFieldHandlers(app);
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||||
teardown();
|
teardown();
|
||||||
@@ -226,6 +234,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const sectionsDraft: Section[] = existing
|
const sectionsDraft: Section[] = existing
|
||||||
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
: [];
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
@@ -249,6 +258,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
||||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
@@ -265,6 +275,27 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
wireSectionsEditor(app, sectionsDraft, rerender);
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
const disc = app.querySelector('.attachments-disclosure');
|
||||||
|
if (disc) {
|
||||||
|
disc.outerHTML = renderAttachmentsDisclosure({
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
});
|
||||||
|
wireDisclosure();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
wireDisclosure();
|
||||||
|
|
||||||
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
||||||
const trigger = e.currentTarget as HTMLElement;
|
const trigger = e.currentTarget as HTMLElement;
|
||||||
if (isGeneratorPanelOpen()) {
|
if (isGeneratorPanelOpen()) {
|
||||||
@@ -292,7 +323,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveLogin(mode, existing, sectionsDraft);
|
await saveLogin(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
@@ -320,7 +351,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||||
@@ -371,7 +402,7 @@ async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDr
|
|||||||
trashed_at: undefined,
|
trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: sectionsDraft,
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: attachmentsDraft,
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
/// detail view; the form is just a big <textarea>.
|
/// detail view; the form is just a big <textarea>.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, ManifestEntry, Section } from '../../../shared/types';
|
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||||
import {
|
import {
|
||||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
renderSectionsEditor, wireSectionsEditor,
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let sectionsExpanded = false;
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
activeKeyHandler = null;
|
activeKeyHandler = null;
|
||||||
@@ -39,6 +45,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
</div>
|
</div>
|
||||||
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
|
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
|
||||||
${renderSections(item, 'secure-note')}
|
${renderSections(item, 'secure-note')}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -48,6 +55,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
wireFieldHandlers(app);
|
wireFieldHandlers(app);
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||||||
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); });
|
||||||
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); });
|
||||||
@@ -99,6 +107,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const sectionsDraft: Section[] = existing
|
const sectionsDraft: Section[] = existing
|
||||||
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
: [];
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
@@ -109,6 +118,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<div class="form-group"><label class="label" for="f-body">body</label>
|
<div class="form-group"><label class="label" for="f-body">body</label>
|
||||||
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
|
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -125,12 +135,33 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
wireSectionsEditor(app, sectionsDraft, rerender);
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveSecureNote(mode, existing, sectionsDraft);
|
await saveSecureNote(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
@@ -145,7 +176,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
const body = (document.getElementById('f-body') as HTMLTextAreaElement).value;
|
const body = (document.getElementById('f-body') as HTMLTextAreaElement).value;
|
||||||
@@ -164,7 +195,7 @@ async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null, secti
|
|||||||
trashed_at: undefined,
|
trashed_at: undefined,
|
||||||
core: { type: 'secure_note', body },
|
core: { type: 'secure_note', body },
|
||||||
sections: sectionsDraft,
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: attachmentsDraft,
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
/// (TOTP vs Steam Guard) and a single secret input.
|
/// (TOTP vs Steam Guard) and a single secret input.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, ManifestEntry, Section, TotpKind } from '../../../shared/types';
|
import type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types';
|
||||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||||
import {
|
import {
|
||||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
renderSectionsEditor, wireSectionsEditor,
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
import {
|
||||||
|
renderAttachmentsDisclosure,
|
||||||
|
wireAttachmentsDisclosure,
|
||||||
|
teardownAttachmentsDisclosure,
|
||||||
|
} from '../attachments-disclosure';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
// Module-scope lifecycle state
|
// Module-scope lifecycle state
|
||||||
@@ -26,6 +31,7 @@ function stopTotpTicker(): void {
|
|||||||
/// Called by the dispatcher before each render. Stops the countdown ticker
|
/// Called by the dispatcher before each render. Stops the countdown ticker
|
||||||
/// AND removes the detail-view's keyboard handler so they don't leak.
|
/// AND removes the detail-view's keyboard handler so they don't leak.
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
|
teardownAttachmentsDisclosure();
|
||||||
stopTotpTicker();
|
stopTotpTicker();
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
document.removeEventListener('keydown', activeKeyHandler);
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
@@ -87,6 +93,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
|
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
|
||||||
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
|
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
|
||||||
${renderSections(item, 'totp')}
|
${renderSections(item, 'totp')}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -96,6 +103,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
wireFieldHandlers(app);
|
wireFieldHandlers(app);
|
||||||
|
wireAttachmentsDisclosure(app, { itemId: item.id, attachments: item.attachments, mode: 'view' });
|
||||||
|
|
||||||
// Start the ticker — re-fetches code + countdown every second from the SW.
|
// Start the ticker — re-fetches code + countdown every second from the SW.
|
||||||
startTotpTicker(item.id, c.config.period_seconds || 30);
|
startTotpTicker(item.id, c.config.period_seconds || 30);
|
||||||
@@ -200,6 +208,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const sectionsDraft: Section[] = existing
|
const sectionsDraft: Section[] = existing
|
||||||
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
: [];
|
: [];
|
||||||
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
const renderInner = (): string => `
|
const renderInner = (): string => `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
@@ -221,6 +230,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<div class="form-group"><label class="label" for="f-label">label</label>
|
<div class="form-group"><label class="label" for="f-label">label</label>
|
||||||
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
|
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
|
${renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' })}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -248,8 +258,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
|
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
|
||||||
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
|
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
|
||||||
wireKindToggle();
|
wireKindToggle();
|
||||||
wireFormButtons(mode, existing, sectionsDraft);
|
wireFormButtons(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
|
wireDisclosure();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rerender only the sections editor in place (used by structural section
|
// Rerender only the sections editor in place (used by structural section
|
||||||
@@ -264,6 +275,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wireDisclosure = (): void => {
|
||||||
|
wireAttachmentsDisclosure(app, {
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
onChange: (next) => {
|
||||||
|
attachmentsDraft = next;
|
||||||
|
const disc = app.querySelector('.attachments-disclosure');
|
||||||
|
if (disc) {
|
||||||
|
disc.outerHTML = renderAttachmentsDisclosure({
|
||||||
|
itemId: existing?.id ?? '',
|
||||||
|
attachments: attachmentsDraft,
|
||||||
|
mode: 'edit',
|
||||||
|
});
|
||||||
|
wireDisclosure();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const wireKindToggle = (): void => {
|
const wireKindToggle = (): void => {
|
||||||
document.getElementById('kind-totp')?.addEventListener('click', () => {
|
document.getElementById('kind-totp')?.addEventListener('click', () => {
|
||||||
formKind = 'totp';
|
formKind = 'totp';
|
||||||
@@ -276,8 +307,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
|
|
||||||
wireKindToggle();
|
wireKindToggle();
|
||||||
wireFormButtons(mode, existing, sectionsDraft);
|
wireFormButtons(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
|
wireDisclosure();
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -291,17 +323,17 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireFormButtons(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): void {
|
function wireFormButtons(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): void {
|
||||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveTotp(mode, existing, sectionsDraft);
|
await saveTotp(mode, existing, sectionsDraft, attachmentsDraft);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTotp(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
async function saveTotp(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -345,7 +377,7 @@ async function saveTotp(mode: 'add' | 'edit', existing: Item | null, sectionsDra
|
|||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: sectionsDraft,
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: attachmentsDraft,
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ textarea {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-row__attach-indicator {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Detail view */
|
/* Detail view */
|
||||||
.detail-header {
|
.detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user