feat(extension): trash pane revamp — per-item purge countdown + glyph restore
This commit is contained in:
@@ -52,7 +52,8 @@ describe('trash view', () => {
|
|||||||
await renderTrash(app);
|
await renderTrash(app);
|
||||||
|
|
||||||
expect(app.innerHTML).toContain('Test Login');
|
expect(app.innerHTML).toContain('Test Login');
|
||||||
expect(app.innerHTML).toContain('restore');
|
expect(app.querySelector('[data-restore]')).not.toBeNull();
|
||||||
|
expect(app.innerHTML).toContain('purges in');
|
||||||
expect(app.querySelector('#empty-trash-btn')).not.toBeNull();
|
expect(app.querySelector('#empty-trash-btn')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { relativeTime, daysUntilPurge } from '../../shared/relative-time';
|
|||||||
import {
|
import {
|
||||||
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
|
||||||
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
|
||||||
|
GLYPH_RESTORE,
|
||||||
} from '../../shared/glyphs';
|
} from '../../shared/glyphs';
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
@@ -25,7 +26,6 @@ export function teardown(): void {
|
|||||||
export async function renderTrash(app: HTMLElement): Promise<void> {
|
export async function renderTrash(app: HTMLElement): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
|
||||||
// Fetch trashed items
|
|
||||||
const resp = await sendMessage({ type: 'list_trashed' });
|
const resp = await sendMessage({ type: 'list_trashed' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
|
||||||
@@ -35,7 +35,6 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
|||||||
const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items;
|
const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items;
|
||||||
const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 };
|
const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 };
|
||||||
|
|
||||||
// Calculate days until oldest auto-purges
|
|
||||||
let oldestPurgeDays: number | null = null;
|
let oldestPurgeDays: number | null = null;
|
||||||
if (items.length > 0 && retention.kind === 'days') {
|
if (items.length > 0 && retention.kind === 'days') {
|
||||||
const oldest = items[items.length - 1][1];
|
const oldest = items[items.length - 1][1];
|
||||||
@@ -45,8 +44,8 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
|||||||
const headerInfo = items.length === 0
|
const headerInfo = items.length === 0
|
||||||
? ''
|
? ''
|
||||||
: oldestPurgeDays !== null
|
: oldestPurgeDays !== null
|
||||||
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest auto-purges in ${oldestPurgeDays}d`
|
? `${items.length} item${items.length === 1 ? '' : 's'} · oldest purges in ${oldestPurgeDays} days`
|
||||||
: `${items.length} item${items.length === 1 ? '' : 's'}`;
|
: `${items.length} item${items.length === 1 ? '' : 's'} · retained forever`;
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
@@ -57,25 +56,30 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
|||||||
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
|
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
|
||||||
${items.length === 0
|
${items.length === 0
|
||||||
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
|
||||||
: items.map(([id, entry]) => `
|
: `<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)}">
|
<div class="trash-row" data-id="${escapeHtml(id)}">
|
||||||
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '◻'}</span>
|
||||||
<div class="trash-row__info">
|
<div class="trash-row__info">
|
||||||
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
|
||||||
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
|
<span class="trash-row__meta muted">trashed ${escapeHtml(relativeTime(entry.trashed_at ?? 0))} · ${escapeHtml(purgeStr)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="trash-row__restore" data-restore="${escapeHtml(id)}">restore</button>
|
<button class="glyph-btn" data-restore="${escapeHtml(id)}" title="restore" aria-label="restore ${escapeHtml(entry.title)}">${GLYPH_RESTORE}</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`;
|
||||||
|
}).join('')}`
|
||||||
|
}
|
||||||
${items.length > 0 ? `
|
${items.length > 0 ? `
|
||||||
<div style="margin-top:16px;text-align:center;">
|
<div class="trash-footer">
|
||||||
<button class="btn danger" id="empty-trash-btn">empty trash</button>
|
<button class="btn btn-danger" id="empty-trash-btn">empty trash</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Wire handlers
|
|
||||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((btn) => {
|
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((btn) => {
|
||||||
@@ -90,14 +94,14 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
|||||||
renderTrash(app);
|
renderTrash(app);
|
||||||
} else {
|
} else {
|
||||||
setState({ error: result.error });
|
setState({ error: result.error });
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⤺';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('empty-trash-btn')?.addEventListener('click', async () => {
|
document.getElementById('empty-trash-btn')?.addEventListener('click', async () => {
|
||||||
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) {
|
if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement;
|
const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'deleting...';
|
btn.textContent = 'deleting...';
|
||||||
@@ -107,6 +111,8 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
|
|||||||
renderTrash(app);
|
renderTrash(app);
|
||||||
} else {
|
} else {
|
||||||
setState({ error: result.error });
|
setState({ error: result.error });
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'empty trash';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1110,54 +1110,18 @@ textarea {
|
|||||||
.trash-row {
|
.trash-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 8px;
|
padding: 8px 0;
|
||||||
border-radius: 4px;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
background: #161b22;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
.trash-row__icon { font-size: 14px; }
|
||||||
.trash-row__icon {
|
.trash-row__info { flex: 1; display: flex; flex-direction: column; }
|
||||||
font-size: 16px;
|
.trash-row__title { color: var(--text); }
|
||||||
flex-shrink: 0;
|
.trash-row__meta { font-size: 11px; color: var(--text-muted); }
|
||||||
}
|
.trash-footer {
|
||||||
|
display: flex;
|
||||||
.trash-row__info {
|
justify-content: flex-end;
|
||||||
flex: 1;
|
margin-top: 16px;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__title {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #c9d1d9;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__restore {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: #238636;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__restore:hover {
|
|
||||||
background: #2ea043;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__restore:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Devices view --- */
|
/* --- Devices view --- */
|
||||||
|
|||||||
@@ -1030,54 +1030,18 @@ textarea {
|
|||||||
.trash-row {
|
.trash-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 8px;
|
padding: 8px 0;
|
||||||
border-radius: 4px;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
background: #161b22;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
.trash-row__icon { font-size: 14px; }
|
||||||
.trash-row__icon {
|
.trash-row__info { flex: 1; display: flex; flex-direction: column; }
|
||||||
font-size: 16px;
|
.trash-row__title { color: var(--text); }
|
||||||
flex-shrink: 0;
|
.trash-row__meta { font-size: 11px; color: var(--text-muted); }
|
||||||
}
|
.trash-footer {
|
||||||
|
display: flex;
|
||||||
.trash-row__info {
|
justify-content: flex-end;
|
||||||
flex: 1;
|
margin-top: 16px;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__title {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #c9d1d9;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__restore {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: #238636;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__restore:hover {
|
|
||||||
background: #2ea043;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-row__restore:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Devices view --- */
|
/* --- Devices view --- */
|
||||||
|
|||||||
Reference in New Issue
Block a user