119 lines
4.6 KiB
TypeScript
119 lines
4.6 KiB
TypeScript
/// Trash view — lists soft-deleted items with restore/purge actions.
|
|
|
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
|
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
|
|
import { relativeTime, daysUntilPurge } from '../../shared/relative-time';
|
|
import {
|
|
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
|
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
|
GLYPH_RESTORE,
|
|
} from '../../shared/glyphs';
|
|
|
|
const TYPE_ICONS: Record<string, string> = {
|
|
login: GLYPH_TYPE_LOGIN,
|
|
secure_note: GLYPH_TYPE_SECURE_NOTE,
|
|
identity: GLYPH_TYPE_IDENTITY,
|
|
card: GLYPH_TYPE_CARD,
|
|
key: GLYPH_TYPE_KEY,
|
|
document: GLYPH_TYPE_DOCUMENT,
|
|
totp: GLYPH_TYPE_TOTP,
|
|
};
|
|
|
|
export function teardown(): void {
|
|
// No cleanup needed
|
|
}
|
|
|
|
export async function renderTrash(app: HTMLElement): Promise<void> {
|
|
const state = getState();
|
|
|
|
const resp = await sendMessage({ type: 'list_trashed' });
|
|
if (!resp.ok) {
|
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
|
|
return;
|
|
}
|
|
|
|
const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items;
|
|
const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 };
|
|
|
|
let oldestPurgeDays: number | null = null;
|
|
if (items.length > 0 && retention.kind === 'days') {
|
|
const oldest = items[items.length - 1][1];
|
|
oldestPurgeDays = daysUntilPurge(oldest.trashed_at ?? 0, retention);
|
|
}
|
|
|
|
const headerInfo = items.length === 0
|
|
? ''
|
|
: oldestPurgeDays !== null
|
|
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest purges in ${oldestPurgeDays} days`
|
|
: `${items.length} item${items.length === 1 ? '' : 's'} · retained forever`;
|
|
|
|
app.innerHTML = `
|
|
<div class="pad">
|
|
<div class="trash-header">
|
|
<button class="btn" id="back-btn">← back</button>
|
|
<h3 style="margin:0;">trash</h3>
|
|
</div>
|
|
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
|
|
${items.length === 0
|
|
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
|
: `<div class="section-header"> </div>
|
|
${items.map(([id, entry]) => {
|
|
const purgeIn = daysUntilPurge(entry.trashed_at ?? 0, retention);
|
|
const purgeStr = purgeIn === null ? 'retained forever' : `purges in ${purgeIn} days`;
|
|
return `
|
|
<div class="trash-row" data-id="${escapeHtml(id)}">
|
|
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
|
<div class="trash-row__info">
|
|
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
|
<span class="trash-row__meta muted">trashed ${escapeHtml(relativeTime(entry.trashed_at ?? 0))} · ${escapeHtml(purgeStr)}</span>
|
|
</div>
|
|
<button class="glyph-btn" data-restore="${escapeHtml(id)}" title="restore" aria-label="restore ${escapeHtml(entry.title)}">${GLYPH_RESTORE}</button>
|
|
</div>
|
|
`;
|
|
}).join('')}`
|
|
}
|
|
${items.length > 0 ? `
|
|
<div class="trash-footer">
|
|
<button class="btn btn-danger" id="empty-trash-btn">empty trash</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
|
|
|
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = btn.dataset.restore;
|
|
if (!id) return;
|
|
btn.disabled = true;
|
|
btn.textContent = '...';
|
|
const result = await sendMessage({ type: 'restore_item', id });
|
|
if (result.ok) {
|
|
await sendMessage({ type: 'sync' });
|
|
renderTrash(app);
|
|
} else {
|
|
setState({ error: result.error });
|
|
btn.disabled = false;
|
|
btn.textContent = '⤺';
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById('empty-trash-btn')?.addEventListener('click', async () => {
|
|
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) return;
|
|
const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement;
|
|
btn.disabled = true;
|
|
btn.textContent = 'deleting...';
|
|
const result = await sendMessage({ type: 'purge_all_trash' });
|
|
if (result.ok) {
|
|
await sendMessage({ type: 'sync' });
|
|
renderTrash(app);
|
|
} else {
|
|
setState({ error: result.error });
|
|
btn.disabled = false;
|
|
btn.textContent = 'empty trash';
|
|
}
|
|
});
|
|
}
|