fix(admin): truncate spam titles + speed up table view

This commit is contained in:
Young Lee
2026-02-06 00:11:32 -08:00
parent 223560e874
commit 022c188873
8 changed files with 289 additions and 123 deletions
+1
View File
@@ -34,6 +34,7 @@ Core goals:
Current keys used by routes: Current keys used by routes:
- `feeds:list` -> `{ feeds: Array<{ id, title }> }` - `feeds:list` -> `{ feeds: Array<{ id, title }> }`
- `feeds:list.feeds[].description` -> optional description (used to keep the dashboard fast; older data may omit it)
- `feed:<feedId>:config` -> feed config object - `feed:<feedId>:config` -> feed config object
- `feed:<feedId>:config.allowed_senders` -> optional sender allowlist (email or domain) - `feed:<feedId>:config.allowed_senders` -> optional sender allowlist (email or domain)
- `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }` - `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }`
+1
View File
@@ -111,6 +111,7 @@ npm run build
1. Open `/admin`. 1. Open `/admin`.
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.
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**.
+200 -102
View File
@@ -39,6 +39,17 @@ function parseAllowedSenders(rawAllowedSenders: string): string[] {
.filter(Boolean); .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. // Prevent accidental caching of admin pages and redirects.
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
c.header("Cache-Control", "no-store, max-age=0"); c.header("Cache-Control", "no-store, max-age=0");
@@ -227,19 +238,12 @@ app.get("/", async (c) => {
// List all feeds // List all feeds
const feedList = await listAllFeeds(emailStorage); const feedList = await listAllFeeds(emailStorage);
// Fetch full feed configs to get descriptions // Keep the dashboard fast: avoid N KV reads for N feeds.
const feedsWithConfig = await Promise.all( // We store title/description in `feeds:list` (description is optional for older data).
feedList.map(async (feed) => { const feedsWithConfig = feedList.map((feed) => ({
const configKey = `feed:${feed.id}:config`; ...feed,
const config = (await emailStorage.get(configKey, { description: feed.description || "",
type: "json", }));
})) as FeedConfig | null;
return {
...feed,
description: config?.description || "",
};
}),
);
const viewHref = (nextView: "list" | "table") => { const viewHref = (nextView: "list" | "table") => {
const nextUrl = new URL(url); const nextUrl = new URL(url);
@@ -272,7 +276,7 @@ app.get("/", async (c) => {
layout( layout(
"Dashboard", "Dashboard",
html` html`
<div class="container fade-in"> <div class="container ${view === "table" ? "container-wide" : ""} fade-in">
<div class="header-with-actions"> <div class="header-with-actions">
<div class="header-title"> <div class="header-title">
<h1>Email to RSS Admin</h1> <h1>Email to RSS Admin</h1>
@@ -354,8 +358,8 @@ app.get("/", async (c) => {
type="search" type="search"
id="feed-search" id="feed-search"
class="search" class="search"
placeholder="Search feed title, id, or description" placeholder="Search title, feed id, or description"
oninput="filterFeedRows()" oninput="scheduleFeedFilter()"
/> />
<button <button
type="button" type="button"
@@ -404,11 +408,20 @@ app.get("/", async (c) => {
</tr> </tr>
</thead> </thead>
<tbody id="feed-table-body"> <tbody id="feed-table-body">
${feedsWithConfig.map( ${feedsWithConfig.map((feed) => {
(feed) => html` const emailAddress = `${feed.id}@${env.DOMAIN}`;
const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
const titleDisplay = clampText(feed.title, 160);
const titleHover = clampText(feed.title, 1000);
const descDisplay = clampText(feed.description || "", 220);
const descHover = clampText(feed.description || "", 1000);
const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
return html`
<tr <tr
class="feed-row" class="feed-row"
data-search="${`${feed.title} ${feed.id} ${feed.description || ""}`.toLowerCase()}" data-search="${searchHaystack}"
> >
<td> <td>
<input <input
@@ -420,13 +433,16 @@ app.get("/", async (c) => {
/> />
</td> </td>
<td> <td>
<strong>${feed.title}</strong> <strong class="truncate" title="${titleHover}"
>${titleDisplay}</strong
>
${feed.description ${feed.description
? html`<div ? html`<div
class="muted" class="muted truncate"
style="font-size: var(--font-size-sm); margin-top: 4px;" style="font-size: var(--font-size-sm); margin-top: 4px;"
title="${descHover}"
> >
${feed.description} ${descDisplay}
</div>` </div>`
: ""} : ""}
</td> </td>
@@ -436,8 +452,9 @@ app.get("/", async (c) => {
<div class="copyable-content"> <div class="copyable-content">
<span <span
class="copyable-value" class="copyable-value"
data-copy="${feed.id}@${env.DOMAIN}" data-copy="${emailAddress}"
>${feed.id}@${env.DOMAIN}</span title="${emailAddress}"
>${emailAddress}</span
> >
<div class="copy-icon-container"> <div class="copy-icon-container">
<svg <svg
@@ -487,8 +504,9 @@ app.get("/", async (c) => {
<div class="copyable-content"> <div class="copyable-content">
<span <span
class="copyable-value" class="copyable-value"
data-copy="https://${env.DOMAIN}/rss/${feed.id}" data-copy="${rssUrl}"
>https://${env.DOMAIN}/rss/${feed.id}</span title="${rssUrl}"
>${rssUrl}</span
> >
<div class="copy-icon-container"> <div class="copy-icon-container">
<svg <svg
@@ -555,8 +573,8 @@ app.get("/", async (c) => {
</div> </div>
</td> </td>
</tr> </tr>
`, `;
)} })}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -581,8 +599,8 @@ app.get("/", async (c) => {
type="search" type="search"
id="feed-search" id="feed-search"
class="search" class="search"
placeholder="Search feed title, id, or description" placeholder="Search title, feed id, or description"
oninput="filterFeedRows()" oninput="scheduleFeedFilter()"
/> />
<span class="pill" <span class="pill"
>Tip: use Table view for bulk deletion.</span >Tip: use Table view for bulk deletion.</span
@@ -591,17 +609,28 @@ app.get("/", async (c) => {
</div> </div>
<ul class="feed-list"> <ul class="feed-list">
${feedsWithConfig.map( ${feedsWithConfig.map((feed) => {
(feed) => html` const emailAddress = `${feed.id}@${env.DOMAIN}`;
const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
const titleDisplay = clampText(feed.title, 140);
const titleHover = clampText(feed.title, 1000);
const descDisplay = clampText(feed.description || "", 240);
const descHover = clampText(feed.description || "", 1000);
const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
return html`
<li <li
class="feed-item card feed-row" class="feed-item card feed-row"
data-search="${`${feed.title} ${feed.id} ${feed.description || ""}`.toLowerCase()}" data-search="${searchHaystack}"
> >
<div class="feed-header"> <div class="feed-header">
<h3 class="feed-title">${feed.title}</h3> <h3 class="feed-title" title="${titleHover}">
${titleDisplay}
</h3>
${feed.description ${feed.description
? html`<p class="feed-description"> ? html`<p class="feed-description">
${feed.description} <span title="${descHover}">${descDisplay}</span>
</p>` </p>`
: html`<p class="feed-description empty"> : html`<p class="feed-description empty">
<i>No description</i> <i>No description</i>
@@ -614,8 +643,9 @@ app.get("/", async (c) => {
<div class="copyable-content"> <div class="copyable-content">
<span <span
class="copyable-value" class="copyable-value"
data-copy="${feed.id}@${env.DOMAIN}" data-copy="${emailAddress}"
>${feed.id}@${env.DOMAIN}</span title="${emailAddress}"
>${emailAddress}</span
> >
<div class="copy-icon-container"> <div class="copy-icon-container">
<svg <svg
@@ -662,8 +692,9 @@ app.get("/", async (c) => {
<div class="copyable-content"> <div class="copyable-content">
<span <span
class="copyable-value" class="copyable-value"
data-copy="https://${env.DOMAIN}/rss/${feed.id}" data-copy="${rssUrl}"
>https://${env.DOMAIN}/rss/${feed.id}</span title="${rssUrl}"
>${rssUrl}</span
> >
<div class="copy-icon-container"> <div class="copy-icon-container">
<svg <svg
@@ -731,14 +762,37 @@ app.get("/", async (c) => {
</div> </div>
</div> </div>
</li> </li>
`, `;
)} })}
</ul> </ul>
`} `}
</div> </div>
<script> <script>
${raw(` ${raw(`
let FEED_ROWS = [];
let FEED_CHECKBOXES = [];
let FEED_SELECTED_COUNT_EL = null;
let FEED_BULK_DELETE_BUTTON_EL = null;
let FEED_SELECT_ALL_EL = null;
let FEED_FILTER_TIMER = null;
function initFeedUI() {
FEED_ROWS = Array.from(document.querySelectorAll('.feed-row'));
FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select'));
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');
updateFeedSelectionState();
}
function scheduleFeedFilter() {
if (FEED_FILTER_TIMER) {
clearTimeout(FEED_FILTER_TIMER);
}
FEED_FILTER_TIMER = setTimeout(filterFeedRows, 120);
}
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';
@@ -751,56 +805,53 @@ app.get("/", async (c) => {
} }
function updateFeedSelectionState() { function updateFeedSelectionState() {
const checkboxes = Array.from(document.querySelectorAll('.feed-select')); if (!FEED_CHECKBOXES.length) {
const selected = checkboxes.filter((checkbox) => checkbox.checked); return;
const selectedCount = document.getElementById('selected-feed-count'); }
const bulkDeleteButton = document.getElementById('bulk-delete-feeds-button');
const selectAll = document.getElementById('select-all-feeds');
if (selectedCount) { const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked);
selectedCount.textContent = selected.length + ' selected';
if (FEED_SELECTED_COUNT_EL) {
FEED_SELECTED_COUNT_EL.textContent = selected.length + ' selected';
} }
if (bulkDeleteButton) { if (FEED_BULK_DELETE_BUTTON_EL) {
bulkDeleteButton.disabled = selected.length === 0; FEED_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0;
} }
if (selectAll) { if (FEED_SELECT_ALL_EL) {
const visibleCheckboxes = checkboxes.filter((checkbox) => checkbox.closest('tr')?.style.display !== 'none'); const visibleCheckboxes = FEED_CHECKBOXES.filter((checkbox) => !(checkbox.closest('tr')?.hidden));
selectAll.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked); FEED_SELECT_ALL_EL.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked);
} }
} }
function toggleAllFeeds(checked) { function toggleAllFeeds(checked) {
const checkboxes = document.querySelectorAll('.feed-select'); FEED_CHECKBOXES.forEach((checkbox) => {
checkboxes.forEach((checkbox) => { if (!checkbox.closest('tr')?.hidden) {
if (checkbox.closest('tr')?.style.display !== 'none') {
checkbox.checked = checked; checkbox.checked = checked;
} }
}); })
updateFeedSelectionState(); updateFeedSelectionState();
} }
function setVisibleFeedSelection(checked) { function setVisibleFeedSelection(checked) {
const checkboxes = document.querySelectorAll('.feed-select'); FEED_CHECKBOXES.forEach((checkbox) => {
checkboxes.forEach((checkbox) => { if (!checkbox.closest('tr')?.hidden) {
if (checkbox.closest('tr')?.style.display !== 'none') {
checkbox.checked = checked; checkbox.checked = checked;
} }
}); })
updateFeedSelectionState(); updateFeedSelectionState();
} }
function filterFeedRows() { function filterFeedRows() {
const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim(); const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim();
const rows = document.querySelectorAll('.feed-row'); FEED_ROWS.forEach((row) => {
rows.forEach((row) => {
const haystack = row.getAttribute('data-search') || ''; const haystack = row.getAttribute('data-search') || '';
row.style.display = !query || haystack.includes(query) ? '' : 'none'; row.hidden = !!query && !haystack.includes(query);
}); });
updateFeedSelectionState(); updateFeedSelectionState();
} }
function confirmBulkFeedDelete() { function confirmBulkFeedDelete() {
const selected = document.querySelectorAll('.feed-select:checked').length; const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
if (selected === 0) { if (selected === 0) {
return false; return false;
} }
@@ -808,7 +859,7 @@ app.get("/", async (c) => {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
updateFeedSelectionState(); initFeedUI();
}); });
`)}; `)};
</script> </script>
@@ -869,7 +920,12 @@ app.post("/feeds/create", async (c) => {
); );
// Add feed to the list of all feeds // Add feed to the list of all feeds
await addFeedToList(emailStorage, feedId, parsedData.title); await addFeedToList(
emailStorage,
feedId,
parsedData.title,
parsedData.description,
);
// Redirect back to admin page // Redirect back to admin page
return c.redirect(`/admin?view=${view}`); return c.redirect(`/admin?view=${view}`);
@@ -1010,7 +1066,12 @@ app.post("/feeds/:feedId/edit", async (c) => {
); );
// Update feed in the list of all feeds // Update feed in the list of all feeds
await updateFeedInList(emailStorage, feedId, parsedData.title); await updateFeedInList(
emailStorage,
feedId,
parsedData.title,
parsedData.description,
);
// Redirect back to admin page // Redirect back to admin page
return c.redirect("/admin"); return c.redirect("/admin");
@@ -1122,7 +1183,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
layout( layout(
`${feedConfig.title} - Emails`, `${feedConfig.title} - Emails`,
html` html`
<div class="container fade-in"> <div class="container container-wide fade-in">
<div class="header-with-actions"> <div class="header-with-actions">
<div class="header-title"> <div class="header-title">
<h1>${feedConfig.title} - Emails</h1> <h1>${feedConfig.title} - Emails</h1>
@@ -1255,7 +1316,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
id="email-search" id="email-search"
class="search" class="search"
placeholder="Search email subjects" placeholder="Search email subjects"
oninput="filterEmailRows()" oninput="scheduleEmailFilter()"
/> />
<button <button
type="button" type="button"
@@ -1297,11 +1358,15 @@ app.get("/feeds/:feedId/emails", async (c) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${feedMetadata.emails.map( ${feedMetadata.emails.map((email: EmailMetadata) => {
(email: EmailMetadata) => html` const subjectDisplay = clampText(email.subject, 180);
const subjectHover = clampText(email.subject, 1000);
const searchHaystack = clampText(email.subject, 320).toLowerCase();
return html`
<tr <tr
class="email-row" class="email-row"
data-search="${email.subject.toLowerCase()}" data-search="${searchHaystack}"
> >
<td> <td>
<input <input
@@ -1312,7 +1377,11 @@ app.get("/feeds/:feedId/emails", async (c) => {
onchange="updateEmailSelectionState()" onchange="updateEmailSelectionState()"
/> />
</td> </td>
<td>${email.subject}</td> <td>
<span class="truncate" title="${subjectHover}"
>${subjectDisplay}</span
>
</td>
<td> <td>
${new Date(email.receivedAt).toLocaleString()} ${new Date(email.receivedAt).toLocaleString()}
</td> </td>
@@ -1333,8 +1402,8 @@ app.get("/feeds/:feedId/emails", async (c) => {
</div> </div>
</td> </td>
</tr> </tr>
`, `;
)} })}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1360,6 +1429,29 @@ app.get("/feeds/:feedId/emails", async (c) => {
<script> <script>
${raw(` ${raw(`
let EMAIL_ROWS = [];
let EMAIL_CHECKBOXES = [];
let EMAIL_SELECTED_COUNT_EL = null;
let EMAIL_BULK_DELETE_BUTTON_EL = null;
let EMAIL_SELECT_ALL_EL = null;
let EMAIL_FILTER_TIMER = null;
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_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button');
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
updateEmailSelectionState();
}
function scheduleEmailFilter() {
if (EMAIL_FILTER_TIMER) {
clearTimeout(EMAIL_FILTER_TIMER);
}
EMAIL_FILTER_TIMER = setTimeout(filterEmailRows, 120);
}
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');
@@ -1371,56 +1463,53 @@ app.get("/feeds/:feedId/emails", async (c) => {
} }
function updateEmailSelectionState() { function updateEmailSelectionState() {
const checkboxes = Array.from(document.querySelectorAll('.email-select')); if (!EMAIL_CHECKBOXES.length) {
const selected = checkboxes.filter((checkbox) => checkbox.checked); return;
const selectedCount = document.getElementById('selected-email-count'); }
const bulkDeleteButton = document.getElementById('bulk-delete-emails-button');
const selectAll = document.getElementById('select-all-emails');
if (selectedCount) { const selected = EMAIL_CHECKBOXES.filter((checkbox) => checkbox.checked);
selectedCount.textContent = selected.length + ' selected';
if (EMAIL_SELECTED_COUNT_EL) {
EMAIL_SELECTED_COUNT_EL.textContent = selected.length + ' selected';
} }
if (bulkDeleteButton) { if (EMAIL_BULK_DELETE_BUTTON_EL) {
bulkDeleteButton.disabled = selected.length === 0; EMAIL_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0;
} }
if (selectAll) { if (EMAIL_SELECT_ALL_EL) {
const visibleCheckboxes = checkboxes.filter((checkbox) => checkbox.closest('tr')?.style.display !== 'none'); const visibleCheckboxes = EMAIL_CHECKBOXES.filter((checkbox) => !(checkbox.closest('tr')?.hidden));
selectAll.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked); EMAIL_SELECT_ALL_EL.checked = visibleCheckboxes.length > 0 && visibleCheckboxes.every((checkbox) => checkbox.checked);
} }
} }
function toggleAllEmails(checked) { function toggleAllEmails(checked) {
const checkboxes = document.querySelectorAll('.email-select'); EMAIL_CHECKBOXES.forEach((checkbox) => {
checkboxes.forEach((checkbox) => { if (!checkbox.closest('tr')?.hidden) {
if (checkbox.closest('tr')?.style.display !== 'none') {
checkbox.checked = checked; checkbox.checked = checked;
} }
}); })
updateEmailSelectionState(); updateEmailSelectionState();
} }
function setVisibleEmailSelection(checked) { function setVisibleEmailSelection(checked) {
const checkboxes = document.querySelectorAll('.email-select'); EMAIL_CHECKBOXES.forEach((checkbox) => {
checkboxes.forEach((checkbox) => { if (!checkbox.closest('tr')?.hidden) {
if (checkbox.closest('tr')?.style.display !== 'none') {
checkbox.checked = checked; checkbox.checked = checked;
} }
}); })
updateEmailSelectionState(); updateEmailSelectionState();
} }
function filterEmailRows() { function filterEmailRows() {
const query = (document.getElementById('email-search')?.value || '').toLowerCase().trim(); const query = (document.getElementById('email-search')?.value || '').toLowerCase().trim();
const rows = document.querySelectorAll('.email-row'); EMAIL_ROWS.forEach((row) => {
rows.forEach((row) => {
const haystack = row.getAttribute('data-search') || ''; const haystack = row.getAttribute('data-search') || '';
row.style.display = !query || haystack.includes(query) ? '' : 'none'; row.hidden = !!query && !haystack.includes(query);
}); });
updateEmailSelectionState(); updateEmailSelectionState();
} }
function confirmBulkEmailDelete() { function confirmBulkEmailDelete() {
const selected = document.querySelectorAll('.email-select:checked').length; const selected = EMAIL_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
if (selected === 0) { if (selected === 0) {
return false; return false;
} }
@@ -1428,7 +1517,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
updateEmailSelectionState(); initEmailUI();
}); });
`)}; `)};
</script> </script>
@@ -1910,6 +1999,7 @@ async function addFeedToList(
emailStorage: KVNamespace, emailStorage: KVNamespace,
feedId: string, feedId: string,
title: string, title: string,
description?: string,
): Promise<void> { ): Promise<void> {
try { try {
const feedListKey = "feeds:list"; const feedListKey = "feeds:list";
@@ -1921,6 +2011,7 @@ async function addFeedToList(
feedList.feeds.push({ feedList.feeds.push({
id: feedId, id: feedId,
title, title,
description,
}); });
// Store updated list // Store updated list
@@ -1935,6 +2026,7 @@ async function updateFeedInList(
emailStorage: KVNamespace, emailStorage: KVNamespace,
feedId: string, feedId: string,
title: string, title: string,
description?: string,
): Promise<void> { ): Promise<void> {
try { try {
const feedListKey = "feeds:list"; const feedListKey = "feeds:list";
@@ -1946,6 +2038,7 @@ async function updateFeedInList(
const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId);
if (feedIndex !== -1) { if (feedIndex !== -1) {
feedList.feeds[feedIndex].title = title; feedList.feeds[feedIndex].title = title;
feedList.feeds[feedIndex].description = description;
// Store updated list // Store updated list
await emailStorage.put(feedListKey, JSON.stringify(feedList)); 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 // 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 success response
return c.json({ success: true }); return c.json({ success: true });
+19 -17
View File
@@ -3,11 +3,8 @@
export const clipboardScripts = ` export const clipboardScripts = `
// Copy text to clipboard with animation feedback // Copy text to clipboard with animation feedback
function copyToClipboard(text, element) { function copyToClipboard(text, contentElement) {
// Find the parent .copyable element and the content element if (!contentElement) return;
const copyableContainer = element.closest('.copyable');
const contentElement = copyableContainer?.querySelector('.copyable-content');
if (!copyableContainer || !contentElement) return;
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
// Add the 'copied' class to the content element for success styling // Add the 'copied' class to the content element for success styling
@@ -24,18 +21,23 @@ export const clipboardScripts = `
// Initialize copyable elements // Initialize copyable elements
function setupCopyableElements() { function setupCopyableElements() {
document.querySelectorAll('.copyable').forEach(container => { // Event delegation avoids attaching hundreds/thousands of listeners
const contentElement = container.querySelector('.copyable-content'); // when many feeds/emails are rendered in table view.
const valueElement = container.querySelector('.copyable-value'); document.addEventListener('click', (event) => {
const target = event.target;
if (!target || !target.closest) return;
if (contentElement && valueElement) { const contentElement = target.closest('.copyable-content');
const textToCopy = valueElement.getAttribute('data-copy') || valueElement.textContent.trim(); if (!contentElement) return;
// Add click handler to the entire content area const container = contentElement.closest('.copyable');
contentElement.addEventListener('click', () => { const valueElement = container?.querySelector('.copyable-value');
copyToClipboard(textToCopy, contentElement); 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(); form.submit();
} }
} }
`; `;
+56
View File
@@ -591,10 +591,12 @@ export const componentStyles = `
table.table.table-feeds { table.table.table-feeds {
min-width: 860px; min-width: 860px;
table-layout: fixed;
} }
table.table.table-emails { table.table.table-emails {
min-width: 760px; min-width: 760px;
table-layout: fixed;
} }
table.table th, table.table th,
@@ -605,6 +607,47 @@ export const componentStyles = `
vertical-align: top; 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 { table.table thead th {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
@@ -639,6 +682,14 @@ export const componentStyles = `
font-size: 13px; font-size: 13px;
} }
.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* Compact copy-to-clipboard for table cells */ /* Compact copy-to-clipboard for table cells */
.copyable.copyable-inline { .copyable.copyable-inline {
margin-bottom: 0; margin-bottom: 0;
@@ -650,10 +701,15 @@ export const componentStyles = `
.copyable.copyable-inline .copyable-content { .copyable.copyable-inline .copyable-content {
padding: 6px 8px; padding: 6px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
width: 100%;
} }
.copyable.copyable-inline .copyable-value { .copyable.copyable-inline .copyable-value {
margin-right: var(--spacing-xs); margin-right: var(--spacing-xs);
word-break: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.row-actions { .row-actions {
+5
View File
@@ -35,6 +35,11 @@ export const layoutStyles = `
padding: var(--spacing-xl); padding: var(--spacing-xl);
box-sizing: border-box; box-sizing: border-box;
} }
/* Wider layout for data-dense pages (tables) */
.container-wide {
max-width: 1280px;
}
/* Header Styles */ /* Header Styles */
.header { .header {
+1
View File
@@ -48,6 +48,7 @@ export interface FeedList {
export interface FeedListItem { export interface FeedListItem {
id: string; id: string;
title: string; title: string;
description?: string;
} }
// Declare KVNamespace for TypeScript // Declare KVNamespace for TypeScript
+6 -4
View File
@@ -101,7 +101,7 @@ export async function createFeed(
})); }));
// Add feed to the list of all feeds // 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( export async function addFeedToList(
kv: KVNamespace, kv: KVNamespace,
feedId: string, feedId: string,
title: string title: string,
description?: string
): Promise<void> { ): Promise<void> {
const feedListKey = 'feeds:list'; const feedListKey = 'feeds:list';
const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null;
@@ -119,7 +120,8 @@ export async function addFeedToList(
feedList.feeds.push({ feedList.feeds.push({
id: feedId, id: feedId,
title title,
description
}); });
await kv.put(feedListKey, JSON.stringify(feedList)); await kv.put(feedListKey, JSON.stringify(feedList));
@@ -133,4 +135,4 @@ export async function getAllFeeds(kv: KVNamespace): Promise<FeedList> {
const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null;
return feedList || { feeds: [] }; return feedList || { feeds: [] };
} }