feat(admin): style search + clarify bulk actions

This commit is contained in:
Young Lee
2026-02-06 00:49:36 -08:00
parent 65cf54a764
commit 2d350a7601
5 changed files with 205 additions and 122 deletions
+1
View File
@@ -80,6 +80,7 @@ Notes:
- First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection. - First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection.
- 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.
- 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
+2 -2
View File
@@ -114,8 +114,8 @@ npm run build
3. Use the search box to filter obvious spam feeds. 3. Use the search box to filter obvious spam feeds.
- 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. Select rows and use **Delete Selected Feeds**. 4. Use **Select Results** to select the filtered rows, then click **Delete Selected**.
5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Delete Selected Emails**. 5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Select Results** and **Delete Selected**.
## Upgrading dependencies ## Upgrading dependencies
+104 -55
View File
@@ -352,6 +352,14 @@ app.get("/", async (c) => {
: view === "table" : view === "table"
? html` ? html`
<div class="card"> <div class="card">
<form
id="bulk-feed-delete-form"
action="/admin/feeds/bulk-delete"
method="post"
onsubmit="return confirmBulkFeedDelete()"
>
<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">
<input <input
@@ -361,33 +369,36 @@ app.get("/", async (c) => {
placeholder="Search title, feed id, or description" placeholder="Search title, feed id, or description"
oninput="scheduleFeedFilter()" oninput="scheduleFeedFilter()"
/> />
<button <span class="pill" id="feed-match-count"
type="button" >Showing ${feedsWithConfig.length}</span
class="button button-small"
onclick="setVisibleFeedSelection(true)"
> >
Select Visible
</button>
<button
type="button"
class="button button-small"
onclick="setVisibleFeedSelection(false)"
>
Clear Visible
</button>
<span class="pill" id="selected-feed-count" <span class="pill" id="selected-feed-count"
>0 selected</span >0 selected</span
> >
</div> <button
</div> type="button"
class="button button-small button-secondary"
<form onclick="selectMatchingFeeds()"
id="bulk-feed-delete-form"
action="/admin/feeds/bulk-delete"
method="post"
onsubmit="return confirmBulkFeedDelete()"
> >
<input type="hidden" name="view" value="table" /> Select Results
</button>
<button
type="button"
class="button button-small button-secondary"
onclick="clearFeedSelection()"
>
Clear Selection
</button>
<button
id="bulk-delete-feeds-button"
type="submit"
class="button button-small button-danger"
disabled
>
Delete Selected
</button>
</div>
</div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table table-feeds"> <table class="table table-feeds">
@@ -617,17 +628,6 @@ app.get("/", async (c) => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="actions-row">
<button
id="bulk-delete-feeds-button"
type="submit"
class="button button-danger"
disabled
>
Delete Selected Feeds
</button>
</div>
</form> </form>
</div> </div>
` `
@@ -810,6 +810,7 @@ app.get("/", async (c) => {
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_BULK_DELETE_BUTTON_EL = null; let FEED_BULK_DELETE_BUTTON_EL = null;
let FEED_SELECT_ALL_EL = null; let FEED_SELECT_ALL_EL = null;
let FEED_FILTER_TIMER = null; let FEED_FILTER_TIMER = null;
@@ -821,13 +822,23 @@ app.get("/", async (c) => {
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_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button'); FEED_BULK_DELETE_BUTTON_EL = document.getElementById('bulk-delete-feeds-button');
FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds'); FEED_SELECT_ALL_EL = document.getElementById('select-all-feeds');
setupFeedTableResizing(); setupFeedTableResizing();
setupFeedTableSorting(); setupFeedTableSorting();
updateFeedMatchCount();
updateFeedSelectionState(); updateFeedSelectionState();
} }
function updateFeedMatchCount() {
if (!FEED_MATCH_COUNT_EL) return;
const total = FEED_ROWS.length;
const visible = FEED_ROWS.filter((row) => !row.hidden).length;
const query = (document.getElementById('feed-search')?.value || '').trim();
FEED_MATCH_COUNT_EL.textContent = query ? ('Showing ' + visible + ' of ' + total) : ('Showing ' + total);
}
function scheduleFeedFilter() { function scheduleFeedFilter() {
if (FEED_FILTER_TIMER) { if (FEED_FILTER_TIMER) {
clearTimeout(FEED_FILTER_TIMER); clearTimeout(FEED_FILTER_TIMER);
@@ -1052,12 +1063,24 @@ app.get("/", async (c) => {
updateFeedSelectionState(); updateFeedSelectionState();
} }
function selectMatchingFeeds() {
setVisibleFeedSelection(true);
}
function clearFeedSelection() {
FEED_CHECKBOXES.forEach((checkbox) => {
checkbox.checked = false;
})
updateFeedSelectionState();
}
function filterFeedRows() { function filterFeedRows() {
const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim(); const query = (document.getElementById('feed-search')?.value || '').toLowerCase().trim();
FEED_ROWS.forEach((row) => { FEED_ROWS.forEach((row) => {
const haystack = row.getAttribute('data-search') || ''; const haystack = row.getAttribute('data-search') || '';
row.hidden = !!query && !haystack.includes(query); row.hidden = !!query && !haystack.includes(query);
}); });
updateFeedMatchCount();
updateFeedSelectionState(); updateFeedSelectionState();
} }
@@ -1520,6 +1543,11 @@ app.get("/feeds/:feedId/emails", async (c) => {
: ""} : ""}
${feedMetadata.emails.length > 0 ${feedMetadata.emails.length > 0
? html` ? html`
<form
action="/admin/feeds/${feedId}/emails/bulk-delete"
method="post"
onsubmit="return confirmBulkEmailDelete()"
>
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-group toolbar-group-fill"> <div class="toolbar-group toolbar-group-fill">
<input <input
@@ -1529,29 +1557,37 @@ app.get("/feeds/:feedId/emails", async (c) => {
placeholder="Search email subjects" placeholder="Search email subjects"
oninput="scheduleEmailFilter()" oninput="scheduleEmailFilter()"
/> />
<span class="pill" id="email-match-count"
>Showing ${feedMetadata.emails.length}</span
>
<span class="pill" id="selected-email-count"
>0 selected</span
>
<button <button
type="button" type="button"
class="button button-small" class="button button-small button-secondary"
onclick="setVisibleEmailSelection(true)" onclick="selectMatchingEmails()"
> >
Select Visible Select Results
</button> </button>
<button <button
type="button" type="button"
class="button button-small" class="button button-small button-secondary"
onclick="setVisibleEmailSelection(false)" onclick="clearEmailSelection()"
> >
Clear Visible Clear Selection
</button>
<button
id="bulk-delete-emails-button"
type="submit"
class="button button-small button-danger"
disabled
>
Delete Selected
</button> </button>
<span class="pill" id="selected-email-count">0 selected</span>
</div> </div>
</div> </div>
<form
action="/admin/feeds/${feedId}/emails/bulk-delete"
method="post"
onsubmit="return confirmBulkEmailDelete()"
>
<div class="table-wrap"> <div class="table-wrap">
<table class="table table-emails"> <table class="table table-emails">
<colgroup> <colgroup>
@@ -1641,16 +1677,6 @@ app.get("/feeds/:feedId/emails", async (c) => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="actions-row">
<button
id="bulk-delete-emails-button"
type="submit"
class="button button-danger"
disabled
>
Delete Selected Emails
</button>
</div>
</form> </form>
` `
: html`<div class="card"> : html`<div class="card">
@@ -1666,6 +1692,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
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_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;
@@ -1677,13 +1704,23 @@ app.get("/feeds/:feedId/emails", async (c) => {
EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row')); EMAIL_ROWS = Array.from(document.querySelectorAll('.email-row'));
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_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();
setupEmailTableSorting(); setupEmailTableSorting();
updateEmailMatchCount();
updateEmailSelectionState(); updateEmailSelectionState();
} }
function updateEmailMatchCount() {
if (!EMAIL_MATCH_COUNT_EL) return;
const total = EMAIL_ROWS.length;
const visible = EMAIL_ROWS.filter((row) => !row.hidden).length;
const query = (document.getElementById('email-search')?.value || '').trim();
EMAIL_MATCH_COUNT_EL.textContent = query ? ('Showing ' + visible + ' of ' + total) : ('Showing ' + total);
}
function scheduleEmailFilter() { function scheduleEmailFilter() {
if (EMAIL_FILTER_TIMER) { if (EMAIL_FILTER_TIMER) {
clearTimeout(EMAIL_FILTER_TIMER); clearTimeout(EMAIL_FILTER_TIMER);
@@ -1903,12 +1940,24 @@ app.get("/feeds/:feedId/emails", async (c) => {
updateEmailSelectionState(); updateEmailSelectionState();
} }
function selectMatchingEmails() {
setVisibleEmailSelection(true);
}
function clearEmailSelection() {
EMAIL_CHECKBOXES.forEach((checkbox) => {
checkbox.checked = false;
})
updateEmailSelectionState();
}
function filterEmailRows() { function filterEmailRows() {
const query = (document.getElementById('email-search')?.value || '').toLowerCase().trim(); const query = (document.getElementById('email-search')?.value || '').toLowerCase().trim();
EMAIL_ROWS.forEach((row) => { EMAIL_ROWS.forEach((row) => {
const haystack = row.getAttribute('data-search') || ''; const haystack = row.getAttribute('data-search') || '';
row.hidden = !!query && !haystack.includes(query); row.hidden = !!query && !haystack.includes(query);
}); });
updateEmailMatchCount();
updateEmailSelectionState(); updateEmailSelectionState();
} }
+33
View File
@@ -240,6 +240,7 @@ export const componentStyles = `
input[type="text"], input[type="text"],
input[type="email"], input[type="email"],
input[type="search"],
input[type="password"], input[type="password"],
textarea { textarea {
display: block; display: block;
@@ -257,6 +258,18 @@ export const componentStyles = `
-webkit-backdrop-filter: blur(var(--blur-sm)); -webkit-backdrop-filter: blur(var(--blur-sm));
} }
input[type="search"] {
-webkit-appearance: none;
appearance: none;
}
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration {
-webkit-appearance: none;
}
input:focus, input:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
@@ -507,6 +520,26 @@ export const componentStyles = `
input.search { input.search {
min-width: 280px; min-width: 280px;
flex: 1; flex: 1;
height: 40px;
border-radius: var(--radius-pill);
padding: 0 14px;
padding-left: 38px;
background-color: rgba(60, 60, 67, 0.14);
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%238e8e93'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Ccircle%20cx='11'%20cy='11'%20r='7'/%3E%3Cpath%20d='M21%2021l-4.3-4.3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 14px center;
background-size: 16px 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@media (prefers-color-scheme: light) {
input.search {
background-color: rgba(60, 60, 67, 0.08);
}
}
input.search::placeholder {
color: var(--color-text-tertiary);
} }
.actions-row { .actions-row {
+1 -1
View File
@@ -38,7 +38,7 @@ export const layoutStyles = `
/* Wider layout for data-dense pages (tables) */ /* Wider layout for data-dense pages (tables) */
.container-wide { .container-wide {
max-width: 1280px; max-width: 1440px;
} }
/* Header Styles */ /* Header Styles */