From 022c1888731dd9203476648a4c020f8bb195513d Mon Sep 17 00:00:00 2001 From: Young Lee <8462583+yl8976@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:11:32 -0800 Subject: [PATCH] fix(admin): truncate spam titles + speed up table view --- AGENTS.md | 1 + README.md | 1 + src/routes/admin.ts | 302 ++++++++++++++++++++++++++------------- src/scripts/clipboard.ts | 36 ++--- src/styles/components.ts | 56 ++++++++ src/styles/layout.ts | 5 + src/types/index.ts | 1 + src/utils/storage.ts | 10 +- 8 files changed, 289 insertions(+), 123 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 82a623b..ac6987f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Core goals: Current keys used by routes: - `feeds:list` -> `{ feeds: Array<{ id, title }> }` +- `feeds:list.feeds[].description` -> optional description (used to keep the dashboard fast; older data may omit it) - `feed::config` -> feed config object - `feed::config.allowed_senders` -> optional sender allowlist (email or domain) - `feed::metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }` diff --git a/README.md b/README.md index 358a0ee..560b6a9 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ npm run build 1. Open `/admin`. 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. 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 d11e4f8..9c0ec13 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -39,6 +39,17 @@ function parseAllowedSenders(rawAllowedSenders: string): string[] { .filter(Boolean); } +function clampText(value: string, maxLen: number): string { + const raw = `${value || ""}`; + if (raw.length <= maxLen) { + return raw.trim(); + } + if (maxLen <= 3) { + return raw.slice(0, maxLen).trim(); + } + return `${raw.slice(0, maxLen - 3).trimEnd()}...`; +} + // Prevent accidental caching of admin pages and redirects. app.use("*", async (c, next) => { c.header("Cache-Control", "no-store, max-age=0"); @@ -227,19 +238,12 @@ app.get("/", async (c) => { // List all feeds const feedList = await listAllFeeds(emailStorage); - // Fetch full feed configs to get descriptions - const feedsWithConfig = await Promise.all( - feedList.map(async (feed) => { - const configKey = `feed:${feed.id}:config`; - const config = (await emailStorage.get(configKey, { - type: "json", - })) as FeedConfig | null; - return { - ...feed, - description: config?.description || "", - }; - }), - ); + // Keep the dashboard fast: avoid N KV reads for N feeds. + // We store title/description in `feeds:list` (description is optional for older data). + const feedsWithConfig = feedList.map((feed) => ({ + ...feed, + description: feed.description || "", + })); const viewHref = (nextView: "list" | "table") => { const nextUrl = new URL(url); @@ -272,7 +276,7 @@ app.get("/", async (c) => { layout( "Dashboard", html` -
+

Email to RSS Admin

@@ -354,8 +358,8 @@ app.get("/", async (c) => { type="search" id="feed-search" class="search" - placeholder="Search feed title, id, or description" - oninput="filterFeedRows()" + placeholder="Search title, feed id, or description" + oninput="scheduleFeedFilter()" />
- `, - )} + `; + })}
@@ -1360,6 +1429,29 @@ app.get("/feeds/:feedId/emails", async (c) => { @@ -1910,6 +1999,7 @@ async function addFeedToList( emailStorage: KVNamespace, feedId: string, title: string, + description?: string, ): Promise { try { const feedListKey = "feeds:list"; @@ -1921,6 +2011,7 @@ async function addFeedToList( feedList.feeds.push({ id: feedId, title, + description, }); // Store updated list @@ -1935,6 +2026,7 @@ async function updateFeedInList( emailStorage: KVNamespace, feedId: string, title: string, + description?: string, ): Promise { try { const feedListKey = "feeds:list"; @@ -1946,6 +2038,7 @@ async function updateFeedInList( const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); if (feedIndex !== -1) { feedList.feeds[feedIndex].title = title; + feedList.feeds[feedIndex].description = description; // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); @@ -2017,7 +2110,12 @@ app.post("/api/feeds/:feedId/update", async (c) => { ); // Update feed in the list of all feeds - await updateFeedInList(emailStorage, feedId, parsedData.title); + await updateFeedInList( + emailStorage, + feedId, + parsedData.title, + parsedData.description, + ); // Return success response return c.json({ success: true }); diff --git a/src/scripts/clipboard.ts b/src/scripts/clipboard.ts index 9f70778..7b093a9 100644 --- a/src/scripts/clipboard.ts +++ b/src/scripts/clipboard.ts @@ -3,11 +3,8 @@ export const clipboardScripts = ` // Copy text to clipboard with animation feedback - function copyToClipboard(text, element) { - // Find the parent .copyable element and the content element - const copyableContainer = element.closest('.copyable'); - const contentElement = copyableContainer?.querySelector('.copyable-content'); - if (!copyableContainer || !contentElement) return; + function copyToClipboard(text, contentElement) { + if (!contentElement) return; navigator.clipboard.writeText(text).then(() => { // Add the 'copied' class to the content element for success styling @@ -24,18 +21,23 @@ export const clipboardScripts = ` // Initialize copyable elements function setupCopyableElements() { - document.querySelectorAll('.copyable').forEach(container => { - const contentElement = container.querySelector('.copyable-content'); - const valueElement = container.querySelector('.copyable-value'); + // Event delegation avoids attaching hundreds/thousands of listeners + // when many feeds/emails are rendered in table view. + document.addEventListener('click', (event) => { + const target = event.target; + if (!target || !target.closest) return; - if (contentElement && valueElement) { - const textToCopy = valueElement.getAttribute('data-copy') || valueElement.textContent.trim(); - - // Add click handler to the entire content area - contentElement.addEventListener('click', () => { - copyToClipboard(textToCopy, contentElement); - }); - } + const contentElement = target.closest('.copyable-content'); + if (!contentElement) return; + + const container = contentElement.closest('.copyable'); + const valueElement = container?.querySelector('.copyable-value'); + if (!valueElement) return; + + const textToCopy = valueElement.getAttribute('data-copy') || (valueElement.textContent || '').trim(); + if (!textToCopy) return; + + copyToClipboard(textToCopy, contentElement); }); } @@ -59,4 +61,4 @@ export const clipboardScripts = ` form.submit(); } } -`; \ No newline at end of file +`; diff --git a/src/styles/components.ts b/src/styles/components.ts index 1e0f532..fc10889 100644 --- a/src/styles/components.ts +++ b/src/styles/components.ts @@ -591,10 +591,12 @@ export const componentStyles = ` table.table.table-feeds { min-width: 860px; + table-layout: fixed; } table.table.table-emails { min-width: 760px; + table-layout: fixed; } table.table th, @@ -605,6 +607,47 @@ 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; + } + + table.table.table-feeds th:nth-child(3), + table.table.table-feeds td:nth-child(3) { + width: 170px; + } + + table.table.table-feeds th:nth-child(4), + table.table.table-feeds td:nth-child(4) { + width: 260px; + } + + table.table.table-feeds th:nth-child(5), + table.table.table-feeds td:nth-child(5) { + width: 280px; + } + + table.table.table-feeds th:nth-child(6), + table.table.table-feeds td:nth-child(6) { + width: 220px; + } + + table.table.table-emails th:nth-child(1), + table.table.table-emails td:nth-child(1) { + width: 44px; + } + + table.table.table-emails th:nth-child(3), + table.table.table-emails td:nth-child(3) { + width: 200px; + } + + table.table.table-emails th:nth-child(4), + table.table.table-emails td:nth-child(4) { + width: 200px; + } + table.table thead th { font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); @@ -639,6 +682,14 @@ export const componentStyles = ` font-size: 13px; } + .truncate { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + /* Compact copy-to-clipboard for table cells */ .copyable.copyable-inline { margin-bottom: 0; @@ -650,10 +701,15 @@ export const componentStyles = ` .copyable.copyable-inline .copyable-content { padding: 6px 8px; border-radius: var(--radius-sm); + width: 100%; } .copyable.copyable-inline .copyable-value { margin-right: var(--spacing-xs); + word-break: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .row-actions { diff --git a/src/styles/layout.ts b/src/styles/layout.ts index 378f6c5..4233f27 100644 --- a/src/styles/layout.ts +++ b/src/styles/layout.ts @@ -35,6 +35,11 @@ export const layoutStyles = ` padding: var(--spacing-xl); box-sizing: border-box; } + + /* Wider layout for data-dense pages (tables) */ + .container-wide { + max-width: 1280px; + } /* Header Styles */ .header { diff --git a/src/types/index.ts b/src/types/index.ts index 2ae63c0..8f522ab 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -48,6 +48,7 @@ export interface FeedList { export interface FeedListItem { id: string; title: string; + description?: string; } // Declare KVNamespace for TypeScript diff --git a/src/utils/storage.ts b/src/utils/storage.ts index ad80b75..80b2044 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -101,7 +101,7 @@ export async function createFeed( })); // Add feed to the list of all feeds - await addFeedToList(kv, feedId, feedConfig.title); + await addFeedToList(kv, feedId, feedConfig.title, feedConfig.description); } /** @@ -110,7 +110,8 @@ export async function createFeed( export async function addFeedToList( kv: KVNamespace, feedId: string, - title: string + title: string, + description?: string ): Promise { const feedListKey = 'feeds:list'; const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; @@ -119,7 +120,8 @@ export async function addFeedToList( feedList.feeds.push({ id: feedId, - title + title, + description }); await kv.put(feedListKey, JSON.stringify(feedList)); @@ -133,4 +135,4 @@ export async function getAllFeeds(kv: KVNamespace): Promise { const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; return feedList || { feeds: [] }; -} \ No newline at end of file +}