[feat](trx-frontend-http): add multi-bookmark selection for batch deletion
Add checkbox column to bookmark table with select-all support and a Delete Selected button for batch removal. New POST /bookmarks/batch_delete API endpoint accepts an array of IDs and removes them in one request. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -356,6 +356,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<input type="search" id="bm-text-filter" class="status-input" placeholder="Search bookmarks…" aria-label="Search bookmarks" />
|
<input type="search" id="bm-text-filter" class="status-input" placeholder="Search bookmarks…" aria-label="Search bookmarks" />
|
||||||
<button id="bm-add-btn" type="button" class="bm-add-btn" style="display:none;">+ Add Bookmark</button>
|
<button id="bm-add-btn" type="button" class="bm-add-btn" style="display:none;">+ Add Bookmark</button>
|
||||||
|
<button id="bm-del-selected-btn" type="button" class="bm-del-btn" style="display:none;">Delete Selected (<span id="bm-del-selected-count">0</span>)</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="bm-form-wrap" style="display:none;">
|
<div id="bm-form-wrap" style="display:none;">
|
||||||
<form id="bm-form" class="bm-form">
|
<form id="bm-form" class="bm-form">
|
||||||
@@ -415,6 +416,7 @@
|
|||||||
<table class="bm-table">
|
<table class="bm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="bm-col-sel"><input type="checkbox" id="bm-select-all" aria-label="Select all bookmarks" /></th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Frequency</th>
|
<th>Frequency</th>
|
||||||
<th>Mode</th>
|
<th>Mode</th>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ let bmFilteredList = [];
|
|||||||
let bmEditId = null;
|
let bmEditId = null;
|
||||||
let bmCurrentPage = 1;
|
let bmCurrentPage = 1;
|
||||||
const BM_PAGE_SIZE = 25;
|
const BM_PAGE_SIZE = 25;
|
||||||
|
const bmSelected = new Set();
|
||||||
|
|
||||||
function bmFmtFreq(hz) {
|
function bmFmtFreq(hz) {
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
@@ -49,6 +50,8 @@ async function bmFetch(categoryFilter) {
|
|||||||
if (typeof window.syncBookmarkMapLocators === "function") {
|
if (typeof window.syncBookmarkMapLocators === "function") {
|
||||||
window.syncBookmarkMapLocators(bmList);
|
window.syncBookmarkMapLocators(bmList);
|
||||||
}
|
}
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
bmSyncAccess();
|
bmSyncAccess();
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
bmRefreshCategoryFilter(categoryFilter);
|
bmRefreshCategoryFilter(categoryFilter);
|
||||||
@@ -142,7 +145,9 @@ function bmRender(list) {
|
|||||||
const catCell = bm.category || "Uncategorised";
|
const catCell = bm.category || "Uncategorised";
|
||||||
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||||
const commentCell = bm.comment || "";
|
const commentCell = bm.comment || "";
|
||||||
|
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
|
`<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td>` +
|
||||||
`<td class="bm-col-name">${bmEsc(bm.name)}</td>` +
|
`<td class="bm-col-name">${bmEsc(bm.name)}</td>` +
|
||||||
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
|
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
|
||||||
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
||||||
@@ -160,6 +165,7 @@ function bmRender(list) {
|
|||||||
`</td>`;
|
`</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
|
||||||
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||||
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||||
@@ -379,6 +385,48 @@ async function bmApply(bm) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bmUpdateSelectionUi() {
|
||||||
|
const count = bmSelected.size;
|
||||||
|
const btn = document.getElementById("bm-del-selected-btn");
|
||||||
|
const countEl = document.getElementById("bm-del-selected-count");
|
||||||
|
if (btn) btn.style.display = count > 0 && bmCanControl() ? "" : "none";
|
||||||
|
if (countEl) countEl.textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmSyncSelectAllCheckbox() {
|
||||||
|
const selectAll = document.getElementById("bm-select-all");
|
||||||
|
if (!selectAll) return;
|
||||||
|
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
selectAll.checked = false;
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||||
|
selectAll.checked = checkedCount === checkboxes.length;
|
||||||
|
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmDeleteSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks/batch_delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmarks:", err);
|
||||||
|
alert("Failed to delete bookmarks: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Event wiring ---
|
// --- Event wiring ---
|
||||||
(function initBookmarks() {
|
(function initBookmarks() {
|
||||||
// Set initial button visibility (auth may already be resolved by the time
|
// Set initial button visibility (auth may already be resolved by the time
|
||||||
@@ -440,8 +488,33 @@ async function bmApply(bm) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Table action buttons (event delegation)
|
// Select-all checkbox
|
||||||
|
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) bmSelected.add(cb.dataset.bmId);
|
||||||
|
else bmSelected.delete(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Selected button
|
||||||
|
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
|
bmDeleteSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table action buttons and row checkboxes (event delegation)
|
||||||
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||||
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
|
if (checkbox) {
|
||||||
|
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
||||||
|
else bmSelected.delete(checkbox.dataset.bmId);
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tuneBtn = e.target.closest(".bm-tune-btn");
|
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||||
const editBtn = e.target.closest(".bm-edit-btn");
|
const editBtn = e.target.closest(".bm-edit-btn");
|
||||||
const delBtn = e.target.closest(".bm-del-btn");
|
const delBtn = e.target.closest(".bm-del-btn");
|
||||||
|
|||||||
@@ -2750,6 +2750,8 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.bm-col-sel { grid-column: 1 / -1; display: flex; align-items: center; padding: 0; }
|
||||||
|
.bm-col-sel::before { display: none; }
|
||||||
.bm-col-name { grid-column: 1 / -1; font-weight: 600; font-size: 0.9rem; }
|
.bm-col-name { grid-column: 1 / -1; font-weight: 600; font-size: 0.9rem; }
|
||||||
.bm-col-name::before { content: "Bookmark"; }
|
.bm-col-name::before { content: "Bookmark"; }
|
||||||
.bm-col-freq::before { content: "Frequency"; }
|
.bm-col-freq::before { content: "Frequency"; }
|
||||||
@@ -3616,11 +3618,28 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
background: var(--border-light);
|
background: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bm-del-btn {
|
.bm-col-sel {
|
||||||
|
width: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bm-del-btn,
|
||||||
|
#bm-del-selected-btn {
|
||||||
color: var(--accent-red) !important;
|
color: var(--accent-red) !important;
|
||||||
border-color: var(--accent-red) !important;
|
border-color: var(--accent-red) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bm-del-selected-btn {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--accent-red);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.bm-empty {
|
.bm-empty {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1324,6 +1324,28 @@ pub async fn delete_bookmark(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct BatchDeleteRequest {
|
||||||
|
ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/bookmarks/batch_delete")]
|
||||||
|
pub async fn batch_delete_bookmarks(
|
||||||
|
req: HttpRequest,
|
||||||
|
body: web::Json<BatchDeleteRequest>,
|
||||||
|
store: web::Data<Arc<crate::server::bookmarks::BookmarkStore>>,
|
||||||
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
require_control(&req, &auth_state)?;
|
||||||
|
let mut deleted = 0usize;
|
||||||
|
for id in &body.ids {
|
||||||
|
if store.remove(id) {
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct RigListItem {
|
struct RigListItem {
|
||||||
rig_id: String,
|
rig_id: String,
|
||||||
@@ -1612,6 +1634,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(create_bookmark)
|
.service(create_bookmark)
|
||||||
.service(update_bookmark)
|
.service(update_bookmark)
|
||||||
.service(delete_bookmark)
|
.service(delete_bookmark)
|
||||||
|
.service(batch_delete_bookmarks)
|
||||||
// Scheduler
|
// Scheduler
|
||||||
.service(crate::server::scheduler::get_scheduler)
|
.service(crate::server::scheduler::get_scheduler)
|
||||||
.service(crate::server::scheduler::put_scheduler)
|
.service(crate::server::scheduler::put_scheduler)
|
||||||
|
|||||||
Reference in New Issue
Block a user