From 65cf54a764ad390601ba1071c02c02faddde8c9a Mon Sep 17 00:00:00 2001 From: Young Lee <8462583+yl8976@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:26:38 -0800 Subject: [PATCH] feat(admin): resizable + sortable table columns --- AGENTS.md | 1 + README.md | 2 + src/routes/admin.ts | 422 ++++++++++++++++++++++++++++++++++++++- src/styles/components.ts | 79 +++++--- 4 files changed, 471 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac6987f..fb3ce15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ Notes: - First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection. - Use **Table** view for bulk delete. +- Table columns are resizable and sortable; widths persist per-browser via localStorage. - Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds. ## Cloudflare/Wrangler conventions diff --git a/README.md b/README.md index 560b6a9..526df80 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da - One-click feed creation from an admin dashboard - Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow) +- Resizable + sortable table columns in the admin dashboard (Table view) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`) - ForwardEmail webhook ingestion with source-IP verification - Optional per-feed sender allowlist (`email@domain.com` or `domain.com`) @@ -112,6 +113,7 @@ npm run build 2. Switch to **Table** view. 3. Use the search box to filter obvious spam feeds. - Long titles/URLs are truncated; hover to see the full value. Click to copy. + - Drag the column separators to resize; click headers to sort (double-click a separator to reset width). 4. Select rows and use **Delete Selected Feeds**. 5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Delete Selected Emails**. diff --git a/src/routes/admin.ts b/src/routes/admin.ts index ddd4582..a64713a 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -391,6 +391,14 @@ app.get("/", async (c) => {
+ + + + + + + + - - - - - + + + + + @@ -413,6 +444,10 @@ app.get("/", async (c) => { const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const titleDisplay = clampText(feed.title, 160); const titleHover = clampText(feed.title, 1000); + const sortTitle = titleHover.toLowerCase(); + const sortFeedId = feed.id.toLowerCase(); + const sortEmail = emailAddress.toLowerCase(); + const sortRss = rssUrl.toLowerCase(); const descDisplay = clampText(feed.description || "", 220); const descHover = clampText(feed.description || "", 1000); const searchHaystack = @@ -422,6 +457,10 @@ app.get("/", async (c) => {
@@ -400,11 +408,34 @@ app.get("/", async (c) => { onchange="toggleAllFeeds(this.checked)" /> TitleFeed IDEmailRSSActions + +
+
+ +
+
+ +
+
+ +
+
+ Actions +
+
{ let FEED_BULK_DELETE_BUTTON_EL = null; let FEED_SELECT_ALL_EL = null; let FEED_FILTER_TIMER = null; + 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')); @@ -781,6 +823,8 @@ app.get("/", async (c) => { FEED_SELECTED_COUNT_EL = document.getElementById('selected-feed-count'); FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button'); FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds'); + setupFeedTableResizing(); + setupFeedTableSorting(); updateFeedSelectionState(); } @@ -791,6 +835,175 @@ app.get("/", async (c) => { 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(); + }); + }); + } + function confirmDelete(feedId) { if (confirm('Are you sure you want to delete this feed? This action cannot be undone.')) { const currentView = new URL(window.location.href).searchParams.get('view') || 'list'; @@ -1341,6 +1554,12 @@ app.get("/feeds/:feedId/emails", async (c) => { >
+ + + + + + - - - + + + ${feedMetadata.emails.map((email: EmailMetadata) => { const subjectDisplay = clampText(email.subject, 180); const subjectHover = clampText(email.subject, 1000); + const sortSubject = subjectHover.toLowerCase(); + const sortReceivedAt = String(email.receivedAt); const searchHaystack = clampText(email.subject, 320).toLowerCase(); return html`
@@ -1350,21 +1569,38 @@ app.get("/feeds/:feedId/emails", async (c) => { onchange="toggleAllEmails(this.checked)" /> SubjectReceivedActions + +
+
+ +
+
+ Actions +
+