mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(admin): async bulk delete with toasts
This commit is contained in:
@@ -81,6 +81,7 @@ Notes:
|
|||||||
- Use **Table** view for bulk delete.
|
- Use **Table** view for bulk delete.
|
||||||
- Table columns are resizable and sortable; widths persist per-browser via localStorage.
|
- 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.
|
- **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.
|
- Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds.
|
||||||
|
|
||||||
## Cloudflare/Wrangler conventions
|
## Cloudflare/Wrangler conventions
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ npm run build
|
|||||||
- Long titles/URLs are truncated; hover to see the full value. Click to copy.
|
- 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).
|
- 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**.
|
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**.
|
5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Select Results** and **Delete Selected**.
|
||||||
|
|
||||||
## Upgrading dependencies
|
## Upgrading dependencies
|
||||||
|
|||||||
+574
-109
@@ -337,13 +337,15 @@ app.get("/", async (c) => {
|
|||||||
? html`<div class="card"><p>No feeds were selected.</p></div>`
|
? html`<div class="card"><p>No feeds were selected.</p></div>`
|
||||||
: ""}
|
: ""}
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<h2 style="margin: 0;">Your Feeds</h2>
|
<h2 style="margin: 0;">Your Feeds</h2>
|
||||||
<span class="pill">${feedsWithConfig.length}</span>
|
<span class="pill" id="feed-total-count"
|
||||||
</div>
|
>${feedsWithConfig.length}</span
|
||||||
<div class="toolbar-group">${viewToggle}</div>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar-group">${viewToggle}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${feedsWithConfig.length === 0
|
${feedsWithConfig.length === 0
|
||||||
? html`<div class="card">
|
? html`<div class="card">
|
||||||
@@ -351,14 +353,14 @@ app.get("/", async (c) => {
|
|||||||
</div>`
|
</div>`
|
||||||
: view === "table"
|
: view === "table"
|
||||||
? html`
|
? html`
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form
|
<form
|
||||||
id="bulk-feed-delete-form"
|
id="bulk-feed-delete-form"
|
||||||
action="/admin/feeds/bulk-delete"
|
action="/admin/feeds/bulk-delete"
|
||||||
method="post"
|
method="post"
|
||||||
onsubmit="return confirmBulkFeedDelete()"
|
onsubmit="return onBulkFeedDeleteSubmit(event)"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="view" value="table" />
|
<input type="hidden" name="view" value="table" />
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group toolbar-group-fill">
|
<div class="toolbar-group toolbar-group-fill">
|
||||||
@@ -807,26 +809,29 @@ app.get("/", async (c) => {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
${raw(`
|
${raw(`
|
||||||
let FEED_ROWS = [];
|
let FEED_ROWS = [];
|
||||||
let FEED_CHECKBOXES = [];
|
let FEED_CHECKBOXES = [];
|
||||||
let FEED_SELECTED_COUNT_EL = null;
|
let FEED_SELECTED_COUNT_EL = null;
|
||||||
let FEED_MATCH_COUNT_EL = null;
|
let FEED_MATCH_COUNT_EL = null;
|
||||||
let FEED_BULK_DELETE_BUTTON_EL = null;
|
let FEED_TOTAL_COUNT_EL = null;
|
||||||
let FEED_SELECT_ALL_EL = null;
|
let FEED_BULK_DELETE_BUTTON_EL = null;
|
||||||
let FEED_FILTER_TIMER = null;
|
let FEED_SELECT_ALL_EL = null;
|
||||||
let FEED_SORT_KEY = 'title';
|
let FEED_FILTER_TIMER = null;
|
||||||
let FEED_SORT_DIR = 'asc';
|
let FEED_BULK_DELETE_IN_PROGRESS = false;
|
||||||
const FEED_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
let FEED_SORT_KEY = 'title';
|
||||||
|
let FEED_SORT_DIR = 'asc';
|
||||||
|
const FEED_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
|
||||||
function initFeedUI() {
|
function initFeedUI() {
|
||||||
FEED_ROWS = Array.from(document.querySelectorAll('.feed-row'));
|
FEED_ROWS = Array.from(document.querySelectorAll('.feed-row'));
|
||||||
FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select'));
|
FEED_CHECKBOXES = Array.from(document.querySelectorAll('.feed-select'));
|
||||||
FEED_SELECTED_COUNT_EL = document.getElementById('selected-feed-count');
|
FEED_SELECTED_COUNT_EL = document.getElementById('selected-feed-count');
|
||||||
FEED_MATCH_COUNT_EL = document.getElementById('feed-match-count');
|
FEED_MATCH_COUNT_EL = document.getElementById('feed-match-count');
|
||||||
FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button');
|
FEED_TOTAL_COUNT_EL = document.getElementById('feed-total-count');
|
||||||
FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
|
FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button');
|
||||||
setupFeedTableResizing();
|
FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
|
||||||
setupFeedTableSorting();
|
setupFeedTableResizing();
|
||||||
|
setupFeedTableSorting();
|
||||||
updateFeedMatchCount();
|
updateFeedMatchCount();
|
||||||
updateFeedSelectionState();
|
updateFeedSelectionState();
|
||||||
}
|
}
|
||||||
@@ -1084,17 +1089,151 @@ app.get("/", async (c) => {
|
|||||||
updateFeedSelectionState();
|
updateFeedSelectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmBulkFeedDelete() {
|
function confirmBulkFeedDelete() {
|
||||||
const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
|
const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
|
||||||
if (selected === 0) {
|
if (selected === 0) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return confirm('Delete ' + selected + ' selected feed(s)? This will also delete all emails inside those feeds.');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
const query = (document.getElementById('feed-search')?.value || '').trim();
|
||||||
initFeedUI();
|
const extra =
|
||||||
});
|
selected >= 50 && !query
|
||||||
|
? '\\n\\nThis is a large delete. Tip: use Search to narrow down spam first.'
|
||||||
|
: '';
|
||||||
|
return confirm(
|
||||||
|
'Delete ' +
|
||||||
|
selected +
|
||||||
|
' selected feed(s)? This will also delete all emails inside those feeds.' +
|
||||||
|
extra,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonLoading(buttonEl, loading, label) {
|
||||||
|
if (!buttonEl) return;
|
||||||
|
if (loading) {
|
||||||
|
if (!buttonEl.dataset.originalLabel) {
|
||||||
|
buttonEl.dataset.originalLabel = (buttonEl.textContent || '').trim();
|
||||||
|
}
|
||||||
|
const text = label || 'Working...';
|
||||||
|
buttonEl.classList.add('is-loading');
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class=\"spinner\" aria-hidden=\"true\"></span>' + text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim();
|
||||||
|
buttonEl.classList.remove('is-loading');
|
||||||
|
buttonEl.innerHTML = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeedRowsById(feedIds) {
|
||||||
|
const toRemove = new Set((feedIds || []).map((v) => String(v)));
|
||||||
|
if (toRemove.size === 0) return;
|
||||||
|
|
||||||
|
FEED_ROWS.forEach((row) => {
|
||||||
|
const checkbox = row.querySelector('input.feed-select');
|
||||||
|
const id = checkbox ? checkbox.value : '';
|
||||||
|
if (toRemove.has(id)) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBulkFeedDeleteSubmit(event) {
|
||||||
|
if (event && event.preventDefault) event.preventDefault();
|
||||||
|
void bulkDeleteSelectedFeeds();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDeleteSelectedFeeds() {
|
||||||
|
if (FEED_BULK_DELETE_IN_PROGRESS) return;
|
||||||
|
const selectedIds = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
if (window.showToast) window.showToast('No feeds selected.', { type: 'info' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirmBulkFeedDelete()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FEED_BULK_DELETE_IN_PROGRESS = true;
|
||||||
|
setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, true, 'Deleting...');
|
||||||
|
|
||||||
|
const toast = window.showToast
|
||||||
|
? window.showToast('Deleting ' + selectedIds.length + ' feed(s)...', { type: 'info', loading: true, duration: 0 })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const batchSize = 10;
|
||||||
|
let deletedTotal = 0;
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < selectedIds.length; i += batchSize) {
|
||||||
|
const batch = selectedIds.slice(i, i + batchSize);
|
||||||
|
const res = await fetch('/admin/feeds/bulk-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ feedIds: batch }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch;
|
||||||
|
const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : [];
|
||||||
|
|
||||||
|
removeFeedRowsById(deletedIds);
|
||||||
|
deletedTotal += deletedIds.length;
|
||||||
|
failed.push(...failedIds);
|
||||||
|
|
||||||
|
if (toast && toast.update) {
|
||||||
|
const done = Math.min(i + batch.length, selectedIds.length);
|
||||||
|
toast.update('Deleting... (' + done + ' of ' + selectedIds.length + ')', { type: 'info' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep selection state consistent as rows disappear.
|
||||||
|
updateFeedMatchCount();
|
||||||
|
updateFeedSelectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
|
if (failed.length > 0) {
|
||||||
|
if (window.showToast) {
|
||||||
|
window.showToast(
|
||||||
|
'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible).',
|
||||||
|
{ type: 'error', duration: 6500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' feed(s).', { type: 'success' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
|
if (window.showToast) {
|
||||||
|
window.showToast('Bulk delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 7000 });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
FEED_BULK_DELETE_IN_PROGRESS = false;
|
||||||
|
setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, false);
|
||||||
|
updateFeedSelectionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initFeedUI();
|
||||||
|
});
|
||||||
`)};
|
`)};
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
@@ -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(
|
async function deleteFeedAndEmails(
|
||||||
emailStorage: KVNamespace,
|
emailStorage: KVNamespace,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
|
options: { skipListUpdate?: boolean } = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
const feedMetadata = (await emailStorage.get(feedMetadataKey, {
|
|
||||||
type: "json",
|
|
||||||
})) as FeedMetadata | null;
|
|
||||||
|
|
||||||
if (!feedMetadata) {
|
const [feedConfig, feedMetadata] = (await Promise.all([
|
||||||
return false;
|
emailStorage.get(feedConfigKey, { type: "json" }),
|
||||||
}
|
emailStorage.get(feedMetadataKey, { type: "json" }),
|
||||||
|
])) as [FeedConfig | null, FeedMetadata | null];
|
||||||
|
|
||||||
for (const email of feedMetadata.emails) {
|
const emailKeys = (feedMetadata?.emails || []).map((email) => email.key);
|
||||||
await emailStorage.delete(email.key);
|
await deleteKeysWithConcurrency(emailStorage, emailKeys, 25);
|
||||||
}
|
|
||||||
|
|
||||||
await emailStorage.delete(`feed:${feedId}:config`);
|
await Promise.all([
|
||||||
await emailStorage.delete(feedMetadataKey);
|
emailStorage.delete(feedConfigKey),
|
||||||
await removeFeedFromList(emailStorage, feedId);
|
emailStorage.delete(feedMetadataKey),
|
||||||
|
]);
|
||||||
|
|
||||||
return true;
|
const removedFromList = options.skipListUpdate
|
||||||
|
? false
|
||||||
|
: await removeFeedFromList(emailStorage, feedId);
|
||||||
|
|
||||||
|
return !!feedConfig || !!feedMetadata || removedFromList;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete feed
|
// Delete feed
|
||||||
@@ -1363,26 +1534,108 @@ app.post("/feeds/bulk-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;
|
||||||
|
|
||||||
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 formData = await c.req.formData();
|
||||||
const view = formData.get("view")?.toString() === "table" ? "table" : "list";
|
const view = formData.get("view")?.toString() === "table" ? "table" : "list";
|
||||||
const redirectBase = `/admin?view=${view}`;
|
const redirectBase = `/admin?view=${view}`;
|
||||||
const rawIds = formData.getAll("feedIds").map((value) => value.toString());
|
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`);
|
return c.redirect(`${redirectBase}&message=bulkDeleteNoop`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let deletedCount = 0;
|
const results: Array<{ feedId: string; ok: boolean }> = [];
|
||||||
for (const feedId of feedIds) {
|
const concurrency = 3;
|
||||||
const deleted = await deleteFeedAndEmails(emailStorage, feedId);
|
|
||||||
if (deleted) {
|
for (let i = 0; i < parsedFeedIds.length; i += concurrency) {
|
||||||
deletedCount += 1;
|
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) {
|
} catch (error) {
|
||||||
console.error("Error bulk deleting feeds:", error);
|
console.error("Error bulk deleting feeds:", error);
|
||||||
return c.text("Error bulk deleting feeds. Please try again.", 400);
|
return c.text("Error bulk deleting feeds. Please try again.", 400);
|
||||||
@@ -1531,7 +1784,9 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Emails (${feedMetadata.emails.length})</h2>
|
<h2>
|
||||||
|
Emails (<span id="email-total-count">${feedMetadata.emails.length}</span>)
|
||||||
|
</h2>
|
||||||
|
|
||||||
${message === "bulkDeleted"
|
${message === "bulkDeleted"
|
||||||
? html`<div class="card">
|
? html`<div class="card">
|
||||||
@@ -1543,11 +1798,11 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
: ""}
|
: ""}
|
||||||
${feedMetadata.emails.length > 0
|
${feedMetadata.emails.length > 0
|
||||||
? html`
|
? html`
|
||||||
<form
|
<form
|
||||||
action="/admin/feeds/${feedId}/emails/bulk-delete"
|
action="/admin/feeds/${feedId}/emails/bulk-delete"
|
||||||
method="post"
|
method="post"
|
||||||
onsubmit="return confirmBulkEmailDelete()"
|
onsubmit="return onBulkEmailDeleteSubmit(event)"
|
||||||
>
|
>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group toolbar-group-fill">
|
<div class="toolbar-group toolbar-group-fill">
|
||||||
<input
|
<input
|
||||||
@@ -1689,13 +1944,16 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
${raw(`
|
${raw(`
|
||||||
|
const EMAIL_FEED_ID = ${JSON.stringify(feedId)};
|
||||||
let EMAIL_ROWS = [];
|
let EMAIL_ROWS = [];
|
||||||
let EMAIL_CHECKBOXES = [];
|
let EMAIL_CHECKBOXES = [];
|
||||||
let EMAIL_SELECTED_COUNT_EL = null;
|
let EMAIL_SELECTED_COUNT_EL = null;
|
||||||
let EMAIL_MATCH_COUNT_EL = null;
|
let EMAIL_MATCH_COUNT_EL = null;
|
||||||
|
let EMAIL_TOTAL_COUNT_EL = null;
|
||||||
let EMAIL_BULK_DELETE_BUTTON_EL = null;
|
let EMAIL_BULK_DELETE_BUTTON_EL = null;
|
||||||
let EMAIL_SELECT_ALL_EL = null;
|
let EMAIL_SELECT_ALL_EL = null;
|
||||||
let EMAIL_FILTER_TIMER = null;
|
let EMAIL_FILTER_TIMER = null;
|
||||||
|
let EMAIL_BULK_DELETE_IN_PROGRESS = false;
|
||||||
let EMAIL_SORT_KEY = 'receivedAt';
|
let EMAIL_SORT_KEY = 'receivedAt';
|
||||||
let EMAIL_SORT_DIR = 'desc';
|
let EMAIL_SORT_DIR = 'desc';
|
||||||
const EMAIL_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
const EMAIL_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||||
@@ -1705,6 +1963,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
EMAIL_CHECKBOXES = Array.from(document.querySelectorAll('.email-select'));
|
EMAIL_CHECKBOXES = Array.from(document.querySelectorAll('.email-select'));
|
||||||
EMAIL_SELECTED_COUNT_EL = document.getElementById('selected-email-count');
|
EMAIL_SELECTED_COUNT_EL = document.getElementById('selected-email-count');
|
||||||
EMAIL_MATCH_COUNT_EL = document.getElementById('email-match-count');
|
EMAIL_MATCH_COUNT_EL = document.getElementById('email-match-count');
|
||||||
|
EMAIL_TOTAL_COUNT_EL = document.getElementById('email-total-count');
|
||||||
EMAIL_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button');
|
EMAIL_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-emails-button');
|
||||||
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
|
EMAIL_SELECT_ALL_EL = document.getElementById('select-all-emails');
|
||||||
setupEmailTableResizing();
|
setupEmailTableResizing();
|
||||||
@@ -1961,17 +2220,145 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
updateEmailSelectionState();
|
updateEmailSelectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmBulkEmailDelete() {
|
function confirmBulkEmailDelete() {
|
||||||
const selected = EMAIL_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
|
const selected = EMAIL_CHECKBOXES.filter((checkbox) => checkbox.checked).length;
|
||||||
if (selected === 0) {
|
if (selected === 0) return false;
|
||||||
return false;
|
const query = (document.getElementById('email-search')?.value || '').trim();
|
||||||
}
|
const extra =
|
||||||
return confirm('Delete ' + selected + ' selected email(s)?');
|
selected >= 200 && !query
|
||||||
}
|
? '\\n\\nThis is a large delete. Tip: use Search to narrow down spam first.'
|
||||||
|
: '';
|
||||||
|
return confirm('Delete ' + selected + ' selected email(s)?' + extra);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
function setButtonLoading(buttonEl, loading, label) {
|
||||||
initEmailUI();
|
if (!buttonEl) return;
|
||||||
});
|
if (loading) {
|
||||||
|
if (!buttonEl.dataset.originalLabel) {
|
||||||
|
buttonEl.dataset.originalLabel = (buttonEl.textContent || '').trim();
|
||||||
|
}
|
||||||
|
const text = label || 'Working...';
|
||||||
|
buttonEl.classList.add('is-loading');
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class=\"spinner\" aria-hidden=\"true\"></span>' + text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = buttonEl.dataset.originalLabel || (buttonEl.textContent || '').trim();
|
||||||
|
buttonEl.classList.remove('is-loading');
|
||||||
|
buttonEl.innerHTML = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmailRowsByKey(emailKeys) {
|
||||||
|
const toRemove = new Set((emailKeys || []).map((v) => String(v)));
|
||||||
|
if (toRemove.size === 0) return;
|
||||||
|
|
||||||
|
EMAIL_ROWS.forEach((row) => {
|
||||||
|
const checkbox = row.querySelector('input.email-select');
|
||||||
|
const key = checkbox ? checkbox.value : '';
|
||||||
|
if (toRemove.has(key)) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBulkEmailDeleteSubmit(event) {
|
||||||
|
if (event && event.preventDefault) event.preventDefault();
|
||||||
|
void bulkDeleteSelectedEmails();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDeleteSelectedEmails() {
|
||||||
|
if (EMAIL_BULK_DELETE_IN_PROGRESS) return;
|
||||||
|
const selectedKeys = EMAIL_CHECKBOXES.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
if (window.showToast) window.showToast('No emails selected.', { type: 'info' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirmBulkEmailDelete()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EMAIL_BULK_DELETE_IN_PROGRESS = true;
|
||||||
|
setButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, true, 'Deleting...');
|
||||||
|
|
||||||
|
const toast = window.showToast
|
||||||
|
? window.showToast('Deleting ' + selectedKeys.length + ' email(s)...', { type: 'info', loading: true, duration: 0 })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const batchSize = 50;
|
||||||
|
let deletedTotal = 0;
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = '/admin/feeds/' + encodeURIComponent(EMAIL_FEED_ID) + '/emails/bulk-delete';
|
||||||
|
for (let i = 0; i < selectedKeys.length; i += batchSize) {
|
||||||
|
const batch = selectedKeys.slice(i, i + batchSize);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ emailKeys: batch }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedKeys = Array.isArray(data.deletedEmailKeys) ? data.deletedEmailKeys : batch;
|
||||||
|
const failedKeys = Array.isArray(data.failedEmailKeys) ? data.failedEmailKeys : [];
|
||||||
|
|
||||||
|
removeEmailRowsByKey(deletedKeys);
|
||||||
|
deletedTotal += deletedKeys.length;
|
||||||
|
failed.push(...failedKeys);
|
||||||
|
|
||||||
|
if (toast && toast.update) {
|
||||||
|
const done = Math.min(i + batch.length, selectedKeys.length);
|
||||||
|
toast.update('Deleting... (' + done + ' of ' + selectedKeys.length + ')', { type: 'info' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEmailMatchCount();
|
||||||
|
updateEmailSelectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
|
if (failed.length > 0) {
|
||||||
|
if (window.showToast) {
|
||||||
|
window.showToast(
|
||||||
|
'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible).',
|
||||||
|
{ type: 'error', duration: 6500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.showToast) window.showToast('Deleted ' + deletedTotal + ' email(s).', { type: 'success' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (toast && toast.dismiss) toast.dismiss();
|
||||||
|
if (window.showToast) {
|
||||||
|
window.showToast('Bulk delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 7000 });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
EMAIL_BULK_DELETE_IN_PROGRESS = false;
|
||||||
|
setButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, false);
|
||||||
|
updateEmailSelectionState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initEmailUI();
|
||||||
|
});
|
||||||
`)};
|
`)};
|
||||||
</script>
|
</script>
|
||||||
`,
|
`,
|
||||||
@@ -2388,6 +2775,66 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
|||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
try {
|
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 formData = await c.req.formData();
|
||||||
const rawEmailKeys = formData
|
const rawEmailKeys = formData
|
||||||
.getAll("emailKeys")
|
.getAll("emailKeys")
|
||||||
@@ -2398,32 +2845,21 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
|||||||
return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`);
|
return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
|
||||||
const feedMetadata = (await emailStorage.get(feedMetadataKey, {
|
const { ok: deletedOk } = await deleteKeysWithConcurrency(
|
||||||
type: "json",
|
emailStorage,
|
||||||
})) as FeedMetadata | null;
|
candidates,
|
||||||
if (!feedMetadata) {
|
35,
|
||||||
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 deletedSet = new Set(deletedOk);
|
||||||
feedMetadata.emails = feedMetadata.emails.filter(
|
feedMetadata.emails = feedMetadata.emails.filter(
|
||||||
(email) => !emailKeys.includes(email.key),
|
(email) => !deletedSet.has(email.key),
|
||||||
);
|
);
|
||||||
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||||
|
|
||||||
return c.redirect(
|
return c.redirect(
|
||||||
`/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedCount}`,
|
`/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error bulk deleting emails:", 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 removeFeedsFromListBulk(
|
||||||
async function removeFeedFromList(
|
|
||||||
emailStorage: KVNamespace,
|
emailStorage: KVNamespace,
|
||||||
feedId: string,
|
feedIds: string[],
|
||||||
): Promise<void> {
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const feedListKey = "feeds:list";
|
const feedListKey = "feeds:list";
|
||||||
const feedList = ((await emailStorage.get(feedListKey, {
|
const feedList = ((await emailStorage.get(feedListKey, {
|
||||||
type: "json",
|
type: "json",
|
||||||
})) as FeedList | null) || { feeds: [] };
|
})) as FeedList | null) || { feeds: [] };
|
||||||
|
|
||||||
// Filter out the removed feed
|
const toRemove = new Set(feedIds.filter(Boolean));
|
||||||
feedList.feeds = feedList.feeds.filter((feed) => feed.id !== feedId);
|
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
|
// Store updated list
|
||||||
await emailStorage.put(feedListKey, JSON.stringify(feedList));
|
await emailStorage.put(feedListKey, JSON.stringify(feedList));
|
||||||
|
return removed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error removing feed from list:", 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<boolean> {
|
||||||
|
const removed = await removeFeedsFromListBulk(emailStorage, [feedId]);
|
||||||
|
return removed.includes(feedId);
|
||||||
|
}
|
||||||
|
|
||||||
// Update feed via API (for in-place editing)
|
// Update feed via API (for in-place editing)
|
||||||
app.post("/api/feeds/:feedId/update", async (c) => {
|
app.post("/api/feeds/:feedId/update", async (c) => {
|
||||||
// Type assertion for environment variables
|
// Type assertion for environment variables
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
import { modalScripts, emailViewScripts, initScripts } from "./interactions";
|
import { modalScripts, emailViewScripts, initScripts } from "./interactions";
|
||||||
import { clipboardScripts } from "./clipboard";
|
import { clipboardScripts } from "./clipboard";
|
||||||
import { authHelpers } from "./auth";
|
import { authHelpers } from "./auth";
|
||||||
|
import { toastScripts } from "./toast";
|
||||||
|
|
||||||
// Combine all scripts into a single JavaScript string
|
// Combine all scripts into a single JavaScript string
|
||||||
export const interactiveScripts = `
|
export const interactiveScripts = `
|
||||||
|
${toastScripts}
|
||||||
${modalScripts}
|
${modalScripts}
|
||||||
${emailViewScripts}
|
${emailViewScripts}
|
||||||
${clipboardScripts}
|
${clipboardScripts}
|
||||||
@@ -20,4 +22,5 @@ export {
|
|||||||
initScripts,
|
initScripts,
|
||||||
clipboardScripts,
|
clipboardScripts,
|
||||||
authHelpers,
|
authHelpers,
|
||||||
|
toastScripts,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
|
`;
|
||||||
@@ -780,6 +780,135 @@ export const componentStyles = `
|
|||||||
flex-wrap: wrap;
|
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 and Email Lists */
|
||||||
.feed-list,
|
.feed-list,
|
||||||
.email-list {
|
.email-list {
|
||||||
|
|||||||
Reference in New Issue
Block a user