diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx
index b477e6a..334b791 100644
--- a/src/routes/admin.tsx
+++ b/src/routes/admin.tsx
@@ -10,6 +10,7 @@ import { Layout, clampText } from "./admin/ui";
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
import { feedsRouter } from "./admin/feeds";
import { emailsRouter } from "./admin/emails";
+import { dashboardScript } from "../scripts/generated/dashboard";
type AppEnv = { Bindings: Env };
@@ -220,657 +221,8 @@ app.get("/logout", (c) => {
return c.redirect("/admin/login");
});
-// The large inline script for the dashboard page
-const dashboardScript = `
- let FEED_ROWS = [];
- let FEED_CHECKBOXES = [];
- let FEED_SELECTED_COUNT_EL = null;
- let FEED_MATCH_COUNT_EL = null;
- let FEED_TOTAL_COUNT_EL = null;
- let FEED_BULK_DELETE_BUTTON_EL = null;
- let FEED_SELECT_ALL_EL = null;
- let FEED_FILTER_TIMER = null;
- let FEED_BULK_DELETE_IN_PROGRESS = false;
- let FEED_SORT_KEY = 'title';
- let FEED_SORT_DIR = 'asc';
- const FEED_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
-
- function initFeedUI() {
- FEED_ROWS = Array.from(document.querySelectorAll('.feed-row'));
- FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select'));
- FEED_SELECTED_COUNT_EL = document.getElementById('selected-feed-count');
- FEED_MATCH_COUNT_EL = document.getElementById('feed-match-count');
- FEED_TOTAL_COUNT_EL = document.getElementById('feed-total-count');
- FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button');
- FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
- setupFeedTableResizing();
- setupFeedTableSorting();
- setupFeedDeleteButtons();
- updateFeedMatchCount();
- updateFeedSelectionState();
- }
-
- function updateFeedMatchCount() {
- if (!FEED_MATCH_COUNT_EL) return;
- const total = FEED_ROWS.length;
- const visible = FEED_ROWS.filter((row) => !row.hidden).length;
- const query = (document.getElementById('feed-search')?.value || '').trim();
- FEED_MATCH_COUNT_EL.textContent = query ? ('Showing ' + visible + ' of ' + total) : ('Showing ' + total);
- }
-
- function scheduleFeedFilter() {
- if (FEED_FILTER_TIMER) {
- clearTimeout(FEED_FILTER_TIMER);
- }
- FEED_FILTER_TIMER = setTimeout(filterFeedRows, 120);
- }
-
- function getSortValue(row, key) {
- const prop = 'sort' + key.charAt(0).toUpperCase() + key.slice(1);
- return (row.dataset && row.dataset[prop]) ? row.dataset[prop] : '';
- }
-
- function updateFeedSortIndicators(table) {
- const headerCells = Array.from(table.querySelectorAll('th[data-sort-key]'));
- headerCells.forEach((th) => {
- const key = th.getAttribute('data-sort-key') || '';
- const indicator = th.querySelector('.sort-indicator');
- const active = key === FEED_SORT_KEY;
-
- if (indicator) {
- indicator.textContent = active ? (FEED_SORT_DIR === 'asc' ? '^' : 'v') : '';
- }
- th.setAttribute('aria-sort', active ? (FEED_SORT_DIR === 'asc' ? 'ascending' : 'descending') : 'none');
- });
- }
-
- function sortFeedTableBy(key) {
- const table = document.querySelector('table.table-feeds');
- const tbody = document.getElementById('feed-table-body');
- if (!table || !tbody) return;
-
- if (FEED_SORT_KEY === key) {
- FEED_SORT_DIR = FEED_SORT_DIR === 'asc' ? 'desc' : 'asc';
- } else {
- FEED_SORT_KEY = key;
- FEED_SORT_DIR = 'asc';
- }
-
- const dirMultiplier = FEED_SORT_DIR === 'asc' ? 1 : -1;
- const rows = Array.from(tbody.querySelectorAll('.feed-row'));
- rows.sort((a, b) => {
- const av = getSortValue(a, FEED_SORT_KEY);
- const bv = getSortValue(b, FEED_SORT_KEY);
- return dirMultiplier * FEED_COLLATOR.compare(av, bv);
- });
-
- const fragment = document.createDocumentFragment();
- rows.forEach((row) => fragment.appendChild(row));
- tbody.appendChild(fragment);
-
- updateFeedSortIndicators(table);
- }
-
- function setupFeedTableSorting() {
- const table = document.querySelector('table.table-feeds');
- if (!table) return;
-
- table.querySelectorAll('button.th-button[data-sort-key]').forEach((button) => {
- button.addEventListener('click', () => {
- const key = button.getAttribute('data-sort-key') || '';
- if (!key) return;
- sortFeedTableBy(key);
- });
- });
-
- updateFeedSortIndicators(table);
- }
-
- function setupFeedTableResizing() {
- const table = document.querySelector('table.table-feeds');
- if (!table) return;
-
- const storageKey = 'email-to-rss.admin.feedsTable.colWidths';
- const minWidths = {
- title: 220,
- feedId: 120,
- email: 160,
- rss: 160,
- actions: 160,
- };
- const defaultWidths = {
- title: 340,
- feedId: 160,
- email: 220,
- rss: 220,
- actions: 200,
- };
-
- const cols = Array.from(table.querySelectorAll('colgroup col'));
- const colByKey = {};
- cols.forEach((col) => {
- const key = col.getAttribute('data-col');
- if (key) colByKey[key] = col;
- });
-
- // Restore widths
- try {
- const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
- Object.keys(saved || {}).forEach((key) => {
- const px = Number(saved[key]);
- if (!colByKey[key] || !Number.isFinite(px)) return;
- colByKey[key].style.width = px + 'px';
- });
- } catch {
- // Ignore bad localStorage values
- }
-
- const persist = () => {
- try {
- const out = {};
- Object.keys(colByKey).forEach((key) => {
- if (key === 'select') return;
- const px = parseInt(colByKey[key].style.width || '0', 10);
- if (Number.isFinite(px) && px > 0) out[key] = px;
- });
- localStorage.setItem(storageKey, JSON.stringify(out));
- } catch {
- // localStorage may be unavailable in some modes; ignore
- }
- };
-
- let active = null;
- let rafId = 0;
- let pendingWidth = 0;
-
- table.querySelectorAll('.col-resizer').forEach((handle) => {
- handle.addEventListener('pointerdown', (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- const key = handle.getAttribute('data-col');
- const col = key ? colByKey[key] : null;
- if (!key || !col) return;
-
- const th = handle.closest('th');
- const startWidth = th ? th.getBoundingClientRect().width : parseInt(col.style.width || '0', 10) || 120;
-
- active = { key, col, startX: event.clientX, startWidth };
- document.body.classList.add('is-resizing');
- handle.setPointerCapture(event.pointerId);
- });
-
- handle.addEventListener('pointermove', (event) => {
- if (!active) return;
- const minPx = minWidths[active.key] || 120;
- const nextWidth = Math.max(minPx, Math.round(active.startWidth + (event.clientX - active.startX)));
- pendingWidth = nextWidth;
- if (rafId) return;
- rafId = requestAnimationFrame(() => {
- active.col.style.width = pendingWidth + 'px';
- rafId = 0;
- });
- });
-
- const finish = () => {
- if (!active) return;
- active = null;
- document.body.classList.remove('is-resizing');
- persist();
- };
- handle.addEventListener('pointerup', finish);
- handle.addEventListener('pointercancel', finish);
-
- handle.addEventListener('dblclick', (event) => {
- event.preventDefault();
- event.stopPropagation();
- const key = handle.getAttribute('data-col');
- const col = key ? colByKey[key] : null;
- const px = key ? defaultWidths[key] : null;
- if (!key || !col || !px) return;
- col.style.width = px + 'px';
- persist();
- });
- });
- }
-
- const DELETE_CONFIRM_LABEL = 'Confirm delete';
- const DELETE_LOADING_LABEL = 'Deleting...';
- const DELETE_CONFIRM_TIMEOUT_MS = 4000;
-
- function getDeleteView() {
- return new URL(window.location.href).searchParams.get('view') || 'list';
- }
-
- function resetDeleteButton(buttonEl) {
- if (!buttonEl) return;
- buttonEl.classList.remove('is-confirming');
- buttonEl.removeAttribute('data-confirming');
- buttonEl.disabled = false;
- const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim() || 'Delete';
- buttonEl.innerHTML = original;
- }
-
- function animateRowRemoval(row, onDone) {
- if (!row) {
- if (onDone) onDone();
- return;
- }
-
- const isListItem = row.tagName.toLowerCase() === 'li';
- if (isListItem) {
- row.style.maxHeight = row.getBoundingClientRect().height + 'px';
- row.style.overflow = 'hidden';
- }
-
- row.classList.add('is-removing');
-
- requestAnimationFrame(() => {
- if (isListItem) {
- row.style.maxHeight = '0px';
- row.style.marginTop = '0px';
- row.style.marginBottom = '0px';
- row.style.paddingTop = '0px';
- row.style.paddingBottom = '0px';
- }
- });
-
- window.setTimeout(() => {
- row.remove();
- if (onDone) onDone();
- }, 240);
- }
-
- async function deleteFeedRequest(feedId, view) {
- const res = await fetch('/admin/feeds/' + encodeURIComponent(feedId) + '/delete?view=' + encodeURIComponent(view), {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- },
- credentials: 'same-origin',
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')');
- throw new Error(message);
- }
- return data;
- }
-
- function refreshFeedRowCache() {
- FEED_ROWS = Array.from(document.querySelectorAll('.feed-row'));
- FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select'));
- if (FEED_TOTAL_COUNT_EL) {
- FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length);
- }
- updateFeedMatchCount();
- updateFeedSelectionState();
- }
-
- function setupFeedDeleteButtons() {
- const buttons = Array.from(document.querySelectorAll('button[data-delete-kind="feed"]'));
- buttons.forEach((button) => {
- if (button.dataset.deleteReady === 'true') return;
- button.dataset.deleteReady = 'true';
- const original = (button.textContent || '').trim() || 'Delete';
- button.dataset.originalLabel = original;
-
- let confirming = false;
- let confirmTimer = 0;
- let inFlight = false;
-
- const startConfirm = () => {
- confirming = true;
- button.classList.add('is-confirming');
- button.setAttribute('data-confirming', 'true');
- button.innerHTML = DELETE_CONFIRM_LABEL;
- if (confirmTimer) window.clearTimeout(confirmTimer);
- confirmTimer = window.setTimeout(() => {
- confirming = false;
- resetDeleteButton(button);
- }, DELETE_CONFIRM_TIMEOUT_MS);
- };
-
- button.addEventListener('click', async (event) => {
- event.preventDefault();
- if (inFlight) return;
-
- if (!confirming) {
- startConfirm();
- return;
- }
-
- if (confirmTimer) window.clearTimeout(confirmTimer);
- inFlight = true;
- setButtonLoading(button, true, DELETE_LOADING_LABEL);
-
- const toast = window.showToast
- ? window.showToast('Deleting feed...', { type: 'info', loading: true, duration: 0 })
- : null;
-
- const feedId = button.getAttribute('data-feed-id') || '';
- const view = button.getAttribute('data-view') || getDeleteView();
- const row = button.closest('.feed-row');
-
- try {
- await deleteFeedRequest(feedId, view);
-
- if (toast && toast.update) {
- toast.update('Feed deleted.', { type: 'success', loading: false, duration: 3200 });
- } else if (window.showToast) {
- window.showToast('Feed deleted.', { type: 'success' });
- }
-
- animateRowRemoval(row, () => {
- refreshFeedRowCache();
- });
- } catch (error) {
- if (toast && toast.update) {
- toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false });
- } else if (window.showToast) {
- window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error' });
- }
- setButtonLoading(button, false);
- confirming = false;
- resetDeleteButton(button);
- } finally {
- inFlight = false;
- if (!row) {
- setButtonLoading(button, false);
- confirming = false;
- resetDeleteButton(button);
- }
- }
- });
-
- button.addEventListener('keydown', (event) => {
- if (event.key === 'Escape' && confirming && !inFlight) {
- confirming = false;
- if (confirmTimer) window.clearTimeout(confirmTimer);
- resetDeleteButton(button);
- }
- });
- });
- }
-
- function updateFeedSelectionState() {
- if (!FEED_CHECKBOXES.length) {
- return;
- }
-
- const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked);
-
- if (FEED_SELECTED_COUNT_EL) {
- FEED_SELECTED_COUNT_EL.textContent = selected.length + ' selected';
- }
- if (FEED_BULK_DELETE_BUTTON_EL) {
- FEED_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0;
- }
- if (FEED_SELECT_ALL_EL) {
- const visibleCheckboxes = FEED_CHECKBOXES.filter((checkbox) => !(checkbox.closest('tr')?.hidden));
- FEED_SELECT_ALL_EL.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked);
- }
- }
-
- function toggleAllFeeds(checked) {
- FEED_CHECKBOXES.forEach((checkbox) => {
- if (!checkbox.closest('tr')?.hidden) {
- checkbox.checked = checked;
- }
- })
- updateFeedSelectionState();
- }
-
- function setVisibleFeedSelection(checked) {
- FEED_CHECKBOXES.forEach((checkbox) => {
- if (!checkbox.closest('tr')?.hidden) {
- checkbox.checked = checked;
- }
- })
- updateFeedSelectionState();
- }
-
- function selectMatchingFeeds() {
- setVisibleFeedSelection(true);
- }
-
- function clearFeedSelection() {
- FEED_CHECKBOXES.forEach((checkbox) => {
- checkbox.checked = false;
- })
- updateFeedSelectionState();
- }
-
- function filterFeedRows() {
- const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim();
- FEED_ROWS.forEach((row) => {
- const haystack = row.getAttribute('data-search') || '';
- row.hidden = !!query && !haystack.includes(query);
- });
- updateFeedMatchCount();
- updateFeedSelectionState();
- }
-
- function confirmBulkFeedDelete() {
- const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
- if (selected === 0) return false;
-
- const query = (document.getElementById('feed-search')?.value || '').trim();
- const extra =
- selected >= 50 && !query
- ? '\\n\\nThis is a large delete. Tip: use Search to narrow down spam first.'
- : '';
- return confirm(
- 'Delete ' +
- selected +
- ' selected feed(s)? This disables the feeds immediately. Stored emails are cleaned up best-effort and may take a while.' +
- extra,
- );
- }
-
- function setButtonLoading(buttonEl, loading, label) {
- if (!buttonEl) return;
- if (loading) {
- if (!buttonEl.dataset.originalLabel) {
- buttonEl.dataset.originalLabel = (buttonEl.textContent || '').trim();
- }
- const text = label || 'Working...';
- buttonEl.classList.add('is-loading');
- buttonEl.disabled = true;
- buttonEl.innerHTML = '' + text;
- return;
- }
-
- const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim();
- buttonEl.classList.remove('is-loading');
- buttonEl.innerHTML = original;
- }
-
- function removeFeedRowsById(feedIds) {
- const toRemove = new Set((feedIds || []).map((v) => String(v)));
- if (toRemove.size === 0) return;
-
- FEED_ROWS.forEach((row) => {
- const checkbox = row.querySelector('input.feed-select');
- const id = checkbox ? checkbox.value : '';
- if (toRemove.has(id)) {
- row.remove();
- }
- });
-
- FEED_ROWS = Array.from(document.querySelectorAll('.feed-row'));
- FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select'));
-
- if (FEED_TOTAL_COUNT_EL) {
- FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length);
- }
- }
-
- function onBulkFeedDeleteSubmit(event) {
- if (event && event.preventDefault) event.preventDefault();
- void bulkDeleteSelectedFeeds();
- return false;
- }
-
- async function bulkDeleteSelectedFeeds() {
- if (FEED_BULK_DELETE_IN_PROGRESS) return;
- const selectedIds = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);
- if (selectedIds.length === 0) {
- if (window.showToast) window.showToast('No feeds selected.', { type: 'info' });
- return;
- }
- if (!confirmBulkFeedDelete()) {
- return;
- }
-
- FEED_BULK_DELETE_IN_PROGRESS = true;
- setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, true, 'Deleting...');
-
- const toast = window.showToast
- ? window.showToast('Deleting ' + selectedIds.length + ' feed(s)...', { type: 'info', loading: true, duration: 0 })
- : null;
-
- const batchSize = 10;
- let deletedTotal = 0;
- const failed = [];
-
- try {
- for (let i = 0; i < selectedIds.length; i += batchSize) {
- const batch = selectedIds.slice(i, i + batchSize);
- const res = await fetch('/admin/feeds/bulk-delete', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
- },
- credentials: 'same-origin',
- body: JSON.stringify({ feedIds: batch }),
- });
-
- let data = {};
- if (window.parseJsonResponseOrThrow) {
- data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk feed delete failed' });
- } else {
- data = await res.json().catch(() => ({}));
- if (!res.ok) {
- const message = data && data.error ? String(data.error) : ('Bulk feed delete failed (HTTP ' + res.status + ')');
- throw new Error(message);
- }
- }
-
- const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch;
- const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : [];
- const failureDetails = Array.isArray(data.failures) ? data.failures : [];
-
- removeFeedRowsById(deletedIds);
- deletedTotal += deletedIds.length;
-
- if (toast && toast.update) {
- const done = Math.min(i + batch.length, selectedIds.length);
- toast.update('Deleting... (' + done + ' of ' + selectedIds.length + ')', { type: 'info' });
- }
-
- // Keep selection state consistent as rows disappear.
- updateFeedMatchCount();
- updateFeedSelectionState();
-
- // If a batch fails for some feeds, retry those one-by-one using the bulk-delete
- // endpoint with a single id (keeps semantics consistent and avoids hiding active feeds).
- if (failedIds.length > 0) {
- if (toast && toast.update) {
- toast.update('Retrying ' + failedIds.length + ' failed feed(s) one-by-one...', { type: 'info' });
- }
-
- const stillFailed = [];
- for (let j = 0; j < failedIds.length; j++) {
- const feedId = String(failedIds[j] || '');
- if (!feedId) continue;
- try {
- const retryRes = await fetch('/admin/feeds/bulk-delete', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
- },
- credentials: 'same-origin',
- body: JSON.stringify({ feedIds: [feedId] }),
- });
-
- let retryData = {};
- if (window.parseJsonResponseOrThrow) {
- retryData = await window.parseJsonResponseOrThrow(retryRes, { prefix: 'Retry delete failed' });
- } else {
- retryData = await retryRes.json().catch(() => ({}));
- if (!retryRes.ok) {
- const message = retryData && retryData.error ? String(retryData.error) : ('Retry delete failed (HTTP ' + retryRes.status + ')');
- throw new Error(message);
- }
- }
-
- const retryDeleted = Array.isArray(retryData.deletedFeedIds) ? retryData.deletedFeedIds : [];
- const retryFailed = Array.isArray(retryData.failedFeedIds) ? retryData.failedFeedIds : [];
-
- if (retryDeleted.includes(feedId)) {
- removeFeedRowsById([feedId]);
- deletedTotal += 1;
- } else if (retryFailed.includes(feedId)) {
- stillFailed.push(feedId);
- } else {
- stillFailed.push(feedId);
- }
- } catch (e) {
- stillFailed.push(feedId);
- }
-
- if (toast && toast.update) {
- toast.update('Retrying... (' + (j + 1) + ' of ' + failedIds.length + ')', { type: 'info' });
- }
- }
-
- // Replace failed ids from this batch with only the ones that still failed after retry.
- if (stillFailed.length > 0) {
- failed.push(...stillFailed);
- if (window.showToast && failureDetails.length > 0) {
- const first = failureDetails[0] && failureDetails[0].error ? String(failureDetails[0].error) : '';
- if (first) {
- window.showToast('Some feeds failed to delete: ' + first, { type: 'error' });
- }
- }
- }
-
- updateFeedMatchCount();
- updateFeedSelectionState();
- }
- }
-
- if (toast && toast.dismiss) toast.dismiss();
- const uniqueFailed = Array.from(new Set(failed.map((v) => String(v)).filter(Boolean)));
- if (uniqueFailed.length > 0) {
- if (window.showToast) {
- window.showToast(
- 'Deleted ' + deletedTotal + ' feed(s). ' + uniqueFailed.length + ' failed (still visible).',
- { type: 'error' },
- );
- }
- } else {
- if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' feed(s).', { type: 'success' });
- }
- } catch (error) {
- if (toast && toast.dismiss) toast.dismiss();
- if (window.showToast) {
- window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error' });
- }
- } finally {
- FEED_BULK_DELETE_IN_PROGRESS = false;
- setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, false);
- updateFeedSelectionState();
- }
- }
-
- document.addEventListener('DOMContentLoaded', () => {
- initFeedUI();
- });
-`;
+// dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`.
+// It is imported from src/scripts/generated/dashboard.ts above.
// ── Shared SVG icons ──────────────────────────────────────────────────────────
diff --git a/src/scripts/client/dashboard.ts b/src/scripts/client/dashboard.ts
new file mode 100644
index 0000000..fe96eac
--- /dev/null
+++ b/src/scripts/client/dashboard.ts
@@ -0,0 +1,831 @@
+// Client-side script for the admin dashboard (feeds table).
+// Compiled by scripts/build-client.mjs — do not import DOM types from here in Worker code.
+
+let FEED_ROWS: HTMLElement[] = [];
+let FEED_CHECKBOXES: HTMLInputElement[] = [];
+let FEED_SELECTED_COUNT_EL: HTMLElement | null = null;
+let FEED_MATCH_COUNT_EL: HTMLElement | null = null;
+let FEED_TOTAL_COUNT_EL: HTMLElement | null = null;
+let FEED_BULK_DELETE_BUTTON_EL: HTMLButtonElement | null = null;
+let FEED_SELECT_ALL_EL: HTMLInputElement | null = null;
+let FEED_FILTER_TIMER: ReturnType | null = null;
+let FEED_BULK_DELETE_IN_PROGRESS = false;
+let FEED_SORT_KEY = "title";
+let FEED_SORT_DIR = "asc";
+const FEED_COLLATOR = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: "base",
+});
+
+function initFeedUI(): void {
+ FEED_ROWS = Array.from(document.querySelectorAll(".feed-row"));
+ FEED_CHECKBOXES = Array.from(
+ document.querySelectorAll(".feed-select"),
+ );
+ FEED_SELECTED_COUNT_EL = document.getElementById("selected-feed-count");
+ FEED_MATCH_COUNT_EL = document.getElementById("feed-match-count");
+ FEED_TOTAL_COUNT_EL = document.getElementById("feed-total-count");
+ FEED_BULK_DELETE_BUTTON_EL = document.getElementById(
+ "bulk-delete-feeds-button",
+ ) as HTMLButtonElement | null;
+ FEED_SELECT_ALL_EL = document.getElementById(
+ "select-all-feeds",
+ ) as HTMLInputElement | null;
+ setupFeedTableResizing();
+ setupFeedTableSorting();
+ setupFeedDeleteButtons();
+ updateFeedMatchCount();
+ updateFeedSelectionState();
+}
+
+function updateFeedMatchCount(): void {
+ if (!FEED_MATCH_COUNT_EL) return;
+ const total = FEED_ROWS.length;
+ const visible = FEED_ROWS.filter((row) => !row.hidden).length;
+ const query = (
+ (document.getElementById("feed-search") as HTMLInputElement | null)
+ ?.value || ""
+ ).trim();
+ FEED_MATCH_COUNT_EL.textContent = query
+ ? "Showing " + visible + " of " + total
+ : "Showing " + total;
+}
+
+function scheduleFeedFilter(): void {
+ if (FEED_FILTER_TIMER) {
+ clearTimeout(FEED_FILTER_TIMER);
+ }
+ FEED_FILTER_TIMER = setTimeout(filterFeedRows, 120);
+}
+
+function getSortValue(row: HTMLElement, key: string): string {
+ const prop = "sort" + key.charAt(0).toUpperCase() + key.slice(1);
+ return row.dataset && row.dataset[prop] ? row.dataset[prop]! : "";
+}
+
+function updateFeedSortIndicators(table: Element): void {
+ const headerCells = Array.from(
+ table.querySelectorAll("th[data-sort-key]"),
+ );
+ headerCells.forEach((th) => {
+ const key = th.getAttribute("data-sort-key") || "";
+ const indicator = th.querySelector(".sort-indicator");
+ const active = key === FEED_SORT_KEY;
+
+ if (indicator) {
+ indicator.textContent = active
+ ? FEED_SORT_DIR === "asc"
+ ? "^"
+ : "v"
+ : "";
+ }
+ th.setAttribute(
+ "aria-sort",
+ active ? (FEED_SORT_DIR === "asc" ? "ascending" : "descending") : "none",
+ );
+ });
+}
+
+function sortFeedTableBy(key: string): void {
+ const table = document.querySelector("table.table-feeds");
+ const tbody = document.getElementById("feed-table-body");
+ if (!table || !tbody) return;
+
+ if (FEED_SORT_KEY === key) {
+ FEED_SORT_DIR = FEED_SORT_DIR === "asc" ? "desc" : "asc";
+ } else {
+ FEED_SORT_KEY = key;
+ FEED_SORT_DIR = "asc";
+ }
+
+ const dirMultiplier = FEED_SORT_DIR === "asc" ? 1 : -1;
+ const rows = Array.from(tbody.querySelectorAll(".feed-row"));
+ rows.sort((a, b) => {
+ const av = getSortValue(a, FEED_SORT_KEY);
+ const bv = getSortValue(b, FEED_SORT_KEY);
+ return dirMultiplier * FEED_COLLATOR.compare(av, bv);
+ });
+
+ const fragment = document.createDocumentFragment();
+ rows.forEach((row) => fragment.appendChild(row));
+ tbody.appendChild(fragment);
+
+ updateFeedSortIndicators(table);
+}
+
+function setupFeedTableSorting(): void {
+ const table = document.querySelector("table.table-feeds");
+ if (!table) return;
+
+ table
+ .querySelectorAll("button.th-button[data-sort-key]")
+ .forEach((button) => {
+ button.addEventListener("click", () => {
+ const key = button.getAttribute("data-sort-key") || "";
+ if (!key) return;
+ sortFeedTableBy(key);
+ });
+ });
+
+ updateFeedSortIndicators(table);
+}
+
+function setupFeedTableResizing(): void {
+ const table = document.querySelector("table.table-feeds");
+ if (!table) return;
+
+ const storageKey = "email-to-rss.admin.feedsTable.colWidths";
+ const minWidths: Record = {
+ title: 220,
+ feedId: 120,
+ email: 160,
+ rss: 160,
+ actions: 160,
+ };
+ const defaultWidths: Record = {
+ title: 340,
+ feedId: 160,
+ email: 220,
+ rss: 220,
+ actions: 200,
+ };
+
+ const cols = Array.from(table.querySelectorAll("colgroup col"));
+ const colByKey: Record = {};
+ cols.forEach((col) => {
+ const key = col.getAttribute("data-col");
+ if (key) colByKey[key] = col;
+ });
+
+ // Restore widths
+ try {
+ const saved = JSON.parse(
+ localStorage.getItem(storageKey) || "{}",
+ ) as Record;
+ Object.keys(saved || {}).forEach((key) => {
+ const px = Number(saved[key]);
+ if (!colByKey[key] || !Number.isFinite(px)) return;
+ colByKey[key].style.width = px + "px";
+ });
+ } catch {
+ // Ignore bad localStorage values
+ }
+
+ const persist = () => {
+ try {
+ const out: Record = {};
+ Object.keys(colByKey).forEach((key) => {
+ if (key === "select") return;
+ const px = parseInt(colByKey[key].style.width || "0", 10);
+ if (Number.isFinite(px) && px > 0) out[key] = px;
+ });
+ localStorage.setItem(storageKey, JSON.stringify(out));
+ } catch {
+ // localStorage may be unavailable in some modes; ignore
+ }
+ };
+
+ type ActiveResize = {
+ key: string;
+ col: HTMLElement;
+ startX: number;
+ startWidth: number;
+ };
+ let active: ActiveResize | null = null;
+ let rafId = 0;
+ let pendingWidth = 0;
+
+ table.querySelectorAll(".col-resizer").forEach((handle) => {
+ handle.addEventListener("pointerdown", (event: PointerEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const key = handle.getAttribute("data-col");
+ const col = key ? colByKey[key] : null;
+ if (!key || !col) return;
+
+ const th = handle.closest("th");
+ const startWidth = th
+ ? th.getBoundingClientRect().width
+ : parseInt(col.style.width || "0", 10) || 120;
+
+ active = { key, col, startX: event.clientX, startWidth };
+ document.body.classList.add("is-resizing");
+ handle.setPointerCapture(event.pointerId);
+ });
+
+ handle.addEventListener("pointermove", (event: PointerEvent) => {
+ if (!active) return;
+ const minPx = minWidths[active.key] || 120;
+ const nextWidth = Math.max(
+ minPx,
+ Math.round(active.startWidth + (event.clientX - active.startX)),
+ );
+ pendingWidth = nextWidth;
+ if (rafId) return;
+ rafId = requestAnimationFrame(() => {
+ active!.col.style.width = pendingWidth + "px";
+ rafId = 0;
+ });
+ });
+
+ const finish = () => {
+ if (!active) return;
+ active = null;
+ document.body.classList.remove("is-resizing");
+ persist();
+ };
+ handle.addEventListener("pointerup", finish);
+ handle.addEventListener("pointercancel", finish);
+
+ handle.addEventListener("dblclick", (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const key = handle.getAttribute("data-col");
+ const col = key ? colByKey[key] : null;
+ const px = key ? defaultWidths[key] : null;
+ if (!key || !col || !px) return;
+ col.style.width = px + "px";
+ persist();
+ });
+ });
+}
+
+const DELETE_CONFIRM_LABEL = "Confirm delete";
+const DELETE_LOADING_LABEL = "Deleting...";
+const DELETE_CONFIRM_TIMEOUT_MS = 4000;
+
+function getDeleteView(): string {
+ return new URL(window.location.href).searchParams.get("view") || "list";
+}
+
+function resetDeleteButton(buttonEl: HTMLButtonElement): void {
+ if (!buttonEl) return;
+ buttonEl.classList.remove("is-confirming");
+ buttonEl.removeAttribute("data-confirming");
+ buttonEl.disabled = false;
+ const original =
+ buttonEl.dataset.originalLabel ||
+ (buttonEl.textContent || "").trim() ||
+ "Delete";
+ buttonEl.innerHTML = original;
+}
+
+function animateRowRemoval(row: HTMLElement | null, onDone?: () => void): void {
+ if (!row) {
+ if (onDone) onDone();
+ return;
+ }
+
+ const isListItem = row.tagName.toLowerCase() === "li";
+ if (isListItem) {
+ row.style.maxHeight = row.getBoundingClientRect().height + "px";
+ row.style.overflow = "hidden";
+ }
+
+ row.classList.add("is-removing");
+
+ requestAnimationFrame(() => {
+ if (isListItem) {
+ row.style.maxHeight = "0px";
+ row.style.marginTop = "0px";
+ row.style.marginBottom = "0px";
+ row.style.paddingTop = "0px";
+ row.style.paddingBottom = "0px";
+ }
+ });
+
+ window.setTimeout(() => {
+ row.remove();
+ if (onDone) onDone();
+ }, 240);
+}
+
+async function deleteFeedRequest(
+ feedId: string,
+ view: string,
+): Promise {
+ const res = await fetch(
+ "/admin/feeds/" +
+ encodeURIComponent(feedId) +
+ "/delete?view=" +
+ encodeURIComponent(view),
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ },
+ credentials: "same-origin",
+ },
+ );
+ const data = (await res.json().catch(() => ({}))) as Record;
+ if (!res.ok) {
+ const message =
+ data && data.error
+ ? String(data.error)
+ : "Request failed (" + res.status + ")";
+ throw new Error(message);
+ }
+ return data;
+}
+
+function refreshFeedRowCache(): void {
+ FEED_ROWS = Array.from(document.querySelectorAll(".feed-row"));
+ FEED_CHECKBOXES = Array.from(
+ document.querySelectorAll(".feed-select"),
+ );
+ if (FEED_TOTAL_COUNT_EL) {
+ FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length);
+ }
+ updateFeedMatchCount();
+ updateFeedSelectionState();
+}
+
+interface ToastHandle {
+ update?: (msg: string, opts?: Record) => void;
+ dismiss?: () => void;
+}
+
+declare global {
+ interface Window {
+ showToast?: (msg: string, opts?: Record) => ToastHandle;
+ parseJsonResponseOrThrow?: (
+ res: Response,
+ opts?: Record,
+ ) => Promise>;
+ }
+}
+
+function setupFeedDeleteButtons(): void {
+ const buttons = Array.from(
+ document.querySelectorAll(
+ 'button[data-delete-kind="feed"]',
+ ),
+ );
+ buttons.forEach((button) => {
+ if (button.dataset.deleteReady === "true") return;
+ button.dataset.deleteReady = "true";
+ const original = (button.textContent || "").trim() || "Delete";
+ button.dataset.originalLabel = original;
+
+ let confirming = false;
+ let confirmTimer = 0;
+ let inFlight = false;
+
+ const startConfirm = () => {
+ confirming = true;
+ button.classList.add("is-confirming");
+ button.setAttribute("data-confirming", "true");
+ button.innerHTML = DELETE_CONFIRM_LABEL;
+ if (confirmTimer) window.clearTimeout(confirmTimer);
+ confirmTimer = window.setTimeout(() => {
+ confirming = false;
+ resetDeleteButton(button);
+ }, DELETE_CONFIRM_TIMEOUT_MS);
+ };
+
+ button.addEventListener("click", async (event: MouseEvent) => {
+ event.preventDefault();
+ if (inFlight) return;
+
+ if (!confirming) {
+ startConfirm();
+ return;
+ }
+
+ if (confirmTimer) window.clearTimeout(confirmTimer);
+ inFlight = true;
+ setButtonLoading(button, true, DELETE_LOADING_LABEL);
+
+ const toast = window.showToast
+ ? window.showToast("Deleting feed...", {
+ type: "info",
+ loading: true,
+ duration: 0,
+ })
+ : null;
+
+ const feedId = button.getAttribute("data-feed-id") || "";
+ const view = button.getAttribute("data-view") || getDeleteView();
+ const row = button.closest(".feed-row");
+
+ try {
+ await deleteFeedRequest(feedId, view);
+
+ if (toast && toast.update) {
+ toast.update("Feed deleted.", {
+ type: "success",
+ loading: false,
+ duration: 3200,
+ });
+ } else if (window.showToast) {
+ window.showToast("Feed deleted.", { type: "success" });
+ }
+
+ animateRowRemoval(row, () => {
+ refreshFeedRowCache();
+ });
+ } catch (error) {
+ const errMsg =
+ error instanceof Error && error.message
+ ? error.message
+ : "Unknown error";
+ if (toast && toast.update) {
+ toast.update("Delete failed: " + errMsg, {
+ type: "error",
+ loading: false,
+ });
+ } else if (window.showToast) {
+ window.showToast("Delete failed: " + errMsg, { type: "error" });
+ }
+ setButtonLoading(button, false);
+ confirming = false;
+ resetDeleteButton(button);
+ } finally {
+ inFlight = false;
+ if (!row) {
+ setButtonLoading(button, false);
+ confirming = false;
+ resetDeleteButton(button);
+ }
+ }
+ });
+
+ button.addEventListener("keydown", (event: KeyboardEvent) => {
+ if (event.key === "Escape" && confirming && !inFlight) {
+ confirming = false;
+ if (confirmTimer) window.clearTimeout(confirmTimer);
+ resetDeleteButton(button);
+ }
+ });
+ });
+}
+
+function updateFeedSelectionState(): void {
+ if (!FEED_CHECKBOXES.length) {
+ return;
+ }
+
+ const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked);
+
+ if (FEED_SELECTED_COUNT_EL) {
+ FEED_SELECTED_COUNT_EL.textContent = selected.length + " selected";
+ }
+ if (FEED_BULK_DELETE_BUTTON_EL) {
+ FEED_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0;
+ }
+ if (FEED_SELECT_ALL_EL) {
+ const visibleCheckboxes = FEED_CHECKBOXES.filter(
+ (checkbox) => !(checkbox.closest("tr") as HTMLElement | null)?.hidden,
+ );
+ FEED_SELECT_ALL_EL.checked =
+ visibleCheckboxes.length > 0 &&
+ visibleCheckboxes.every((checkbox) => checkbox.checked);
+ }
+}
+
+function toggleAllFeeds(checked: boolean): void {
+ FEED_CHECKBOXES.forEach((checkbox) => {
+ if (!(checkbox.closest("tr") as HTMLElement | null)?.hidden) {
+ checkbox.checked = checked;
+ }
+ });
+ updateFeedSelectionState();
+}
+
+function setVisibleFeedSelection(checked: boolean): void {
+ FEED_CHECKBOXES.forEach((checkbox) => {
+ if (!(checkbox.closest("tr") as HTMLElement | null)?.hidden) {
+ checkbox.checked = checked;
+ }
+ });
+ updateFeedSelectionState();
+}
+
+function selectMatchingFeeds(): void {
+ setVisibleFeedSelection(true);
+}
+
+function clearFeedSelection(): void {
+ FEED_CHECKBOXES.forEach((checkbox) => {
+ checkbox.checked = false;
+ });
+ updateFeedSelectionState();
+}
+
+function filterFeedRows(): void {
+ const query = (
+ (document.getElementById("feed-search") as HTMLInputElement | null)
+ ?.value || ""
+ )
+ .toLowerCase()
+ .trim();
+ FEED_ROWS.forEach((row) => {
+ const haystack = row.getAttribute("data-search") || "";
+ row.hidden = !!query && !haystack.includes(query);
+ });
+ updateFeedMatchCount();
+ updateFeedSelectionState();
+}
+
+function confirmBulkFeedDelete(): boolean {
+ const selected = FEED_CHECKBOXES.filter(
+ (checkbox) => checkbox.checked,
+ ).length;
+ if (selected === 0) return false;
+
+ const query = (
+ (document.getElementById("feed-search") as HTMLInputElement | null)
+ ?.value || ""
+ ).trim();
+ const extra =
+ selected >= 50 && !query
+ ? "\n\nThis is a large delete. Tip: use Search to narrow down spam first."
+ : "";
+ return confirm(
+ "Delete " +
+ selected +
+ " selected feed(s)? This disables the feeds immediately. Stored emails are cleaned up best-effort and may take a while." +
+ extra,
+ );
+}
+
+function setButtonLoading(
+ buttonEl: HTMLButtonElement | null,
+ loading: boolean,
+ label?: string,
+): void {
+ if (!buttonEl) return;
+ if (loading) {
+ if (!buttonEl.dataset.originalLabel) {
+ buttonEl.dataset.originalLabel = (buttonEl.textContent || "").trim();
+ }
+ const text = label || "Working...";
+ buttonEl.classList.add("is-loading");
+ buttonEl.disabled = true;
+ buttonEl.innerHTML =
+ '' + text;
+ return;
+ }
+
+ const original =
+ buttonEl.dataset.originalLabel || (buttonEl.textContent || "").trim();
+ buttonEl.classList.remove("is-loading");
+ buttonEl.innerHTML = original;
+}
+
+function removeFeedRowsById(feedIds: string[]): void {
+ const toRemove = new Set((feedIds || []).map((v) => String(v)));
+ if (toRemove.size === 0) return;
+
+ FEED_ROWS.forEach((row) => {
+ const checkbox = row.querySelector("input.feed-select");
+ const id = checkbox ? checkbox.value : "";
+ if (toRemove.has(id)) {
+ row.remove();
+ }
+ });
+
+ FEED_ROWS = Array.from(document.querySelectorAll(".feed-row"));
+ FEED_CHECKBOXES = Array.from(
+ document.querySelectorAll(".feed-select"),
+ );
+
+ if (FEED_TOTAL_COUNT_EL) {
+ FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length);
+ }
+}
+
+function onBulkFeedDeleteSubmit(event: Event): boolean {
+ if (event && event.preventDefault) event.preventDefault();
+ void bulkDeleteSelectedFeeds();
+ return false;
+}
+
+async function bulkDeleteSelectedFeeds(): Promise {
+ if (FEED_BULK_DELETE_IN_PROGRESS) return;
+ const selectedIds = FEED_CHECKBOXES.filter(
+ (checkbox) => checkbox.checked,
+ ).map((checkbox) => checkbox.value);
+ if (selectedIds.length === 0) {
+ if (window.showToast)
+ window.showToast("No feeds selected.", { type: "info" });
+ return;
+ }
+ if (!confirmBulkFeedDelete()) {
+ return;
+ }
+
+ FEED_BULK_DELETE_IN_PROGRESS = true;
+ setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, true, "Deleting...");
+
+ const toast = window.showToast
+ ? window.showToast("Deleting " + selectedIds.length + " feed(s)...", {
+ type: "info",
+ loading: true,
+ duration: 0,
+ })
+ : null;
+
+ const batchSize = 10;
+ let deletedTotal = 0;
+ const failed: string[] = [];
+
+ try {
+ for (let i = 0; i < selectedIds.length; i += batchSize) {
+ const batch = selectedIds.slice(i, i + batchSize);
+ const res = await fetch("/admin/feeds/bulk-delete", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ credentials: "same-origin",
+ body: JSON.stringify({ feedIds: batch }),
+ });
+
+ let data: Record = {};
+ if (window.parseJsonResponseOrThrow) {
+ data = await window.parseJsonResponseOrThrow(res, {
+ prefix: "Bulk feed delete failed",
+ });
+ } else {
+ data = (await res.json().catch(() => ({}))) as Record;
+ if (!res.ok) {
+ const message =
+ data && data.error
+ ? String(data.error)
+ : "Bulk feed delete failed (HTTP " + res.status + ")";
+ throw new Error(message);
+ }
+ }
+
+ const deletedIds = Array.isArray(data.deletedFeedIds)
+ ? (data.deletedFeedIds as string[])
+ : batch;
+ const failedIds = Array.isArray(data.failedFeedIds)
+ ? (data.failedFeedIds as string[])
+ : [];
+ const failureDetails = Array.isArray(data.failures)
+ ? (data.failures as Array>)
+ : [];
+
+ removeFeedRowsById(deletedIds);
+ deletedTotal += deletedIds.length;
+
+ if (toast && toast.update) {
+ const done = Math.min(i + batch.length, selectedIds.length);
+ toast.update(
+ "Deleting... (" + done + " of " + selectedIds.length + ")",
+ { type: "info" },
+ );
+ }
+
+ // Keep selection state consistent as rows disappear.
+ updateFeedMatchCount();
+ updateFeedSelectionState();
+
+ // If a batch fails for some feeds, retry those one-by-one using the bulk-delete
+ // endpoint with a single id (keeps semantics consistent and avoids hiding active feeds).
+ if (failedIds.length > 0) {
+ if (toast && toast.update) {
+ toast.update(
+ "Retrying " + failedIds.length + " failed feed(s) one-by-one...",
+ { type: "info" },
+ );
+ }
+
+ const stillFailed: string[] = [];
+ for (let j = 0; j < failedIds.length; j++) {
+ const feedId = String(failedIds[j] || "");
+ if (!feedId) continue;
+ try {
+ const retryRes = await fetch("/admin/feeds/bulk-delete", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ credentials: "same-origin",
+ body: JSON.stringify({ feedIds: [feedId] }),
+ });
+
+ let retryData: Record = {};
+ if (window.parseJsonResponseOrThrow) {
+ retryData = await window.parseJsonResponseOrThrow(retryRes, {
+ prefix: "Retry delete failed",
+ });
+ } else {
+ retryData = (await retryRes.json().catch(() => ({}))) as Record<
+ string,
+ unknown
+ >;
+ if (!retryRes.ok) {
+ const message =
+ retryData && retryData.error
+ ? String(retryData.error)
+ : "Retry delete failed (HTTP " + retryRes.status + ")";
+ throw new Error(message);
+ }
+ }
+
+ const retryDeleted = Array.isArray(retryData.deletedFeedIds)
+ ? (retryData.deletedFeedIds as string[])
+ : [];
+ const retryFailed = Array.isArray(retryData.failedFeedIds)
+ ? (retryData.failedFeedIds as string[])
+ : [];
+
+ if (retryDeleted.includes(feedId)) {
+ removeFeedRowsById([feedId]);
+ deletedTotal += 1;
+ } else if (retryFailed.includes(feedId)) {
+ stillFailed.push(feedId);
+ } else {
+ stillFailed.push(feedId);
+ }
+ } catch {
+ stillFailed.push(feedId);
+ }
+
+ if (toast && toast.update) {
+ toast.update(
+ "Retrying... (" + (j + 1) + " of " + failedIds.length + ")",
+ { type: "info" },
+ );
+ }
+ }
+
+ // Replace failed ids from this batch with only the ones that still failed after retry.
+ if (stillFailed.length > 0) {
+ failed.push(...stillFailed);
+ if (window.showToast && failureDetails.length > 0) {
+ const first =
+ failureDetails[0] && failureDetails[0].error
+ ? String(failureDetails[0].error)
+ : "";
+ if (first) {
+ window.showToast("Some feeds failed to delete: " + first, {
+ type: "error",
+ });
+ }
+ }
+ }
+
+ updateFeedMatchCount();
+ updateFeedSelectionState();
+ }
+ }
+
+ if (toast && toast.dismiss) toast.dismiss();
+ const uniqueFailed = Array.from(
+ new Set(failed.map((v) => String(v)).filter(Boolean)),
+ );
+ if (uniqueFailed.length > 0) {
+ if (window.showToast) {
+ window.showToast(
+ "Deleted " +
+ deletedTotal +
+ " feed(s). " +
+ uniqueFailed.length +
+ " failed (still visible).",
+ { type: "error" },
+ );
+ }
+ } else {
+ if (window.showToast)
+ window.showToast("Deleted " + deletedTotal + " feed(s).", {
+ type: "success",
+ });
+ }
+ } catch (error) {
+ if (toast && toast.dismiss) toast.dismiss();
+ if (window.showToast) {
+ window.showToast(
+ error instanceof Error && error.message
+ ? error.message
+ : "Bulk feed delete failed.",
+ { type: "error" },
+ );
+ }
+ } finally {
+ FEED_BULK_DELETE_IN_PROGRESS = false;
+ setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, false);
+ updateFeedSelectionState();
+ }
+}
+
+// Expose functions needed by inline HTML event handlers
+(window as unknown as Record).scheduleFeedFilter =
+ scheduleFeedFilter;
+(window as unknown as Record).toggleAllFeeds = toggleAllFeeds;
+(window as unknown as Record).selectMatchingFeeds =
+ selectMatchingFeeds;
+(window as unknown as Record).clearFeedSelection =
+ clearFeedSelection;
+(window as unknown as Record).onBulkFeedDeleteSubmit =
+ onBulkFeedDeleteSubmit;
+
+document.addEventListener("DOMContentLoaded", () => {
+ initFeedUI();
+});
diff --git a/src/scripts/generated/dashboard.ts b/src/scripts/generated/dashboard.ts
new file mode 100644
index 0000000..1a744f5
--- /dev/null
+++ b/src/scripts/generated/dashboard.ts
@@ -0,0 +1,6 @@
+// AUTO-GENERATED by scripts/build-client.mjs — do not edit directly.
+// Source: src/scripts/client/dashboard.ts
+// Run `npm run build:client` to regenerate.
+
+export const dashboardScript =
+ '"use strict";(()=>{var y=[],h=[],H=null,D=null,A=null,F=null,b=null,I=null,M=!1,v="title",L="asc",N=new Intl.Collator(void 0,{numeric:!0,sensitivity:"base"});function P(){y=Array.from(document.querySelectorAll(".feed-row")),h=Array.from(document.querySelectorAll(".feed-select")),H=document.getElementById("selected-feed-count"),D=document.getElementById("feed-match-count"),A=document.getElementById("feed-total-count"),F=document.getElementById("bulk-delete-feeds-button"),b=document.getElementById("select-all-feeds"),J(),j(),Z(),R(),E()}function R(){if(!D)return;let e=y.length,t=y.filter(s=>!s.hidden).length,n=(document.getElementById("feed-search")?.value||"").trim();D.textContent=n?"Showing "+t+" of "+e:"Showing "+e}function U(){I&&clearTimeout(I),I=setTimeout(se,120)}function C(e,t){let n="sort"+t.charAt(0).toUpperCase()+t.slice(1);return e.dataset&&e.dataset[n]?e.dataset[n]:""}function _(e){Array.from(e.querySelectorAll("th[data-sort-key]")).forEach(n=>{let s=n.getAttribute("data-sort-key")||"",l=n.querySelector(".sort-indicator"),i=s===v;l&&(l.textContent=i?L==="asc"?"^":"v":""),n.setAttribute("aria-sort",i?L==="asc"?"ascending":"descending":"none")})}function W(e){let t=document.querySelector("table.table-feeds"),n=document.getElementById("feed-table-body");if(!t||!n)return;v===e?L=L==="asc"?"desc":"asc":(v=e,L="asc");let s=L==="asc"?1:-1,l=Array.from(n.querySelectorAll(".feed-row"));l.sort((m,c)=>{let f=C(m,v),u=C(c,v);return s*N.compare(f,u)});let i=document.createDocumentFragment();l.forEach(m=>i.appendChild(m)),n.appendChild(i),_(t)}function j(){let e=document.querySelector("table.table-feeds");e&&(e.querySelectorAll("button.th-button[data-sort-key]").forEach(t=>{t.addEventListener("click",()=>{let n=t.getAttribute("data-sort-key")||"";n&&W(n)})}),_(e))}function J(){let e=document.querySelector("table.table-feeds");if(!e)return;let t="email-to-rss.admin.feedsTable.colWidths",n={title:220,feedId:120,email:160,rss:160,actions:160},s={title:340,feedId:160,email:220,rss:220,actions:200},l=Array.from(e.querySelectorAll("colgroup col")),i={};l.forEach(a=>{let d=a.getAttribute("data-col");d&&(i[d]=a)});try{let a=JSON.parse(localStorage.getItem(t)||"{}");Object.keys(a||{}).forEach(d=>{let o=Number(a[d]);!i[d]||!Number.isFinite(o)||(i[d].style.width=o+"px")})}catch{}let m=()=>{try{let a={};Object.keys(i).forEach(d=>{if(d==="select")return;let o=parseInt(i[d].style.width||"0",10);Number.isFinite(o)&&o>0&&(a[d]=o)}),localStorage.setItem(t,JSON.stringify(a))}catch{}},c=null,f=0,u=0;e.querySelectorAll(".col-resizer").forEach(a=>{a.addEventListener("pointerdown",o=>{o.preventDefault(),o.stopPropagation();let r=a.getAttribute("data-col"),g=r?i[r]:null;if(!r||!g)return;let w=a.closest("th"),T=w?w.getBoundingClientRect().width:parseInt(g.style.width||"0",10)||120;c={key:r,col:g,startX:o.clientX,startWidth:T},document.body.classList.add("is-resizing"),a.setPointerCapture(o.pointerId)}),a.addEventListener("pointermove",o=>{if(!c)return;let r=n[c.key]||120;u=Math.max(r,Math.round(c.startWidth+(o.clientX-c.startX))),!f&&(f=requestAnimationFrame(()=>{c.col.style.width=u+"px",f=0}))});let d=()=>{c&&(c=null,document.body.classList.remove("is-resizing"),m())};a.addEventListener("pointerup",d),a.addEventListener("pointercancel",d),a.addEventListener("dblclick",o=>{o.preventDefault(),o.stopPropagation();let r=a.getAttribute("data-col"),g=r?i[r]:null,w=r?s[r]:null;!r||!g||!w||(g.style.width=w+"px",m())})})}var z="Confirm delete",K="Deleting...",X=4e3;function V(){return new URL(window.location.href).searchParams.get("view")||"list"}function k(e){if(!e)return;e.classList.remove("is-confirming"),e.removeAttribute("data-confirming"),e.disabled=!1;let t=e.dataset.originalLabel||(e.textContent||"").trim()||"Delete";e.innerHTML=t}function G(e,t){if(!e){t&&t();return}let n=e.tagName.toLowerCase()==="li";n&&(e.style.maxHeight=e.getBoundingClientRect().height+"px",e.style.overflow="hidden"),e.classList.add("is-removing"),requestAnimationFrame(()=>{n&&(e.style.maxHeight="0px",e.style.marginTop="0px",e.style.marginBottom="0px",e.style.paddingTop="0px",e.style.paddingBottom="0px")}),window.setTimeout(()=>{e.remove(),t&&t()},240)}async function Y(e,t){let n=await fetch("/admin/feeds/"+encodeURIComponent(e)+"/delete?view="+encodeURIComponent(t),{method:"POST",headers:{Accept:"application/json"},credentials:"same-origin"}),s=await n.json().catch(()=>({}));if(!n.ok){let l=s&&s.error?String(s.error):"Request failed ("+n.status+")";throw new Error(l)}return s}function Q(){y=Array.from(document.querySelectorAll(".feed-row")),h=Array.from(document.querySelectorAll(".feed-select")),A&&(A.textContent=String(y.length)),R(),E()}function Z(){Array.from(document.querySelectorAll(\'button[data-delete-kind="feed"]\')).forEach(t=>{if(t.dataset.deleteReady==="true")return;t.dataset.deleteReady="true";let n=(t.textContent||"").trim()||"Delete";t.dataset.originalLabel=n;let s=!1,l=0,i=!1,m=()=>{s=!0,t.classList.add("is-confirming"),t.setAttribute("data-confirming","true"),t.innerHTML=z,l&&window.clearTimeout(l),l=window.setTimeout(()=>{s=!1,k(t)},X)};t.addEventListener("click",async c=>{if(c.preventDefault(),i)return;if(!s){m();return}l&&window.clearTimeout(l),i=!0,S(t,!0,K);let f=window.showToast?window.showToast("Deleting feed...",{type:"info",loading:!0,duration:0}):null,u=t.getAttribute("data-feed-id")||"",a=t.getAttribute("data-view")||V(),d=t.closest(".feed-row");try{await Y(u,a),f&&f.update?f.update("Feed deleted.",{type:"success",loading:!1,duration:3200}):window.showToast&&window.showToast("Feed deleted.",{type:"success"}),G(d,()=>{Q()})}catch(o){let r=o instanceof Error&&o.message?o.message:"Unknown error";f&&f.update?f.update("Delete failed: "+r,{type:"error",loading:!1}):window.showToast&&window.showToast("Delete failed: "+r,{type:"error"}),S(t,!1),s=!1,k(t)}finally{i=!1,d||(S(t,!1),s=!1,k(t))}}),t.addEventListener("keydown",c=>{c.key==="Escape"&&s&&!i&&(s=!1,l&&window.clearTimeout(l),k(t))})})}function E(){if(!h.length)return;let e=h.filter(t=>t.checked);if(H&&(H.textContent=e.length+" selected"),F&&(F.disabled=e.length===0),b){let t=h.filter(n=>!n.closest("tr")?.hidden);b.checked=t.length>0&&t.every(n=>n.checked)}}function $(e){h.forEach(t=>{t.closest("tr")?.hidden||(t.checked=e)}),E()}function ee(e){h.forEach(t=>{t.closest("tr")?.hidden||(t.checked=e)}),E()}function te(){ee(!0)}function ne(){h.forEach(e=>{e.checked=!1}),E()}function se(){let e=(document.getElementById("feed-search")?.value||"").toLowerCase().trim();y.forEach(t=>{let n=t.getAttribute("data-search")||"";t.hidden=!!e&&!n.includes(e)}),R(),E()}function ie(){let e=h.filter(s=>s.checked).length;if(e===0)return!1;let t=(document.getElementById("feed-search")?.value||"").trim(),n=e>=50&&!t?`\n\nThis is a large delete. Tip: use Search to narrow down spam first.`:"";return confirm("Delete "+e+" selected feed(s)? This disables the feeds immediately. Stored emails are cleaned up best-effort and may take a while."+n)}function S(e,t,n){if(!e)return;if(t){e.dataset.originalLabel||(e.dataset.originalLabel=(e.textContent||"").trim());let l=n||"Working...";e.classList.add("is-loading"),e.disabled=!0,e.innerHTML=\'\'+l;return}let s=e.dataset.originalLabel||(e.textContent||"").trim();e.classList.remove("is-loading"),e.innerHTML=s}function B(e){let t=new Set((e||[]).map(n=>String(n)));t.size!==0&&(y.forEach(n=>{let s=n.querySelector("input.feed-select"),l=s?s.value:"";t.has(l)&&n.remove()}),y=Array.from(document.querySelectorAll(".feed-row")),h=Array.from(document.querySelectorAll(".feed-select")),A&&(A.textContent=String(y.length)))}function oe(e){return e&&e.preventDefault&&e.preventDefault(),re(),!1}async function re(){if(M)return;let e=h.filter(i=>i.checked).map(i=>i.value);if(e.length===0){window.showToast&&window.showToast("No feeds selected.",{type:"info"});return}if(!ie())return;M=!0,S(F,!0,"Deleting...");let t=window.showToast?window.showToast("Deleting "+e.length+" feed(s)...",{type:"info",loading:!0,duration:0}):null,n=10,s=0,l=[];try{for(let m=0;m({})),!f.ok){let r=u&&u.error?String(u.error):"Bulk feed delete failed (HTTP "+f.status+")";throw new Error(r)}let a=Array.isArray(u.deletedFeedIds)?u.deletedFeedIds:c,d=Array.isArray(u.failedFeedIds)?u.failedFeedIds:[],o=Array.isArray(u.failures)?u.failures:[];if(B(a),s+=a.length,t&&t.update){let r=Math.min(m+c.length,e.length);t.update("Deleting... ("+r+" of "+e.length+")",{type:"info"})}if(R(),E(),d.length>0){t&&t.update&&t.update("Retrying "+d.length+" failed feed(s) one-by-one...",{type:"info"});let r=[];for(let g=0;g({})),!T.ok){let q=p&&p.error?String(p.error):"Retry delete failed (HTTP "+T.status+")";throw new Error(q)}let x=Array.isArray(p.deletedFeedIds)?p.deletedFeedIds:[],O=Array.isArray(p.failedFeedIds)?p.failedFeedIds:[];x.includes(w)?(B([w]),s+=1):(O.includes(w),r.push(w))}catch{r.push(w)}t&&t.update&&t.update("Retrying... ("+(g+1)+" of "+d.length+")",{type:"info"})}}if(r.length>0&&(l.push(...r),window.showToast&&o.length>0)){let g=o[0]&&o[0].error?String(o[0].error):"";g&&window.showToast("Some feeds failed to delete: "+g,{type:"error"})}R(),E()}}t&&t.dismiss&&t.dismiss();let i=Array.from(new Set(l.map(m=>String(m)).filter(Boolean)));i.length>0?window.showToast&&window.showToast("Deleted "+s+" feed(s). "+i.length+" failed (still visible).",{type:"error"}):window.showToast&&window.showToast("Deleted "+s+" feed(s).",{type:"success"})}catch(i){t&&t.dismiss&&t.dismiss(),window.showToast&&window.showToast(i instanceof Error&&i.message?i.message:"Bulk feed delete failed.",{type:"error"})}finally{M=!1,S(F,!1),E()}}window.scheduleFeedFilter=U;window.toggleAllFeeds=$;window.selectMatchingFeeds=te;window.clearFeedSelection=ne;window.onBulkFeedDeleteSubmit=oe;document.addEventListener("DOMContentLoaded",()=>{P()});})();\n';