mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
fix(admin): truncate spam titles + speed up table view
This commit is contained in:
@@ -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 }> }`
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
+17
-15
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export const layoutStyles = `
|
|||||||
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 {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-xl);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user