From 1c40740686619339c498fc16073e37eb7b384c36 Mon Sep 17 00:00:00 2001 From: Young Lee <8462583+yl8976@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:17:03 -0800 Subject: [PATCH] feat(admin): async bulk delete with toasts --- AGENTS.md | 1 + README.md | 1 + src/routes/admin.ts | 683 ++++++++++++++++++++++++++++++++------- src/scripts/index.ts | 3 + src/scripts/toast.ts | 101 ++++++ src/styles/components.ts | 129 ++++++++ 6 files changed, 809 insertions(+), 109 deletions(-) create mode 100644 src/scripts/toast.ts diff --git a/AGENTS.md b/AGENTS.md index ef10613..6222a7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ Notes: - Use **Table** view for bulk delete. - Table columns are resizable and sortable; widths persist per-browser via localStorage. - **Select Results** selects all rows currently shown by the search filter; **Clear Selection** unselects everything. +- Bulk deletes are performed asynchronously (batched requests) so the UI stays responsive. - Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds. ## Cloudflare/Wrangler conventions diff --git a/README.md b/README.md index e79f5dd..788e2eb 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ npm run build - 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. Use **Select Results** to select the filtered rows, then click **Delete Selected**. + - Bulk deletes run in small batches so the UI stays responsive. Keep the tab open until it finishes. 5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Select Results** and **Delete Selected**. ## Upgrading dependencies diff --git a/src/routes/admin.ts b/src/routes/admin.ts index b535e96..f0c6bf6 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -337,13 +337,15 @@ app.get("/", async (c) => { ? html`

No feeds were selected.

` : ""} -
-
-

Your Feeds

- ${feedsWithConfig.length} -
-
${viewToggle}
-
+
+
+

Your Feeds

+ ${feedsWithConfig.length} +
+
${viewToggle}
+
${feedsWithConfig.length === 0 ? html`
@@ -351,14 +353,14 @@ app.get("/", async (c) => {
` : view === "table" ? html` -
-
- +
+ +
@@ -807,26 +809,29 @@ app.get("/", async (c) => { `, @@ -1315,28 +1454,60 @@ app.post("/feeds/:feedId/edit", async (c) => { } }); +async function deleteKeysWithConcurrency( + emailStorage: KVNamespace, + keys: string[], + concurrency: number, +): Promise<{ ok: string[]; failed: string[] }> { + const uniqueKeys = Array.from(new Set(keys.filter(Boolean))); + const ok: string[] = []; + const failed: string[] = []; + const limit = Math.max(1, Math.floor(concurrency) || 1); + + for (let i = 0; i < uniqueKeys.length; i += limit) { + const batch = uniqueKeys.slice(i, i + limit); + const results = await Promise.allSettled( + batch.map((key) => emailStorage.delete(key)), + ); + results.forEach((result, idx) => { + const key = batch[idx]; + if (result.status === "fulfilled") { + ok.push(key); + } else { + failed.push(key); + } + }); + } + + return { ok, failed }; +} + async function deleteFeedAndEmails( emailStorage: KVNamespace, feedId: string, + options: { skipListUpdate?: boolean } = {}, ): Promise { + const feedConfigKey = `feed:${feedId}:config`; const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = (await emailStorage.get(feedMetadataKey, { - type: "json", - })) as FeedMetadata | null; - if (!feedMetadata) { - return false; - } + const [feedConfig, feedMetadata] = (await Promise.all([ + emailStorage.get(feedConfigKey, { type: "json" }), + emailStorage.get(feedMetadataKey, { type: "json" }), + ])) as [FeedConfig | null, FeedMetadata | null]; - for (const email of feedMetadata.emails) { - await emailStorage.delete(email.key); - } + const emailKeys = (feedMetadata?.emails || []).map((email) => email.key); + await deleteKeysWithConcurrency(emailStorage, emailKeys, 25); - await emailStorage.delete(`feed:${feedId}:config`); - await emailStorage.delete(feedMetadataKey); - await removeFeedFromList(emailStorage, feedId); + await Promise.all([ + emailStorage.delete(feedConfigKey), + emailStorage.delete(feedMetadataKey), + ]); - return true; + const removedFromList = options.skipListUpdate + ? false + : await removeFeedFromList(emailStorage, feedId); + + return !!feedConfig || !!feedMetadata || removedFromList; } // Delete feed @@ -1363,26 +1534,108 @@ app.post("/feeds/bulk-delete", async (c) => { const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; - try { + try { + const contentType = c.req.header("Content-Type") || ""; + const wantsJson = + contentType.includes("application/json") || + (c.req.header("Accept") || "").includes("application/json"); + + if (wantsJson) { + const body = (await c.req.json().catch(() => null)) as { + feedIds?: unknown; + } | null; + + const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : []; + const parsedFeedIds = Array.from( + new Set(rawIds.map((value) => String(value)).filter(Boolean)), + ); + + if (parsedFeedIds.length === 0) { + return c.json({ ok: false, error: "No feeds were selected." }, 400); + } + + // The UI batches requests; cap to avoid accidental huge deletes in one request. + if (parsedFeedIds.length > 50) { + return c.json( + { + ok: false, + error: + "Too many feedIds for a single request. Please delete in smaller batches.", + }, + 413, + ); + } + + const results: Array<{ feedId: string; ok: boolean }> = []; + const concurrency = 3; + + for (let i = 0; i < parsedFeedIds.length; i += concurrency) { + const batch = parsedFeedIds.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(async (feedId) => { + try { + await deleteFeedAndEmails(emailStorage, feedId, { + skipListUpdate: true, + }); + return { feedId, ok: true }; + } catch (error) { + console.error("Error bulk deleting feed:", feedId, error); + return { feedId, ok: false }; + } + }), + ); + results.push(...batchResults); + } + + const okIds = results.filter((r) => r.ok).map((r) => r.feedId); + const failedFeedIds = results.filter((r) => !r.ok).map((r) => r.feedId); + + const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); + + return c.json({ + ok: failedFeedIds.length === 0, + deletedFeedIds, + failedFeedIds, + }); + } + const formData = await c.req.formData(); const view = formData.get("view")?.toString() === "table" ? "table" : "list"; const redirectBase = `/admin?view=${view}`; const rawIds = formData.getAll("feedIds").map((value) => value.toString()); - const feedIds = Array.from(new Set(rawIds.filter(Boolean))); + const parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean))); - if (feedIds.length === 0) { + if (parsedFeedIds.length === 0) { return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); } - let deletedCount = 0; - for (const feedId of feedIds) { - const deleted = await deleteFeedAndEmails(emailStorage, feedId); - if (deleted) { - deletedCount += 1; - } + const results: Array<{ feedId: string; ok: boolean }> = []; + const concurrency = 3; + + for (let i = 0; i < parsedFeedIds.length; i += concurrency) { + const batch = parsedFeedIds.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(async (feedId) => { + try { + await deleteFeedAndEmails(emailStorage, feedId, { + skipListUpdate: true, + }); + return { feedId, ok: true }; + } catch (error) { + console.error("Error bulk deleting feed:", feedId, error); + return { feedId, ok: false }; + } + }), + ); + results.push(...batchResults); } - return c.redirect(`${redirectBase}&message=bulkDeleted&count=${deletedCount}`); + const okIds = results.filter((r) => r.ok).map((r) => r.feedId); + const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); + + return c.redirect( + `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, + ); } catch (error) { console.error("Error bulk deleting feeds:", error); return c.text("Error bulk deleting feeds. Please try again.", 400); @@ -1531,7 +1784,9 @@ app.get("/feeds/:feedId/emails", async (c) => {
-

Emails (${feedMetadata.emails.length})

+

+ Emails (${feedMetadata.emails.length}) +

${message === "bulkDeleted" ? html`
@@ -1543,11 +1798,11 @@ app.get("/feeds/:feedId/emails", async (c) => { : ""} ${feedMetadata.emails.length > 0 ? html` - +
{ `, @@ -2388,6 +2775,66 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => { const feedId = c.req.param("feedId"); try { + const contentType = c.req.header("Content-Type") || ""; + const wantsJson = + contentType.includes("application/json") || + (c.req.header("Accept") || "").includes("application/json"); + + const feedMetadataKey = `feed:${feedId}:metadata`; + const feedMetadata = (await emailStorage.get(feedMetadataKey, { + type: "json", + })) as FeedMetadata | null; + if (!feedMetadata) { + return wantsJson + ? c.json({ ok: false, error: "Feed not found" }, 404) + : c.text("Feed not found", 404); + } + + const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key)); + + if (wantsJson) { + const body = (await c.req.json().catch(() => null)) as { + emailKeys?: unknown; + } | null; + + const rawEmailKeys = Array.isArray(body?.emailKeys) ? body?.emailKeys : []; + const emailKeys = Array.from( + new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)), + ); + + if (emailKeys.length === 0) { + return c.json({ ok: false, error: "No emails were selected." }, 400); + } + + // The UI batches requests; cap to avoid accidental huge deletes in one request. + if (emailKeys.length > 250) { + return c.json( + { + ok: false, + error: + "Too many emailKeys for a single request. Please delete in smaller batches.", + }, + 413, + ); + } + + const candidates = emailKeys.filter((key) => allowedKeys.has(key)); + const { ok: deletedOk, failed: failedEmailKeys } = + await deleteKeysWithConcurrency(emailStorage, candidates, 35); + + const deletedSet = new Set(deletedOk); + feedMetadata.emails = feedMetadata.emails.filter( + (email) => !deletedSet.has(email.key), + ); + await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); + + return c.json({ + ok: failedEmailKeys.length === 0, + deletedEmailKeys: deletedOk, + failedEmailKeys, + }); + } + const formData = await c.req.formData(); const rawEmailKeys = formData .getAll("emailKeys") @@ -2398,32 +2845,21 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => { return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`); } - const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = (await emailStorage.get(feedMetadataKey, { - type: "json", - })) as FeedMetadata | null; - if (!feedMetadata) { - return c.text("Feed not found", 404); - } - - const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key)); - let deletedCount = 0; - - for (const emailKey of emailKeys) { - if (!allowedKeys.has(emailKey)) { - continue; - } - await emailStorage.delete(emailKey); - deletedCount += 1; - } + const candidates = emailKeys.filter((key) => allowedKeys.has(key)); + const { ok: deletedOk } = await deleteKeysWithConcurrency( + emailStorage, + candidates, + 35, + ); + const deletedSet = new Set(deletedOk); feedMetadata.emails = feedMetadata.emails.filter( - (email) => !emailKeys.includes(email.key), + (email) => !deletedSet.has(email.key), ); await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); return c.redirect( - `/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedCount}`, + `/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`, ); } catch (error) { console.error("Error bulk deleting emails:", error); @@ -2501,27 +2937,56 @@ async function updateFeedInList( } } -// Helper function to remove a feed from the list of all feeds -async function removeFeedFromList( +async function removeFeedsFromListBulk( emailStorage: KVNamespace, - feedId: string, -): Promise { + feedIds: string[], +): Promise { try { const feedListKey = "feeds:list"; const feedList = ((await emailStorage.get(feedListKey, { type: "json", })) as FeedList | null) || { feeds: [] }; - // Filter out the removed feed - feedList.feeds = feedList.feeds.filter((feed) => feed.id !== feedId); + const toRemove = new Set(feedIds.filter(Boolean)); + if (toRemove.size === 0) { + return []; + } + + const removed: string[] = []; + const nextFeeds: FeedListItem[] = []; + + for (const feed of feedList.feeds) { + if (toRemove.has(feed.id)) { + removed.push(feed.id); + continue; + } + nextFeeds.push(feed); + } + + if (removed.length === 0) { + return []; + } + + feedList.feeds = nextFeeds; // Store updated list await emailStorage.put(feedListKey, JSON.stringify(feedList)); + return removed; } catch (error) { console.error("Error removing feed from list:", error); + return []; } } +// Helper function to remove a feed from the list of all feeds +async function removeFeedFromList( + emailStorage: KVNamespace, + feedId: string, +): Promise { + const removed = await removeFeedsFromListBulk(emailStorage, [feedId]); + return removed.includes(feedId); +} + // Update feed via API (for in-place editing) app.post("/api/feeds/:feedId/update", async (c) => { // Type assertion for environment variables diff --git a/src/scripts/index.ts b/src/scripts/index.ts index cf75739..6792347 100644 --- a/src/scripts/index.ts +++ b/src/scripts/index.ts @@ -4,9 +4,11 @@ import { modalScripts, emailViewScripts, initScripts } from "./interactions"; import { clipboardScripts } from "./clipboard"; import { authHelpers } from "./auth"; +import { toastScripts } from "./toast"; // Combine all scripts into a single JavaScript string export const interactiveScripts = ` + ${toastScripts} ${modalScripts} ${emailViewScripts} ${clipboardScripts} @@ -20,4 +22,5 @@ export { initScripts, clipboardScripts, authHelpers, + toastScripts, }; diff --git a/src/scripts/toast.ts b/src/scripts/toast.ts new file mode 100644 index 0000000..8340833 --- /dev/null +++ b/src/scripts/toast.ts @@ -0,0 +1,101 @@ +// Toast notifications (lightweight, no deps) +// Designed to match the project's "liquid glass" design language. + +export const toastScripts = ` + (function () { + function ensureToastStack() { + let stack = document.getElementById('toast-stack'); + if (stack) return stack; + stack = document.createElement('div'); + stack.id = 'toast-stack'; + stack.className = 'toast-stack'; + document.body.appendChild(stack); + return stack; + } + + function createToastEl(message, opts) { + const type = (opts && opts.type) ? String(opts.type) : 'info'; + const loading = !!(opts && opts.loading); + + const toast = document.createElement('div'); + toast.className = 'toast toast-' + type; + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); + + const body = document.createElement('div'); + body.className = 'toast-body'; + + if (loading) { + const spin = document.createElement('span'); + spin.className = 'spinner'; + spin.setAttribute('aria-hidden', 'true'); + body.appendChild(spin); + } + + const text = document.createElement('div'); + text.className = 'toast-text'; + text.textContent = String(message || ''); + body.appendChild(text); + + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'toast-close'; + close.setAttribute('aria-label', 'Dismiss notification'); + close.textContent = 'x'; + + toast.appendChild(body); + toast.appendChild(close); + + return { toast, text, close }; + } + + function showToast(message, opts) { + const options = opts || {}; + const duration = Number.isFinite(options.duration) ? Number(options.duration) : 4500; + + const stack = ensureToastStack(); + const { toast, text, close } = createToastEl(message, options); + + let dismissed = false; + let timeoutId = 0; + + function dismiss() { + if (dismissed) return; + dismissed = true; + toast.classList.remove('visible'); + // Match CSS transition duration to avoid abrupt removal + setTimeout(() => { + toast.remove(); + }, 220); + } + + function update(nextMessage, nextOpts) { + if (dismissed) return; + text.textContent = String(nextMessage || ''); + if (nextOpts && typeof nextOpts.type === 'string') { + toast.className = 'toast toast-' + nextOpts.type; + } + } + + close.addEventListener('click', dismiss); + toast.addEventListener('click', (e) => { + // Clicking the toast itself dismisses, but keep buttons functional + if (e.target === close) return; + dismiss(); + }); + + stack.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('visible')); + + // duration: 0 means "persistent" + if (duration !== 0) { + timeoutId = window.setTimeout(dismiss, duration); + } + + return { dismiss, update }; + } + + // Expose globally + window.showToast = showToast; + })(); +`; diff --git a/src/styles/components.ts b/src/styles/components.ts index 7dd26bd..50c968d 100644 --- a/src/styles/components.ts +++ b/src/styles/components.ts @@ -779,6 +779,135 @@ export const componentStyles = ` gap: 6px; flex-wrap: wrap; } + + /* Spinner (buttons + toasts) */ + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + .spinner { + width: 14px; + height: 14px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: rgba(255, 255, 255, 0.95); + display: inline-block; + animation: spin 0.85s linear infinite; + flex: 0 0 auto; + } + + @media (prefers-color-scheme: light) { + .spinner { + border-color: rgba(0, 0, 0, 0.16); + border-top-color: rgba(0, 0, 0, 0.55); + } + } + + .button.is-loading { + pointer-events: none; + } + + .button .spinner { + margin-right: 8px; + } + + /* Toasts */ + .toast-stack { + position: fixed; + top: 18px; + right: 18px; + width: min(360px, calc(100vw - 36px)); + display: flex; + flex-direction: column; + gap: 10px; + z-index: 2000; + } + + .toast { + opacity: 0; + transform: translateY(-8px); + transition: opacity 180ms ease, transform 180ms ease; + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.12); + background-color: rgba(44, 44, 46, 0.72); + backdrop-filter: blur(var(--blur-md)); + -webkit-backdrop-filter: blur(var(--blur-md)); + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.28); + padding: 12px 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + color: var(--color-text-primary); + } + + @media (prefers-color-scheme: light) { + .toast { + background-color: rgba(255, 255, 255, 0.78); + border-color: rgba(60, 60, 67, 0.18); + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.14); + } + } + + .toast.visible { + opacity: 1; + transform: translateY(0); + } + + .toast-body { + display: flex; + gap: 10px; + align-items: flex-start; + flex: 1; + min-width: 0; + } + + .toast-text { + font-size: 14px; + line-height: 1.35; + color: var(--color-text-primary); + word-break: break-word; + flex: 1; + min-width: 0; + } + + .toast-close { + appearance: none; + border: none; + background: transparent; + color: var(--color-text-tertiary); + font-size: 18px; + line-height: 1; + padding: 2px 6px; + cursor: pointer; + border-radius: var(--radius-sm); + flex: 0 0 auto; + } + + .toast-close:hover { + background-color: rgba(255, 255, 255, 0.06); + color: var(--color-text-secondary); + } + + @media (prefers-color-scheme: light) { + .toast-close:hover { + background-color: rgba(0, 0, 0, 0.06); + } + } + + .toast-info { + border-color: rgba(10, 132, 255, 0.35); + } + + .toast-success { + border-color: rgba(48, 209, 88, 0.35); + } + + .toast-error { + border-color: rgba(255, 69, 58, 0.35); + } /* Feed and Email Lists */ .feed-list,