mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
Improve admin delete confirmations
This commit is contained in:
@@ -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
|
- One-click feed creation from an admin dashboard
|
||||||
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
|
- 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)
|
- Resizable + sortable table columns in the admin dashboard (Table view)
|
||||||
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
||||||
- ForwardEmail webhook ingestion with source-IP verification
|
- ForwardEmail webhook ingestion with source-IP verification
|
||||||
|
|||||||
@@ -263,6 +263,42 @@ describe("Admin Routes", () => {
|
|||||||
expect(feedConfig).toBeNull();
|
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 () => {
|
it("should allow bulk feed deletion with valid authentication", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
|
|
||||||
@@ -310,5 +346,80 @@ describe("Admin Routes", () => {
|
|||||||
expect(feedListAfter?.feeds.length).toBe(0);
|
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: "<p>Hi</p>",
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+334
-29
@@ -476,14 +476,15 @@ app.get("/", async (c) => {
|
|||||||
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<tr
|
<tr
|
||||||
class="feed-row"
|
class="feed-row"
|
||||||
data-search="${searchHaystack}"
|
data-feed-id="${feed.id}"
|
||||||
data-sort-title="${sortTitle}"
|
data-search="${searchHaystack}"
|
||||||
data-sort-feed-id="${sortFeedId}"
|
data-sort-title="${sortTitle}"
|
||||||
data-sort-email="${sortEmail}"
|
data-sort-feed-id="${sortFeedId}"
|
||||||
data-sort-rss="${sortRss}"
|
data-sort-email="${sortEmail}"
|
||||||
>
|
data-sort-rss="${sortRss}"
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -626,8 +627,10 @@ app.get("/", async (c) => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button button-small button-danger"
|
class="button button-small button-danger button-delete"
|
||||||
onclick="confirmDelete('${feed.id}')"
|
data-delete-kind="feed"
|
||||||
|
data-feed-id="${feed.id}"
|
||||||
|
data-view="table"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -672,6 +675,7 @@ app.get("/", async (c) => {
|
|||||||
return html`
|
return html`
|
||||||
<li
|
<li
|
||||||
class="feed-item card feed-row"
|
class="feed-item card feed-row"
|
||||||
|
data-feed-id="${feed.id}"
|
||||||
data-search="${searchHaystack}"
|
data-search="${searchHaystack}"
|
||||||
>
|
>
|
||||||
<div class="feed-header">
|
<div class="feed-header">
|
||||||
@@ -802,8 +806,10 @@ app.get("/", async (c) => {
|
|||||||
<div class="feed-buttons-right">
|
<div class="feed-buttons-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="confirmDelete('${feed.id}')"
|
class="button button-small button-danger button-delete"
|
||||||
class="button button-small button-danger"
|
data-delete-kind="feed"
|
||||||
|
data-feed-id="${feed.id}"
|
||||||
|
data-view="list"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -841,6 +847,7 @@ app.get("/", async (c) => {
|
|||||||
FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
|
FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
|
||||||
setupFeedTableResizing();
|
setupFeedTableResizing();
|
||||||
setupFeedTableSorting();
|
setupFeedTableSorting();
|
||||||
|
setupFeedDeleteButtons();
|
||||||
updateFeedMatchCount();
|
updateFeedMatchCount();
|
||||||
updateFeedSelectionState();
|
updateFeedSelectionState();
|
||||||
}
|
}
|
||||||
@@ -1029,15 +1036,163 @@ app.get("/", async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(feedId) {
|
const DELETE_CONFIRM_LABEL = 'Confirm delete';
|
||||||
if (confirm('Are you sure you want to delete this feed? This action cannot be undone.')) {
|
const DELETE_LOADING_LABEL = 'Deleting...';
|
||||||
const currentView = new URL(window.location.href).searchParams.get('view') || 'list';
|
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
function getDeleteView() {
|
||||||
form.action = '/admin/feeds/' + feedId + '/delete?view=' + encodeURIComponent(currentView);
|
return new URL(window.location.href).searchParams.get('view') || 'list';
|
||||||
document.body.appendChild(form);
|
}
|
||||||
form.submit();
|
|
||||||
|
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() {
|
function updateFeedSelectionState() {
|
||||||
@@ -1541,6 +1696,7 @@ app.post("/feeds/:feedId/delete", async (c) => {
|
|||||||
const emailStorage = env.EMAIL_STORAGE;
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const view = c.req.query("view") === "table" ? "table" : "list";
|
const view = c.req.query("view") === "table" ? "table" : "list";
|
||||||
|
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFeedFast(emailStorage, feedId);
|
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.
|
// Best-effort cleanup in the background so the request stays fast.
|
||||||
// Use the UI purge endpoint for full, user-visible progress.
|
// Use the UI purge endpoint for full, user-visible progress.
|
||||||
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
|
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json({ ok: true, feedId });
|
||||||
|
}
|
||||||
return c.redirect(`/admin?view=${view}`);
|
return c.redirect(`/admin?view=${view}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting feed:", 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);
|
return c.text("Error deleting feed. Please try again.", 400);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1957,6 +2119,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
return html`
|
return html`
|
||||||
<tr
|
<tr
|
||||||
class="email-row"
|
class="email-row"
|
||||||
|
data-email-key="${email.key}"
|
||||||
data-search="${searchHaystack}"
|
data-search="${searchHaystack}"
|
||||||
data-sort-subject="${sortSubject}"
|
data-sort-subject="${sortSubject}"
|
||||||
data-sort-received-at="${sortReceivedAt}"
|
data-sort-received-at="${sortReceivedAt}"
|
||||||
@@ -1987,8 +2150,10 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="confirmDeleteEmail('${email.key}', '${feedId}')"
|
class="button button-small button-danger button-delete"
|
||||||
class="button button-small button-danger"
|
data-delete-kind="email"
|
||||||
|
data-email-key="${email.key}"
|
||||||
|
data-feed-id="${feedId}"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -2036,6 +2201,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
|
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
|
||||||
setupEmailTableResizing();
|
setupEmailTableResizing();
|
||||||
setupEmailTableSorting();
|
setupEmailTableSorting();
|
||||||
|
setupEmailDeleteButtons();
|
||||||
updateEmailMatchCount();
|
updateEmailMatchCount();
|
||||||
updateEmailSelectionState();
|
updateEmailSelectionState();
|
||||||
}
|
}
|
||||||
@@ -2220,14 +2386,143 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDeleteEmail(emailKey, feedId) {
|
const EMAIL_DELETE_CONFIRM_LABEL = 'Confirm delete';
|
||||||
if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) {
|
const EMAIL_DELETE_LOADING_LABEL = 'Deleting...';
|
||||||
const form = document.createElement('form');
|
const EMAIL_DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||||
form.method = 'POST';
|
|
||||||
form.action = '/admin/emails/' + emailKey + '/delete?feedId=' + feedId;
|
function resetEmailDeleteButton(buttonEl) {
|
||||||
document.body.appendChild(form);
|
if (!buttonEl) return;
|
||||||
form.submit();
|
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() {
|
function updateEmailSelectionState() {
|
||||||
@@ -2800,12 +3095,16 @@ app.post("/emails/:emailKey/delete", async (c) => {
|
|||||||
const env = c.env as unknown as Env;
|
const env = c.env as unknown as Env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
const emailKey = c.req.param("emailKey");
|
const emailKey = c.req.param("emailKey");
|
||||||
|
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get feedId from query parameters instead of form data
|
// Get feedId from query parameters instead of form data
|
||||||
const feedId = c.req.query("feedId");
|
const feedId = c.req.query("feedId");
|
||||||
|
|
||||||
if (!feedId) {
|
if (!feedId) {
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json({ ok: false, error: "Feed ID is required" }, 400);
|
||||||
|
}
|
||||||
return c.text("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));
|
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wantsJson) {
|
||||||
|
return c.json({ ok: true, emailKey, feedId });
|
||||||
|
}
|
||||||
// Redirect back to the feed emails page
|
// Redirect back to the feed emails page
|
||||||
return c.redirect(`/admin/feeds/${feedId}/emails`);
|
return c.redirect(`/admin/feeds/${feedId}/emails`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting email:", 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);
|
return c.text("Error deleting email. Please try again.", 400);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-9
@@ -25,11 +25,12 @@ export const toastScripts = `
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'toast-body';
|
body.className = 'toast-body';
|
||||||
|
|
||||||
|
let spinner = null;
|
||||||
if (loading) {
|
if (loading) {
|
||||||
const spin = document.createElement('span');
|
spinner = document.createElement('span');
|
||||||
spin.className = 'spinner';
|
spinner.className = 'spinner';
|
||||||
spin.setAttribute('aria-hidden', 'true');
|
spinner.setAttribute('aria-hidden', 'true');
|
||||||
body.appendChild(spin);
|
body.appendChild(spinner);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = document.createElement('div');
|
const text = document.createElement('div');
|
||||||
@@ -46,7 +47,7 @@ export const toastScripts = `
|
|||||||
toast.appendChild(body);
|
toast.appendChild(body);
|
||||||
toast.appendChild(close);
|
toast.appendChild(close);
|
||||||
|
|
||||||
return { toast, text, close };
|
return { toast, text, close, spinner, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message, opts) {
|
function showToast(message, opts) {
|
||||||
@@ -54,14 +55,16 @@ export const toastScripts = `
|
|||||||
const duration = Number.isFinite(options.duration) ? Number(options.duration) : 4500;
|
const duration = Number.isFinite(options.duration) ? Number(options.duration) : 4500;
|
||||||
|
|
||||||
const stack = ensureToastStack();
|
const stack = ensureToastStack();
|
||||||
const { toast, text, close } = createToastEl(message, options);
|
const { toast, text, close, body } = createToastEl(message, options);
|
||||||
|
|
||||||
let dismissed = false;
|
let dismissed = false;
|
||||||
let timeoutId = 0;
|
let timeoutId = 0;
|
||||||
|
let currentDuration = duration;
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
if (dismissed) return;
|
if (dismissed) return;
|
||||||
dismissed = true;
|
dismissed = true;
|
||||||
|
if (timeoutId) window.clearTimeout(timeoutId);
|
||||||
toast.classList.remove('visible');
|
toast.classList.remove('visible');
|
||||||
// Match CSS transition duration to avoid abrupt removal
|
// Match CSS transition duration to avoid abrupt removal
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -69,12 +72,34 @@ export const toastScripts = `
|
|||||||
}, 220);
|
}, 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleDismiss(nextDuration) {
|
||||||
|
if (timeoutId) window.clearTimeout(timeoutId);
|
||||||
|
if (nextDuration !== 0) {
|
||||||
|
timeoutId = window.setTimeout(dismiss, nextDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function update(nextMessage, nextOpts) {
|
function update(nextMessage, nextOpts) {
|
||||||
if (dismissed) return;
|
if (dismissed) return;
|
||||||
text.textContent = String(nextMessage || '');
|
text.textContent = String(nextMessage || '');
|
||||||
if (nextOpts && typeof nextOpts.type === 'string') {
|
if (nextOpts && typeof nextOpts.type === 'string') {
|
||||||
toast.className = 'toast toast-' + nextOpts.type;
|
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);
|
close.addEventListener('click', dismiss);
|
||||||
@@ -88,9 +113,7 @@ export const toastScripts = `
|
|||||||
requestAnimationFrame(() => toast.classList.add('visible'));
|
requestAnimationFrame(() => toast.classList.add('visible'));
|
||||||
|
|
||||||
// duration: 0 means "persistent"
|
// duration: 0 means "persistent"
|
||||||
if (duration !== 0) {
|
scheduleDismiss(currentDuration);
|
||||||
timeoutId = window.setTimeout(dismiss, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { dismiss, update };
|
return { dismiss, update };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -834,6 +834,49 @@ export const componentStyles = `
|
|||||||
flex-wrap: wrap;
|
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) */
|
/* Spinner (buttons + toasts) */
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
|
|||||||
Reference in New Issue
Block a user