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';