From 0f6670d0e9c505059daa7fb8f542968f2ca81d39 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Fri, 22 May 2026 13:50:54 +0200 Subject: [PATCH] refactor(admin): extract emailsScript to src/scripts/client/emails-page.ts Moves the inline JS template literal from emails.tsx into a typed TypeScript source file. The dynamic feedId value (previously interpolated directly) is now passed via a window.__APP_CONFIG__ bootstrap script injected immediately before the compiled static script in the HTML. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/admin/emails.tsx | 320 +------------- src/scripts/client/emails-page.ts | 632 +++++++++++++++++++++++++++ src/scripts/generated/emails-page.ts | 6 + 3 files changed, 643 insertions(+), 315 deletions(-) create mode 100644 src/scripts/client/emails-page.ts create mode 100644 src/scripts/generated/emails-page.ts diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index 6b81a58..b1fbb94 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -9,6 +9,7 @@ import { import { logger } from "../../lib/logger"; import { Layout, clampText } from "./ui"; import { deleteKeysWithConcurrency } from "./helpers"; +import { emailsPageScript } from "../../scripts/generated/emails-page"; type AppEnv = { Bindings: Env }; @@ -93,320 +94,6 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { const emailAddress = `${feedId}@${env.DOMAIN}`; const rssUrl = `https://${env.DOMAIN}/rss/${feedId}`; - // Inline script for the emails page - const emailsScript = ` - const EMAIL_FEED_ID = ${JSON.stringify(feedId)}; - let EMAIL_ROWS = []; - let EMAIL_CHECKBOXES = []; - let EMAIL_SELECTED_COUNT_EL = null; - let EMAIL_MATCH_COUNT_EL = null; - let EMAIL_TOTAL_COUNT_EL = null; - let EMAIL_BULK_DELETE_BUTTON_EL = null; - let EMAIL_SELECT_ALL_EL = null; - let EMAIL_FILTER_TIMER = null; - let EMAIL_BULK_DELETE_IN_PROGRESS = false; - let EMAIL_SORT_KEY = 'receivedAt'; - let EMAIL_SORT_DIR = 'desc'; - const EMAIL_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); - - function initEmailUI() { - EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row')); - EMAIL_CHECKBOXES = Array.from(document.querySelectorAll('.email-select')); - EMAIL_SELECTED_COUNT_EL = document.getElementById('selected-email-count'); - EMAIL_MATCH_COUNT_EL = document.getElementById('email-match-count'); - EMAIL_TOTAL_COUNT_EL = document.getElementById('email-total-count'); - EMAIL_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button'); - EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails'); - setupEmailTableResizing(); - setupEmailTableSorting(); - setupEmailDeleteButtons(); - updateEmailMatchCount(); - updateEmailSelectionState(); - } - - function updateEmailMatchCount() { - if (!EMAIL_MATCH_COUNT_EL) return; - const total = EMAIL_ROWS.length; - const visible = EMAIL_ROWS.filter((row) => !row.hidden).length; - const query = (document.getElementById('email-search')?.value || '').trim(); - EMAIL_MATCH_COUNT_EL.textContent = query ? ('Showing ' + visible + ' of ' + total) : ('Showing ' + total); - } - - function scheduleEmailFilter() { - if (EMAIL_FILTER_TIMER) clearTimeout(EMAIL_FILTER_TIMER); - EMAIL_FILTER_TIMER = setTimeout(filterEmailRows, 120); - } - - function getEmailSortValue(row, key) { - const prop = 'sort' + key.charAt(0).toUpperCase() + key.slice(1); - return (row.dataset && row.dataset[prop]) ? row.dataset[prop] : ''; - } - - function updateEmailSortIndicators(table) { - Array.from(table.querySelectorAll('th[data-sort-key]')).forEach((th) => { - const key = th.getAttribute('data-sort-key') || ''; - const indicator = th.querySelector('.sort-indicator'); - const active = key === EMAIL_SORT_KEY; - if (indicator) indicator.textContent = active ? (EMAIL_SORT_DIR === 'asc' ? '^' : 'v') : ''; - th.setAttribute('aria-sort', active ? (EMAIL_SORT_DIR === 'asc' ? 'ascending' : 'descending') : 'none'); - }); - } - - function sortEmailTableBy(key) { - const table = document.querySelector('table.table-emails'); - const tbody = table ? table.querySelector('tbody') : null; - if (!table || !tbody) return; - if (EMAIL_SORT_KEY === key) { - EMAIL_SORT_DIR = EMAIL_SORT_DIR === 'asc' ? 'desc' : 'asc'; - } else { - EMAIL_SORT_KEY = key; - EMAIL_SORT_DIR = key === 'receivedAt' ? 'desc' : 'asc'; - } - const dir = EMAIL_SORT_DIR === 'asc' ? 1 : -1; - const rows = Array.from(tbody.querySelectorAll('.email-row')); - rows.sort((a, b) => dir * EMAIL_COLLATOR.compare(getEmailSortValue(a, EMAIL_SORT_KEY), getEmailSortValue(b, EMAIL_SORT_KEY))); - const frag = document.createDocumentFragment(); - rows.forEach((row) => frag.appendChild(row)); - tbody.appendChild(frag); - updateEmailSortIndicators(table); - } - - function setupEmailTableSorting() { - const table = document.querySelector('table.table-emails'); - if (!table) return; - table.querySelectorAll('button.th-button[data-sort-key]').forEach((btn) => { - btn.addEventListener('click', () => { const key = btn.getAttribute('data-sort-key'); if (key) sortEmailTableBy(key); }); - }); - updateEmailSortIndicators(table); - } - - function setupEmailTableResizing() { - const table = document.querySelector('table.table-emails'); - if (!table) return; - const storageKey = 'email-to-rss.admin.emailsTable.colWidths'; - const minWidths = { subject: 240, receivedAt: 180, actions: 160 }; - const defaultWidths = { subject: 520, receivedAt: 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; }); - try { - const saved = JSON.parse(localStorage.getItem(storageKey) || '{}'); - Object.keys(saved || {}).forEach((key) => { const px = Number(saved[key]); if (colByKey[key] && Number.isFinite(px)) colByKey[key].style.width = px + 'px'; }); - } catch { /* ignore */ } - 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 { /* 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) || 200; - 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] || 180; - pendingWidth = Math.max(minPx, Math.round(active.startWidth + (event.clientX - active.startX))); - 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 EMAIL_DELETE_CONFIRM_LABEL = 'Confirm delete'; - const EMAIL_DELETE_CONFIRM_TIMEOUT_MS = 4000; - - function resetEmailDeleteButton(buttonEl) { - if (!buttonEl) return; - buttonEl.classList.remove('is-confirming'); - buttonEl.removeAttribute('data-confirming'); - buttonEl.disabled = false; - buttonEl.textContent = buttonEl.dataset.originalLabel || 'Delete'; - } - - function setEmailButtonLoading(buttonEl, loading, label) { - if (!buttonEl) return; - if (loading) { - if (!buttonEl.dataset.originalLabel) buttonEl.dataset.originalLabel = (buttonEl.textContent || '').trim(); - buttonEl.classList.add('is-loading'); - buttonEl.disabled = true; - buttonEl.textContent = ''; - const spinner = document.createElement('span'); - spinner.className = 'spinner'; - spinner.setAttribute('aria-hidden', 'true'); - buttonEl.appendChild(spinner); - buttonEl.appendChild(document.createTextNode(' ' + (label || 'Working...'))); - return; - } - buttonEl.classList.remove('is-loading'); - buttonEl.textContent = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim(); - } - - function animateEmailRowRemoval(row, onDone) { - if (!row) { if (onDone) onDone(); return; } - row.classList.add('is-removing'); - window.setTimeout(() => { row.remove(); if (onDone) onDone(); }, 240); - } - - async function deleteEmailRequest(emailKey, feedId) { - const res = await fetch('/admin/emails/' + encodeURIComponent(emailKey) + '/delete?feedId=' + encodeURIComponent(feedId), { - method: 'POST', headers: { 'Accept': 'application/json' }, credentials: 'same-origin', - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data && data.error ? String(data.error) : ('Request failed (' + res.status + ')')); - return data; - } - - function refreshEmailRowCache() { - EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row')); - EMAIL_CHECKBOXES = Array.from(document.querySelectorAll('.email-select')); - if (EMAIL_TOTAL_COUNT_EL) EMAIL_TOTAL_COUNT_EL.textContent = String(EMAIL_ROWS.length); - updateEmailMatchCount(); - updateEmailSelectionState(); - } - - function setupEmailDeleteButtons() { - Array.from(document.querySelectorAll('button[data-delete-kind="email"]')).forEach((button) => { - if (button.dataset.deleteReady === 'true') return; - button.dataset.deleteReady = 'true'; - button.dataset.originalLabel = (button.textContent || '').trim() || 'Delete'; - let confirming = false; let confirmTimer = 0; let inFlight = false; - const startConfirm = () => { - confirming = true; - button.classList.add('is-confirming'); - button.setAttribute('data-confirming', 'true'); - button.textContent = EMAIL_DELETE_CONFIRM_LABEL; - if (confirmTimer) window.clearTimeout(confirmTimer); - confirmTimer = window.setTimeout(() => { confirming = false; resetEmailDeleteButton(button); }, EMAIL_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; - setEmailButtonLoading(button, true, 'Deleting...'); - const toast = window.showToast ? window.showToast('Deleting email...', { type: 'info', loading: true, duration: 0 }) : null; - const emailKey = button.getAttribute('data-email-key') || ''; - const feedId = button.getAttribute('data-feed-id') || EMAIL_FEED_ID; - const row = button.closest('.email-row'); - try { - await deleteEmailRequest(emailKey, feedId); - if (toast && toast.update) toast.update('Email deleted.', { type: 'success', loading: false, duration: 3200 }); - else if (window.showToast) window.showToast('Email deleted.', { type: 'success' }); - animateEmailRowRemoval(row, () => refreshEmailRowCache()); - } catch (error) { - const msg = 'Delete failed: ' + (error && error.message ? error.message : 'Unknown error'); - if (toast && toast.update) toast.update(msg, { type: 'error', loading: false }); - else if (window.showToast) window.showToast(msg, { type: 'error' }); - setEmailButtonLoading(button, false); confirming = false; resetEmailDeleteButton(button); - } finally { - inFlight = false; - if (!row) { setEmailButtonLoading(button, false); confirming = false; resetEmailDeleteButton(button); } - } - }); - button.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && confirming && !inFlight) { confirming = false; if (confirmTimer) window.clearTimeout(confirmTimer); resetEmailDeleteButton(button); } - }); - }); - } - - function updateEmailSelectionState() { - if (!EMAIL_CHECKBOXES.length) return; - const selected = EMAIL_CHECKBOXES.filter((c) => c.checked); - if (EMAIL_SELECTED_COUNT_EL) EMAIL_SELECTED_COUNT_EL.textContent = selected.length + ' selected'; - if (EMAIL_BULK_DELETE_BUTTON_EL) EMAIL_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0; - if (EMAIL_SELECT_ALL_EL) { - const visible = EMAIL_CHECKBOXES.filter((c) => !c.closest('tr')?.hidden); - EMAIL_SELECT_ALL_EL.checked = visible.length > 0 && visible.every((c) => c.checked); - } - } - - function toggleAllEmails(checked) { EMAIL_CHECKBOXES.forEach((c) => { if (!c.closest('tr')?.hidden) c.checked = checked; }); updateEmailSelectionState(); } - function selectMatchingEmails() { EMAIL_CHECKBOXES.forEach((c) => { if (!c.closest('tr')?.hidden) c.checked = true; }); updateEmailSelectionState(); } - function clearEmailSelection() { EMAIL_CHECKBOXES.forEach((c) => { c.checked = false; }); updateEmailSelectionState(); } - - function filterEmailRows() { - const query = (document.getElementById('email-search')?.value || '').toLowerCase().trim(); - EMAIL_ROWS.forEach((row) => { row.hidden = !!query && !(row.getAttribute('data-search') || '').includes(query); }); - updateEmailMatchCount(); updateEmailSelectionState(); - } - - function confirmBulkEmailDelete() { - const selected = EMAIL_CHECKBOXES.filter((c) => c.checked).length; - if (selected === 0) return false; - const query = (document.getElementById('email-search')?.value || '').trim(); - const extra = selected >= 200 && !query ? '\\n\\nThis is a large delete. Tip: use Search to narrow down spam first.' : ''; - return confirm('Delete ' + selected + ' selected email(s)?' + extra); - } - - function removeEmailRowsByKey(emailKeys) { - const toRemove = new Set((emailKeys || []).map((v) => String(v))); - if (!toRemove.size) return; - EMAIL_ROWS.forEach((row) => { const cb = row.querySelector('input.email-select'); if (cb && toRemove.has(cb.value)) row.remove(); }); - EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row')); - EMAIL_CHECKBOXES = Array.from(document.querySelectorAll('.email-select')); - if (EMAIL_TOTAL_COUNT_EL) EMAIL_TOTAL_COUNT_EL.textContent = String(EMAIL_ROWS.length); - } - - function onBulkEmailDeleteSubmit(event) { if (event && event.preventDefault) event.preventDefault(); void bulkDeleteSelectedEmails(); return false; } - - async function bulkDeleteSelectedEmails() { - if (EMAIL_BULK_DELETE_IN_PROGRESS) return; - const selectedKeys = EMAIL_CHECKBOXES.filter((c) => c.checked).map((c) => c.value); - if (!selectedKeys.length) { if (window.showToast) window.showToast('No emails selected.', { type: 'info' }); return; } - if (!confirmBulkEmailDelete()) return; - EMAIL_BULK_DELETE_IN_PROGRESS = true; - setEmailButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, true, 'Deleting...'); - const toast = window.showToast ? window.showToast('Deleting ' + selectedKeys.length + ' email(s)...', { type: 'info', loading: true, duration: 0 }) : null; - const batchSize = 50; let deletedTotal = 0; const failed = []; - try { - const url = '/admin/feeds/' + encodeURIComponent(EMAIL_FEED_ID) + '/emails/bulk-delete'; - for (let i = 0; i < selectedKeys.length; i += batchSize) { - const batch = selectedKeys.slice(i, i + batchSize); - const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ emailKeys: batch }) }); - let data = {}; - if (window.parseJsonResponseOrThrow) { data = await window.parseJsonResponseOrThrow(res, { prefix: 'Bulk email delete failed' }); } - else { data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data && data.error ? String(data.error) : 'Bulk email delete failed (HTTP ' + res.status + ')'); } - const deletedKeys = Array.isArray(data.deletedEmailKeys) ? data.deletedEmailKeys : batch; - removeEmailRowsByKey(deletedKeys); deletedTotal += deletedKeys.length; - failed.push(...(Array.isArray(data.failedEmailKeys) ? data.failedEmailKeys : [])); - if (toast && toast.update) toast.update('Deleting... (' + Math.min(i + batch.length, selectedKeys.length) + ' of ' + selectedKeys.length + ')', { type: 'info' }); - updateEmailMatchCount(); updateEmailSelectionState(); - } - if (toast && toast.dismiss) toast.dismiss(); - if (failed.length > 0) { if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible).', { type: 'error' }); } - else { if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' email(s).', { type: 'success' }); } - } catch (error) { - if (toast && toast.dismiss) toast.dismiss(); - if (window.showToast) window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error' }); - } finally { - EMAIL_BULK_DELETE_IN_PROGRESS = false; - setEmailButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, false); - updateEmailSelectionState(); - } - } - - document.addEventListener('DOMContentLoaded', () => { initEmailUI(); }); - `; return c.html( @@ -625,7 +312,10 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { )} -