[feat](trx-frontend-http): merge general bookmarks into rig view, fix button styling, improve rig display names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-24 21:02:08 +01:00
parent de5f27f75e
commit eb798ad79f
5 changed files with 94 additions and 35 deletions
@@ -1011,7 +1011,10 @@ async function refreshRigList() {
if (typeof r.display_name === "string" && r.display_name.length > 0) {
displayNames[r.remote] = r.display_name;
} else {
displayNames[r.remote] = r.remote;
const mfg = (r.manufacturer || "").trim();
const mdl = (r.model || "").trim();
const hw = [mfg, mdl].filter(Boolean).join(" ");
displayNames[r.remote] = hw || r.remote;
}
});
serverRigs = rigs;
@@ -379,11 +379,11 @@
</select>
<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-select-all-btn" type="button" class="bm-toolbar-btn" style="display:none;">Select All</button>
<button id="bm-select-all-btn" type="button" class="bm-add-btn" style="display:none;">Select All</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>
<span id="bm-move-selected-wrap" class="bm-move-wrap" style="display:none;">
<select id="bm-move-target" class="status-input" aria-label="Move destination"></select>
<button id="bm-move-selected-btn" type="button" class="bm-toolbar-btn">Move (<span id="bm-move-selected-count">0</span>)</button>
<button id="bm-move-selected-btn" type="button" class="bm-add-btn">Move (<span id="bm-move-selected-count">0</span>)</button>
</span>
</div>
<div id="bm-form-wrap" style="display:none;">
@@ -3,16 +3,17 @@
/** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general";
/** Build the ?scope= query string for the current bookmark scope. */
function bmScopeParam(prefix) {
/** Build the ?scope= query string for a given or current bookmark scope. */
function bmScopeParam(prefix, scope) {
const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(bmScope);
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
var bmList = [];
var bmRevision = 0;
let bmFilteredList = [];
let bmEditId = null;
let bmEditScope = null;
let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25;
const bmSelected = new Set();
@@ -154,6 +155,7 @@ function bmRender(list) {
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
const pageItems = list.slice(startIndex, endIndex);
const showScope = bmScope !== "general";
pageItems.forEach((bm) => {
const tr = document.createElement("tr");
tr.dataset.bmId = bm.id;
@@ -163,9 +165,10 @@ function bmRender(list) {
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
const commentCell = bm.comment || "";
const checked = bmSelected.has(bm.id) ? " checked" : "";
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
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)}${scopeBadge}</td>` +
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
`<td class="bm-col-bw">${bwCell}</td>` +
@@ -228,6 +231,7 @@ function bmOpenForm(bm) {
const wrap = document.getElementById("bm-form-wrap");
if (!wrap) return;
bmEditId = bm ? bm.id : null;
bmEditScope = bm ? (bm.scope || bmScope) : null;
document.getElementById("bm-id").value = bm ? bm.id : "";
document.getElementById("bm-name").value = bm ? bm.name : "";
@@ -296,7 +300,7 @@ async function bmSave(e) {
try {
let resp;
if (id) {
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false), {
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -325,8 +329,10 @@ async function bmSave(e) {
async function bmDelete(id) {
if (!confirm("Delete this bookmark?")) return;
const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : undefined;
try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false), {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE",
});
if (!resp.ok) throw new Error("HTTP " + resp.status);
@@ -471,12 +477,21 @@ async function bmMoveSelected() {
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
try {
const resp = await fetch("/bookmarks/batch_move" + bmScopeParam(false), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, to: target }),
});
if (!resp.ok) throw new Error("HTTP " + resp.status);
// Group selected IDs by their owning scope (skip if already in target).
const byScope = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
if (scope === target) continue;
(byScope[scope] ||= []).push(id);
}
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds, to: target }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
));
bmSelected.clear();
bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value);
@@ -505,12 +520,20 @@ async function bmDeleteSelected() {
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" + bmScopeParam(false), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error("HTTP " + resp.status);
// Group selected IDs by their owning scope.
const byScope = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
(byScope[scope] ||= []).push(id);
}
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
));
bmSelected.clear();
bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value);
@@ -3725,17 +3725,19 @@ button:focus-visible, input:focus-visible, select:focus-visible {
align-items: center;
gap: 0.35rem;
}
.bm-toolbar-btn {
white-space: nowrap;
font-size: 0.78rem;
padding: 0.25rem 0.6rem;
border-radius: 0.25rem;
border: 1px solid var(--border-light);
background: var(--btn-bg);
color: var(--text-heading);
cursor: pointer;
}
.bm-scope-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
padding: 0.05rem 0.3rem;
border-radius: 0.2rem;
background: var(--btn-bg);
border: 1px solid var(--border-light);
color: var(--text-muted);
vertical-align: middle;
margin-left: 0.3rem;
}
.bm-empty {
padding: 2rem 1rem;
text-align: center;
@@ -1505,6 +1505,14 @@ where
.body(body)
}
/// A bookmark with its owning scope tag for the list response.
#[derive(serde::Serialize)]
struct BookmarkWithScope {
#[serde(flatten)]
bm: crate::server::bookmarks::Bookmark,
scope: String,
}
#[get("/bookmarks")]
pub async fn list_bookmarks(
req: HttpRequest,
@@ -1517,15 +1525,38 @@ pub async fn list_bookmarks(
status::index_html(),
));
}
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut list = store.list();
let scope = query.scope.as_deref().filter(|s| !s.is_empty() && *s != "general");
let mut list: Vec<BookmarkWithScope> = match scope {
Some(remote) => {
// Rig selected: merge general + rig-specific (rig wins on duplicate IDs).
let mut map: std::collections::HashMap<String, BookmarkWithScope> = store_map
.general()
.list()
.into_iter()
.map(|bm| {
let id = bm.id.clone();
(id, BookmarkWithScope { bm, scope: "general".into() })
})
.collect();
for bm in store_map.store_for(remote).list() {
let id = bm.id.clone();
map.insert(id, BookmarkWithScope { bm, scope: remote.to_owned() });
}
map.into_values().collect()
}
None => {
store_map.general().list().into_iter()
.map(|bm| BookmarkWithScope { bm, scope: "general".into() })
.collect()
}
};
if let Some(ref cat) = query.category {
if !cat.is_empty() {
let cat_lower = cat.to_lowercase();
list.retain(|bm| bm.category.to_lowercase() == cat_lower);
list.retain(|item| item.bm.category.to_lowercase() == cat_lower);
}
}
list.sort_by_key(|bm| bm.freq_hz);
list.sort_by_key(|item| item.bm.freq_hz);
Ok(HttpResponse::Ok().json(list))
}