feat(admin): resizable + sortable table columns

This commit is contained in:
Young Lee
2026-02-06 00:26:38 -08:00
parent 0b898bf600
commit 65cf54a764
4 changed files with 471 additions and 33 deletions
+1
View File
@@ -79,6 +79,7 @@ Notes:
- First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection. - First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection.
- Use **Table** view for bulk delete. - 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. - Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds.
## Cloudflare/Wrangler conventions ## Cloudflare/Wrangler conventions
+2
View File
@@ -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 - One-click feed creation from an admin dashboard
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow) - 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`) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
- ForwardEmail webhook ingestion with source-IP verification - ForwardEmail webhook ingestion with source-IP verification
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`) - Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
@@ -112,6 +113,7 @@ npm run build
2. Switch to **Table** view. 2. Switch to **Table** view.
3. Use the search box to filter obvious spam feeds. 3. Use the search box to filter obvious spam feeds.
- Long titles/URLs are truncated; hover to see the full value. Click to copy. - 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**. 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**. 5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Delete Selected Emails**.
+414 -8
View File
@@ -391,6 +391,14 @@ app.get("/", async (c) => {
<div class="table-wrap"> <div class="table-wrap">
<table class="table table-feeds"> <table class="table table-feeds">
<colgroup>
<col data-col="select" style="width: 44px;" />
<col data-col="title" style="width: 340px;" />
<col data-col="feedId" style="width: 160px;" />
<col data-col="email" style="width: 220px;" />
<col data-col="rss" style="width: 220px;" />
<col data-col="actions" style="width: 200px;" />
</colgroup>
<thead> <thead>
<tr> <tr>
<th> <th>
@@ -400,11 +408,34 @@ app.get("/", async (c) => {
onchange="toggleAllFeeds(this.checked)" onchange="toggleAllFeeds(this.checked)"
/> />
</th> </th>
<th>Title</th> <th class="th-resizable" data-sort-key="title" aria-sort="none">
<th>Feed ID</th> <button type="button" class="th-button" data-sort-key="title">
<th>Email</th> Title <span class="sort-indicator" aria-hidden="true"></span>
<th>RSS</th> </button>
<th>Actions</th> <div class="col-resizer" data-col="title" title="Resize"></div>
</th>
<th class="th-resizable" data-sort-key="feedId" aria-sort="none">
<button type="button" class="th-button" data-sort-key="feedId">
Feed ID <span class="sort-indicator" aria-hidden="true"></span>
</button>
<div class="col-resizer" data-col="feedId" title="Resize"></div>
</th>
<th class="th-resizable" data-sort-key="email" aria-sort="none">
<button type="button" class="th-button" data-sort-key="email">
Email <span class="sort-indicator" aria-hidden="true"></span>
</button>
<div class="col-resizer" data-col="email" title="Resize"></div>
</th>
<th class="th-resizable" data-sort-key="rss" aria-sort="none">
<button type="button" class="th-button" data-sort-key="rss">
RSS <span class="sort-indicator" aria-hidden="true"></span>
</button>
<div class="col-resizer" data-col="rss" title="Resize"></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div class="col-resizer" data-col="actions" title="Resize"></div>
</th>
</tr> </tr>
</thead> </thead>
<tbody id="feed-table-body"> <tbody id="feed-table-body">
@@ -413,6 +444,10 @@ app.get("/", async (c) => {
const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
const titleDisplay = clampText(feed.title, 160); const titleDisplay = clampText(feed.title, 160);
const titleHover = clampText(feed.title, 1000); 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 descDisplay = clampText(feed.description || "", 220);
const descHover = clampText(feed.description || "", 1000); const descHover = clampText(feed.description || "", 1000);
const searchHaystack = const searchHaystack =
@@ -422,6 +457,10 @@ app.get("/", async (c) => {
<tr <tr
class="feed-row" class="feed-row"
data-search="${searchHaystack}" data-search="${searchHaystack}"
data-sort-title="${sortTitle}"
data-sort-feed-id="${sortFeedId}"
data-sort-email="${sortEmail}"
data-sort-rss="${sortRss}"
> >
<td> <td>
<input <input
@@ -774,6 +813,9 @@ app.get("/", async (c) => {
let FEED_BULK_DELETE_BUTTON_EL = null; let FEED_BULK_DELETE_BUTTON_EL = null;
let FEED_SELECT_ALL_EL = null; let FEED_SELECT_ALL_EL = null;
let FEED_FILTER_TIMER = 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() { function initFeedUI() {
FEED_ROWS = Array.from(document.querySelectorAll('.feed-row')); 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_SELECTED_COUNT_EL = document.getElementById('selected-feed-count');
FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button'); FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button');
FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds'); FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
setupFeedTableResizing();
setupFeedTableSorting();
updateFeedSelectionState(); updateFeedSelectionState();
} }
@@ -791,6 +835,175 @@ app.get("/", async (c) => {
FEED_FILTER_TIMER = setTimeout(filterFeedRows, 120); 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) { function confirmDelete(feedId) {
if (confirm('Are you sure you want to delete this feed? This action cannot be undone.')) { 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'; const currentView = new URL(window.location.href).searchParams.get('view') || 'list';
@@ -1341,6 +1554,12 @@ app.get("/feeds/:feedId/emails", async (c) => {
> >
<div class="table-wrap"> <div class="table-wrap">
<table class="table table-emails"> <table class="table table-emails">
<colgroup>
<col data-col="select" style="width: 44px;" />
<col data-col="subject" style="width: 520px;" />
<col data-col="receivedAt" style="width: 220px;" />
<col data-col="actions" style="width: 200px;" />
</colgroup>
<thead> <thead>
<tr> <tr>
<th> <th>
@@ -1350,21 +1569,38 @@ app.get("/feeds/:feedId/emails", async (c) => {
onchange="toggleAllEmails(this.checked)" onchange="toggleAllEmails(this.checked)"
/> />
</th> </th>
<th>Subject</th> <th class="th-resizable" data-sort-key="subject" aria-sort="none">
<th>Received</th> <button type="button" class="th-button" data-sort-key="subject">
<th>Actions</th> Subject <span class="sort-indicator" aria-hidden="true"></span>
</button>
<div class="col-resizer" data-col="subject" title="Resize"></div>
</th>
<th class="th-resizable" data-sort-key="receivedAt" aria-sort="none">
<button type="button" class="th-button" data-sort-key="receivedAt">
Received <span class="sort-indicator" aria-hidden="true"></span>
</button>
<div class="col-resizer" data-col="receivedAt" title="Resize"></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div class="col-resizer" data-col="actions" title="Resize"></div>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${feedMetadata.emails.map((email: EmailMetadata) => { ${feedMetadata.emails.map((email: EmailMetadata) => {
const subjectDisplay = clampText(email.subject, 180); const subjectDisplay = clampText(email.subject, 180);
const subjectHover = clampText(email.subject, 1000); const subjectHover = clampText(email.subject, 1000);
const sortSubject = subjectHover.toLowerCase();
const sortReceivedAt = String(email.receivedAt);
const searchHaystack = clampText(email.subject, 320).toLowerCase(); const searchHaystack = clampText(email.subject, 320).toLowerCase();
return html` return html`
<tr <tr
class="email-row" class="email-row"
data-search="${searchHaystack}" data-search="${searchHaystack}"
data-sort-subject="${sortSubject}"
data-sort-received-at="${sortReceivedAt}"
> >
<td> <td>
<input <input
@@ -1433,6 +1669,9 @@ app.get("/feeds/:feedId/emails", async (c) => {
let EMAIL_BULK_DELETE_BUTTON_EL = null; let EMAIL_BULK_DELETE_BUTTON_EL = null;
let EMAIL_SELECT_ALL_EL = null; let EMAIL_SELECT_ALL_EL = null;
let EMAIL_FILTER_TIMER = null; let EMAIL_FILTER_TIMER = null;
let EMAIL_SORT_KEY = 'receivedAt';
let EMAIL_SORT_DIR = 'desc';
const EMAIL_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
function initEmailUI() { function initEmailUI() {
EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row')); EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row'));
@@ -1440,6 +1679,8 @@ app.get("/feeds/:feedId/emails", async (c) => {
EMAIL_SELECTED_COUNT_EL = document.getElementById('selected-email-count'); EMAIL_SELECTED_COUNT_EL = document.getElementById('selected-email-count');
EMAIL_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button'); EMAIL_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button');
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails'); EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
setupEmailTableResizing();
setupEmailTableSorting();
updateEmailSelectionState(); updateEmailSelectionState();
} }
@@ -1450,6 +1691,171 @@ app.get("/feeds/:feedId/emails", async (c) => {
EMAIL_FILTER_TIMER = setTimeout(filterEmailRows, 120); 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) {
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 === 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;
// Default Received sorting to newest-first
EMAIL_SORT_DIR = key === 'receivedAt' ? 'desc' : 'asc';
}
const dirMultiplier = EMAIL_SORT_DIR === 'asc' ? 1 : -1;
const rows = Array.from(tbody.querySelectorAll('.email-row'));
rows.sort((a, b) => {
const av = getEmailSortValue(a, EMAIL_SORT_KEY);
const bv = getEmailSortValue(b, EMAIL_SORT_KEY);
return dirMultiplier * EMAIL_COLLATOR.compare(av, bv);
});
const fragment = document.createDocumentFragment();
rows.forEach((row) => fragment.appendChild(row));
tbody.appendChild(fragment);
updateEmailSortIndicators(table);
}
function setupEmailTableSorting() {
const table = document.querySelector('table.table-emails');
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;
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)) 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 {
// 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;
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 confirmDeleteEmail(emailKey, feedId) { function confirmDeleteEmail(emailKey, feedId) {
if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) { if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) {
const form = document.createElement('form'); const form = document.createElement('form');
+54 -25
View File
@@ -607,45 +607,74 @@ export const componentStyles = `
vertical-align: top; vertical-align: top;
} }
/* Fixed column sizing so long spam titles don't blow up layout */ /* Resizable headers */
table.table.table-feeds th:nth-child(1), th.th-resizable {
table.table.table-feeds td:nth-child(1) { position: relative;
width: 44px; padding-right: 18px;
} }
table.table.table-feeds th:nth-child(3), .th-button {
table.table.table-feeds td:nth-child(3) { display: inline-flex;
width: 170px; align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
user-select: none;
} }
table.table.table-feeds th:nth-child(4), .th-button:hover {
table.table.table-feeds td:nth-child(4) { color: var(--color-text-primary);
width: 260px;
} }
table.table.table-feeds th:nth-child(5), .sort-indicator {
table.table.table-feeds td:nth-child(5) { width: 12px;
width: 280px; height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.75;
font-size: 12px;
} }
table.table.table-feeds th:nth-child(6), .col-resizer {
table.table.table-feeds td:nth-child(6) { position: absolute;
width: 220px; top: 0;
right: 0;
width: 12px;
height: 100%;
cursor: col-resize;
touch-action: none;
} }
table.table.table-emails th:nth-child(1), .col-resizer:after {
table.table.table-emails td:nth-child(1) { content: "";
width: 44px; position: absolute;
right: 5px;
top: 24%;
bottom: 24%;
width: 1px;
background: var(--color-border);
opacity: 0.9;
border-radius: 1px;
} }
table.table.table-emails th:nth-child(3), th.th-resizable:hover .col-resizer:after {
table.table.table-emails td:nth-child(3) { background: rgba(255, 255, 255, 0.2);
width: 200px;
} }
table.table.table-emails th:nth-child(4), @media (prefers-color-scheme: light) {
table.table.table-emails td:nth-child(4) { th.th-resizable:hover .col-resizer:after {
width: 200px; background: rgba(0, 0, 0, 0.15);
}
}
body.is-resizing {
cursor: col-resize;
user-select: none;
} }
table.table thead th { table.table thead th {