{
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) => {
>
+
+
+
+
+
+
@@ -1350,21 +1569,38 @@ app.get("/feeds/:feedId/emails", async (c) => {
onchange="toggleAllEmails(this.checked)"
/>
- Subject
- Received
- Actions
+
+
+ Subject
+
+
+
+
+
+ Received
+
+
+
+
+ Actions
+
+
${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`
{
let EMAIL_BULK_DELETE_BUTTON_EL = null;
let EMAIL_SELECT_ALL_EL = 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() {
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_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button');
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
+ setupEmailTableResizing();
+ setupEmailTableSorting();
updateEmailSelectionState();
}
@@ -1450,6 +1691,171 @@ app.get("/feeds/:feedId/emails", async (c) => {
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) {
if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) {
const form = document.createElement('form');
diff --git a/src/styles/components.ts b/src/styles/components.ts
index fc10889..b5f56f3 100644
--- a/src/styles/components.ts
+++ b/src/styles/components.ts
@@ -607,45 +607,74 @@ export const componentStyles = `
vertical-align: top;
}
- /* Fixed column sizing so long spam titles don't blow up layout */
- table.table.table-feeds th:nth-child(1),
- table.table.table-feeds td:nth-child(1) {
- width: 44px;
+ /* Resizable headers */
+ th.th-resizable {
+ position: relative;
+ padding-right: 18px;
}
- table.table.table-feeds th:nth-child(3),
- table.table.table-feeds td:nth-child(3) {
- width: 170px;
+ .th-button {
+ display: inline-flex;
+ 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),
- table.table.table-feeds td:nth-child(4) {
- width: 260px;
+ .th-button:hover {
+ color: var(--color-text-primary);
}
- table.table.table-feeds th:nth-child(5),
- table.table.table-feeds td:nth-child(5) {
- width: 280px;
+ .sort-indicator {
+ width: 12px;
+ 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),
- table.table.table-feeds td:nth-child(6) {
- width: 220px;
+ .col-resizer {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 12px;
+ height: 100%;
+ cursor: col-resize;
+ touch-action: none;
}
- table.table.table-emails th:nth-child(1),
- table.table.table-emails td:nth-child(1) {
- width: 44px;
+ .col-resizer:after {
+ content: "";
+ 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),
- table.table.table-emails td:nth-child(3) {
- width: 200px;
+ th.th-resizable:hover .col-resizer:after {
+ background: rgba(255, 255, 255, 0.2);
}
- table.table.table-emails th:nth-child(4),
- table.table.table-emails td:nth-child(4) {
- width: 200px;
+ @media (prefers-color-scheme: light) {
+ th.th-resizable:hover .col-resizer:after {
+ background: rgba(0, 0, 0, 0.15);
+ }
+ }
+
+ body.is-resizing {
+ cursor: col-resize;
+ user-select: none;
}
table.table thead th {