diff --git a/README.md b/README.md index 788e2eb..97c1885 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da - One-click feed creation from an admin dashboard - Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow) +- Inline double-confirm delete interactions with toast feedback in the admin dashboard - Resizable + sortable table columns in the admin dashboard (Table view) - Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`) - ForwardEmail webhook ingestion with source-IP verification diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 65c1f2c..3befc9b 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -263,6 +263,42 @@ describe("Admin Routes", () => { expect(feedConfig).toBeNull(); }); + it("should return JSON for feed deletion when requested", async () => { + const authCookie = await loginAndGetCookie(); + const formData = new FormData(); + formData.append("title", "JSON Feed"); + formData.append("description", "Test Description"); + + const createRes = await request("/admin/feeds/create", { + method: "POST", + headers: { + Cookie: authCookie, + }, + body: formData, + }); + + expect(createRes.status).toBe(302); + + const feedList = (await mockEnv.EMAIL_STORAGE.get( + "feeds:list", + "json", + )) as { feeds: Array<{ id: string; title: string }> } | null; + const feedId = feedList?.feeds[0].id as string; + + const deleteRes = await request(`/admin/feeds/${feedId}/delete?view=list`, { + method: "POST", + headers: { + Cookie: authCookie, + Accept: "application/json", + }, + }); + + expect(deleteRes.status).toBe(200); + const payload = await deleteRes.json(); + expect(payload.ok).toBe(true); + expect(payload.feedId).toBe(feedId); + }); + it("should allow bulk feed deletion with valid authentication", async () => { const authCookie = await loginAndGetCookie(); @@ -310,5 +346,80 @@ describe("Admin Routes", () => { expect(feedListAfter?.feeds.length).toBe(0); }); }); + + describe("Email Management", () => { + it("should return JSON for email deletion when requested", async () => { + const authCookie = await loginAndGetCookie(); + const formData = new FormData(); + formData.append("title", "Email Feed"); + formData.append("description", "Test Description"); + + const createRes = await request("/admin/feeds/create", { + method: "POST", + headers: { + Cookie: authCookie, + }, + body: formData, + }); + + expect(createRes.status).toBe(302); + + const feedList = (await mockEnv.EMAIL_STORAGE.get( + "feeds:list", + "json", + )) as { feeds: Array<{ id: string; title: string }> } | null; + const feedId = feedList?.feeds[0].id as string; + const emailKey = `feed:${feedId}:emails:123456`; + + await mockEnv.EMAIL_STORAGE.put( + emailKey, + JSON.stringify({ + subject: "Hello", + from: "sender@example.com", + content: "

Hi

", + receivedAt: 123456, + headers: {}, + }), + ); + + const feedMetadataKey = `feed:${feedId}:metadata`; + const feedMetadata = (await mockEnv.EMAIL_STORAGE.get( + feedMetadataKey, + "json", + )) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null; + const updatedMetadata = { + emails: [ + ...(feedMetadata?.emails || []), + { key: emailKey, subject: "Hello", receivedAt: 123456 }, + ], + }; + await mockEnv.EMAIL_STORAGE.put( + feedMetadataKey, + JSON.stringify(updatedMetadata), + ); + + const deleteRes = await request(`/admin/emails/${emailKey}/delete?feedId=${feedId}`, { + method: "POST", + headers: { + Cookie: authCookie, + Accept: "application/json", + }, + }); + + expect(deleteRes.status).toBe(200); + const payload = await deleteRes.json(); + expect(payload.ok).toBe(true); + expect(payload.emailKey).toBe(emailKey); + + const deletedEmail = await mockEnv.EMAIL_STORAGE.get(emailKey, "json"); + expect(deletedEmail).toBeNull(); + + const metadataAfter = (await mockEnv.EMAIL_STORAGE.get( + feedMetadataKey, + "json", + )) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null; + expect(metadataAfter?.emails.length).toBe(0); + }); + }); }); }); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index e428000..66681d8 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -476,14 +476,15 @@ app.get("/", async (c) => { `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); return html` - + { > @@ -672,6 +675,7 @@ app.get("/", async (c) => { return html`
  • @@ -802,8 +806,10 @@ app.get("/", async (c) => {
    @@ -841,6 +847,7 @@ app.get("/", async (c) => { FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds'); setupFeedTableResizing(); setupFeedTableSorting(); + setupFeedDeleteButtons(); updateFeedMatchCount(); updateFeedSelectionState(); } @@ -1029,15 +1036,163 @@ app.get("/", async (c) => { }); } - function confirmDelete(feedId) { - if (confirm('Are you sure you want to delete this feed? This action cannot be undone.')) { - const currentView = new URL(window.location.href).searchParams.get('view') || 'list'; - const form = document.createElement('form'); - form.method = 'POST'; - form.action = '/admin/feeds/' + feedId + '/delete?view=' + encodeURIComponent(currentView); - document.body.appendChild(form); - form.submit(); + const DELETE_CONFIRM_LABEL = 'Confirm delete'; + const DELETE_LOADING_LABEL = 'Deleting...'; + const DELETE_CONFIRM_TIMEOUT_MS = 4000; + + function getDeleteView() { + return new URL(window.location.href).searchParams.get('view') || 'list'; + } + + function resetDeleteButton(buttonEl) { + if (!buttonEl) return; + buttonEl.classList.remove('is-confirming'); + buttonEl.removeAttribute('data-confirming'); + buttonEl.disabled = false; + const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim() || 'Delete'; + buttonEl.innerHTML = original; + } + + function animateRowRemoval(row, onDone) { + if (!row) { + if (onDone) onDone(); + return; } + + const isListItem = row.tagName.toLowerCase() === 'li'; + if (isListItem) { + row.style.maxHeight = row.getBoundingClientRect().height + 'px'; + row.style.overflow = 'hidden'; + } + + row.classList.add('is-removing'); + + requestAnimationFrame(() => { + if (isListItem) { + row.style.maxHeight = '0px'; + row.style.marginTop = '0px'; + row.style.marginBottom = '0px'; + row.style.paddingTop = '0px'; + row.style.paddingBottom = '0px'; + } + }); + + window.setTimeout(() => { + row.remove(); + if (onDone) onDone(); + }, 240); + } + + async function deleteFeedRequest(feedId, view) { + const res = await fetch('/admin/feeds/' + encodeURIComponent(feedId) + '/delete?view=' + encodeURIComponent(view), { + method: 'POST', + headers: { + 'Accept': 'application/json', + }, + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); + throw new Error(message); + } + return data; + } + + function refreshFeedRowCache() { + FEED_ROWS = Array.from(document.querySelectorAll('.feed-row')); + FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select')); + if (FEED_TOTAL_COUNT_EL) { + FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length); + } + updateFeedMatchCount(); + updateFeedSelectionState(); + } + + function setupFeedDeleteButtons() { + const buttons = Array.from(document.querySelectorAll('button[data-delete-kind="feed"]')); + buttons.forEach((button) => { + if (button.dataset.deleteReady === 'true') return; + button.dataset.deleteReady = 'true'; + const original = (button.textContent || '').trim() || 'Delete'; + button.dataset.originalLabel = original; + + let confirming = false; + let confirmTimer = 0; + let inFlight = false; + + const startConfirm = () => { + confirming = true; + button.classList.add('is-confirming'); + button.setAttribute('data-confirming', 'true'); + button.innerHTML = DELETE_CONFIRM_LABEL; + if (confirmTimer) window.clearTimeout(confirmTimer); + confirmTimer = window.setTimeout(() => { + confirming = false; + resetDeleteButton(button); + }, DELETE_CONFIRM_TIMEOUT_MS); + }; + + button.addEventListener('click', async (event) => { + event.preventDefault(); + if (inFlight) return; + + if (!confirming) { + startConfirm(); + return; + } + + if (confirmTimer) window.clearTimeout(confirmTimer); + inFlight = true; + setButtonLoading(button, true, DELETE_LOADING_LABEL); + + const toast = window.showToast + ? window.showToast('Deleting feed...', { type: 'info', loading: true, duration: 0 }) + : null; + + const feedId = button.getAttribute('data-feed-id') || ''; + const view = button.getAttribute('data-view') || getDeleteView(); + const row = button.closest('.feed-row'); + + try { + await deleteFeedRequest(feedId, view); + + if (toast && toast.update) { + toast.update('Feed deleted.', { type: 'success', loading: false, duration: 3200 }); + } else if (window.showToast) { + window.showToast('Feed deleted.', { type: 'success' }); + } + + animateRowRemoval(row, () => { + refreshFeedRowCache(); + }); + } catch (error) { + if (toast && toast.update) { + toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false, duration: 6500 }); + } else if (window.showToast) { + window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 6500 }); + } + setButtonLoading(button, false); + confirming = false; + resetDeleteButton(button); + } finally { + inFlight = false; + if (!row) { + setButtonLoading(button, false); + confirming = false; + resetDeleteButton(button); + } + } + }); + + button.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && confirming && !inFlight) { + confirming = false; + if (confirmTimer) window.clearTimeout(confirmTimer); + resetDeleteButton(button); + } + }); + }); } function updateFeedSelectionState() { @@ -1541,6 +1696,7 @@ app.post("/feeds/:feedId/delete", async (c) => { const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const view = c.req.query("view") === "table" ? "table" : "list"; + const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { await deleteFeedFast(emailStorage, feedId); @@ -1549,9 +1705,15 @@ app.post("/feeds/:feedId/delete", async (c) => { // Best-effort cleanup in the background so the request stays fast. // Use the UI purge endpoint for full, user-visible progress. waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId)); + if (wantsJson) { + return c.json({ ok: true, feedId }); + } return c.redirect(`/admin?view=${view}`); } catch (error) { console.error("Error deleting feed:", error); + if (wantsJson) { + return c.json({ ok: false, error: "Error deleting feed. Please try again." }, 400); + } return c.text("Error deleting feed. Please try again.", 400); } }); @@ -1957,6 +2119,7 @@ app.get("/feeds/:feedId/emails", async (c) => { return html` { > @@ -2036,6 +2201,7 @@ app.get("/feeds/:feedId/emails", async (c) => { EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails'); setupEmailTableResizing(); setupEmailTableSorting(); + setupEmailDeleteButtons(); updateEmailMatchCount(); updateEmailSelectionState(); } @@ -2220,14 +2386,143 @@ app.get("/feeds/:feedId/emails", async (c) => { }); } - function confirmDeleteEmail(emailKey, feedId) { - if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) { - const form = document.createElement('form'); - form.method = 'POST'; - form.action = '/admin/emails/' + emailKey + '/delete?feedId=' + feedId; - document.body.appendChild(form); - form.submit(); + const EMAIL_DELETE_CONFIRM_LABEL = 'Confirm delete'; + const EMAIL_DELETE_LOADING_LABEL = 'Deleting...'; + const EMAIL_DELETE_CONFIRM_TIMEOUT_MS = 4000; + + function resetEmailDeleteButton(buttonEl) { + if (!buttonEl) return; + buttonEl.classList.remove('is-confirming'); + buttonEl.removeAttribute('data-confirming'); + buttonEl.disabled = false; + const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim() || 'Delete'; + buttonEl.innerHTML = original; + } + + function animateEmailRowRemoval(row, onDone) { + if (!row) { + if (onDone) onDone(); + return; } + + row.classList.add('is-removing'); + + window.setTimeout(() => { + row.remove(); + if (onDone) onDone(); + }, 240); + } + + async function deleteEmailRequest(emailKey, feedId) { + const res = await fetch('/admin/emails/' + encodeURIComponent(emailKey) + '/delete?feedId=' + encodeURIComponent(feedId), { + method: 'POST', + headers: { + 'Accept': 'application/json', + }, + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const message = data && data.error ? String(data.error) : ('Request failed (' + res.status + ')'); + throw new Error(message); + } + return data; + } + + function refreshEmailRowCache() { + EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row')); + EMAIL_CHECKBOXES = Array.from(document.querySelectorAll('.email-select')); + if (EMAIL_TOTAL_COUNT_EL) { + EMAIL_TOTAL_COUNT_EL.textContent = String(EMAIL_ROWS.length); + } + updateEmailMatchCount(); + updateEmailSelectionState(); + } + + function setupEmailDeleteButtons() { + const buttons = Array.from(document.querySelectorAll('button[data-delete-kind="email"]')); + buttons.forEach((button) => { + if (button.dataset.deleteReady === 'true') return; + button.dataset.deleteReady = 'true'; + const original = (button.textContent || '').trim() || 'Delete'; + button.dataset.originalLabel = original; + + let confirming = false; + let confirmTimer = 0; + let inFlight = false; + + const startConfirm = () => { + confirming = true; + button.classList.add('is-confirming'); + button.setAttribute('data-confirming', 'true'); + button.innerHTML = EMAIL_DELETE_CONFIRM_LABEL; + if (confirmTimer) window.clearTimeout(confirmTimer); + confirmTimer = window.setTimeout(() => { + confirming = false; + resetEmailDeleteButton(button); + }, EMAIL_DELETE_CONFIRM_TIMEOUT_MS); + }; + + button.addEventListener('click', async (event) => { + event.preventDefault(); + if (inFlight) return; + + if (!confirming) { + startConfirm(); + return; + } + + if (confirmTimer) window.clearTimeout(confirmTimer); + inFlight = true; + setButtonLoading(button, true, EMAIL_DELETE_LOADING_LABEL); + + const toast = window.showToast + ? window.showToast('Deleting email...', { type: 'info', loading: true, duration: 0 }) + : null; + + const emailKey = button.getAttribute('data-email-key') || ''; + const feedId = button.getAttribute('data-feed-id') || EMAIL_FEED_ID; + const row = button.closest('.email-row'); + + try { + await deleteEmailRequest(emailKey, feedId); + + if (toast && toast.update) { + toast.update('Email deleted.', { type: 'success', loading: false, duration: 3200 }); + } else if (window.showToast) { + window.showToast('Email deleted.', { type: 'success' }); + } + + animateEmailRowRemoval(row, () => { + refreshEmailRowCache(); + }); + } catch (error) { + if (toast && toast.update) { + toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false, duration: 6500 }); + } else if (window.showToast) { + window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 6500 }); + } + setButtonLoading(button, false); + confirming = false; + resetEmailDeleteButton(button); + } finally { + inFlight = false; + if (!row) { + setButtonLoading(button, false); + confirming = false; + resetEmailDeleteButton(button); + } + } + }); + + button.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && confirming && !inFlight) { + confirming = false; + if (confirmTimer) window.clearTimeout(confirmTimer); + resetEmailDeleteButton(button); + } + }); + }); } function updateEmailSelectionState() { @@ -2800,12 +3095,16 @@ app.post("/emails/:emailKey/delete", async (c) => { const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param("emailKey"); + const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { // Get feedId from query parameters instead of form data const feedId = c.req.query("feedId"); if (!feedId) { + if (wantsJson) { + return c.json({ ok: false, error: "Feed ID is required" }, 400); + } return c.text("Feed ID is required", 400); } @@ -2828,10 +3127,16 @@ app.post("/emails/:emailKey/delete", async (c) => { await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); } + if (wantsJson) { + return c.json({ ok: true, emailKey, feedId }); + } // Redirect back to the feed emails page return c.redirect(`/admin/feeds/${feedId}/emails`); } catch (error) { console.error("Error deleting email:", error); + if (wantsJson) { + return c.json({ ok: false, error: "Error deleting email. Please try again." }, 400); + } return c.text("Error deleting email. Please try again.", 400); } }); diff --git a/src/scripts/toast.ts b/src/scripts/toast.ts index 8340833..6a24dfd 100644 --- a/src/scripts/toast.ts +++ b/src/scripts/toast.ts @@ -25,11 +25,12 @@ export const toastScripts = ` const body = document.createElement('div'); body.className = 'toast-body'; + let spinner = null; if (loading) { - const spin = document.createElement('span'); - spin.className = 'spinner'; - spin.setAttribute('aria-hidden', 'true'); - body.appendChild(spin); + spinner = document.createElement('span'); + spinner.className = 'spinner'; + spinner.setAttribute('aria-hidden', 'true'); + body.appendChild(spinner); } const text = document.createElement('div'); @@ -46,7 +47,7 @@ export const toastScripts = ` toast.appendChild(body); toast.appendChild(close); - return { toast, text, close }; + return { toast, text, close, spinner, body }; } function showToast(message, opts) { @@ -54,14 +55,16 @@ export const toastScripts = ` const duration = Number.isFinite(options.duration) ? Number(options.duration) : 4500; const stack = ensureToastStack(); - const { toast, text, close } = createToastEl(message, options); + const { toast, text, close, body } = createToastEl(message, options); let dismissed = false; let timeoutId = 0; + let currentDuration = duration; function dismiss() { if (dismissed) return; dismissed = true; + if (timeoutId) window.clearTimeout(timeoutId); toast.classList.remove('visible'); // Match CSS transition duration to avoid abrupt removal setTimeout(() => { @@ -69,12 +72,34 @@ export const toastScripts = ` }, 220); } + function scheduleDismiss(nextDuration) { + if (timeoutId) window.clearTimeout(timeoutId); + if (nextDuration !== 0) { + timeoutId = window.setTimeout(dismiss, nextDuration); + } + } + function update(nextMessage, nextOpts) { if (dismissed) return; text.textContent = String(nextMessage || ''); if (nextOpts && typeof nextOpts.type === 'string') { toast.className = 'toast toast-' + nextOpts.type; } + if (nextOpts && typeof nextOpts.loading === 'boolean') { + const existing = body.querySelector('.spinner'); + if (nextOpts.loading && !existing) { + const spin = document.createElement('span'); + spin.className = 'spinner'; + spin.setAttribute('aria-hidden', 'true'); + body.insertBefore(spin, body.firstChild); + } else if (!nextOpts.loading && existing) { + existing.remove(); + } + } + if (nextOpts && Object.prototype.hasOwnProperty.call(nextOpts, 'duration')) { + currentDuration = Number.isFinite(nextOpts.duration) ? Number(nextOpts.duration) : currentDuration; + scheduleDismiss(currentDuration); + } } close.addEventListener('click', dismiss); @@ -88,9 +113,7 @@ export const toastScripts = ` requestAnimationFrame(() => toast.classList.add('visible')); // duration: 0 means "persistent" - if (duration !== 0) { - timeoutId = window.setTimeout(dismiss, duration); - } + scheduleDismiss(currentDuration); return { dismiss, update }; } diff --git a/src/styles/components.ts b/src/styles/components.ts index 6ea4320..d9d8b67 100644 --- a/src/styles/components.ts +++ b/src/styles/components.ts @@ -834,6 +834,49 @@ export const componentStyles = ` flex-wrap: wrap; } + .button-delete { + transition: + background-color 180ms ease, + border-color 180ms ease, + color 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; + } + + .button-delete.is-confirming { + background-color: rgba(255, 69, 58, 0.22); + border-color: rgba(255, 69, 58, 0.65); + color: #fff; + box-shadow: 0 12px 28px rgba(255, 69, 58, 0.18); + transform: translateY(-1px); + } + + @media (prefers-color-scheme: light) { + .button-delete.is-confirming { + background-color: rgba(255, 69, 58, 0.12); + color: var(--color-text-primary); + } + } + + .feed-row.is-removing, + .email-row.is-removing { + opacity: 0; + transform: translateY(-6px); + transition: opacity 200ms ease, transform 200ms ease; + } + + .feed-item.is-removing { + opacity: 0; + transform: translateY(-6px); + overflow: hidden; + transition: + opacity 220ms ease, + transform 220ms ease, + max-height 220ms ease, + margin 220ms ease, + padding 220ms ease; + } + /* Spinner (buttons + toasts) */ @keyframes spin { to {