Improve admin delete confirmations

This commit is contained in:
Young Lee
2026-02-06 13:36:17 -08:00
parent 2accee54ce
commit bf3a4d9672
5 changed files with 521 additions and 38 deletions
+1
View File
@@ -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
+111
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
} }
+43
View File
@@ -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 {