feat(admin): archived-items page with purge dates, restore, and purge

Admins need to see what the archive lifecycle is holding: a dedicated
/admin/archived page listing every hidden item (archived or still in
the missing grace window) with library, file path, status, and - when
retention is enabled - the exact date it will be permanently deleted
(archived_at + ARCHIVE_RETENTION_DAYS).

Per row: Restore (POST /api/media-items/:id/unarchive, clears the
archive state so it reappears; if the file is still gone the next scan
hides it again) and Delete (existing DELETE endpoint for single-row
purge with its reading history). Purge All Archived reuses the existing
bulk button. The library admin banner links to the page and keeps its
purge button; row actions use data attributes with delegated listeners
in admin.ts since this templ version has no JSFunctionCall helper.

Verified live: page 200 with purge dates shown, unarchive returned 204
and reset the row, per-item delete and bulk purge ({purged:1}) both
removed their rows with no leftovers.
This commit is contained in:
John O'Keefe
2026-09-13 12:01:05 -04:00
parent fe5ab9e5f8
commit aac7c72900
8 changed files with 529 additions and 43 deletions
+61
View File
@@ -289,6 +289,67 @@ async function purgeArchivedItems(): Promise<void> {
(window as any).purgeArchivedItems = purgeArchivedItems;
// Restore a hidden/archived item to library visibility without waiting for
// its file to return (POST /api/media-items/:id/unarchive). If the file is
// still gone the next scan hides it again.
async function unarchiveItem(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch(`/api/media-items/${id}/unarchive`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to restore item");
}
showToast("Item restored to libraries", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to restore item", "error");
}
}
// Permanently delete one archived item and its reading history
// (DELETE /api/media-items/:id).
async function deleteArchivedItem(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
)
) {
return;
}
try {
const resp = await fetch(`/api/media-items/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete item");
}
showToast("Item deleted", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to delete item", "error");
}
}
// The archived-items page renders per-row buttons with data attributes
// (escaping-safe); bind them here. Module scripts run after DOM parse.
document.querySelectorAll<HTMLElement>("[data-unarchive]").forEach((el) => {
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
});
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
el.addEventListener("click", () =>
deleteArchivedItem(el.dataset.deleteArchived || ""),
);
});
export {
hideScanProgress,
loadWatchStatus,