[feat](trx-frontend-http): add Bookmarks tab to web UI
Add a "Bookmarks" tab between Main and Plugins in the tab bar. HTML: tab panel with toolbar (category filter + Add Bookmark button), an inline add/edit form (hidden by default, prefills freq/mode/BW from the current rig state), and a sortable table showing all columns with Tune / Edit / Del action buttons. CSS: responsive bm-* classes following existing card/button theming, works in both dark and light modes and all palette variants. bookmarks.js: fetches bookmarks on tab activation, renders table with event delegation, handles create/update/delete via REST, and applies a bookmark by calling set_freq → set_mode → set_bandwidth, plus toggles FT8/WSPR decoders when the stored mode is DIG. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
</div>
|
||||
<div class="tab-bar-nav">
|
||||
<button class="tab active" data-tab="main">Main</button>
|
||||
<button class="tab" data-tab="bookmarks">Bookmarks</button>
|
||||
<button class="tab" data-tab="plugins">Plugins</button>
|
||||
<button class="tab" data-tab="map">Map</button>
|
||||
<button class="tab" data-tab="about">About</button>
|
||||
@@ -176,10 +177,12 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="wfm-control">
|
||||
<span class="wfm-control-label">Denoise</span>
|
||||
<span class="wfm-control-label">Denoise Level</span>
|
||||
<select id="wfm-denoise" class="status-input">
|
||||
<option value="on">On</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="wfm-gain-group" id="sdr-gain-controls">
|
||||
@@ -258,6 +261,74 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
||||
<div class="bm-toolbar">
|
||||
<select id="bm-category-filter" class="status-input" aria-label="Filter by category">
|
||||
<option value="">All categories</option>
|
||||
</select>
|
||||
<button id="bm-add-btn" type="button" class="bm-add-btn">+ Add Bookmark</button>
|
||||
</div>
|
||||
<div id="bm-form-wrap" style="display:none;">
|
||||
<form id="bm-form" class="bm-form">
|
||||
<div class="bm-form-title" id="bm-form-title">Add Bookmark</div>
|
||||
<input type="hidden" id="bm-id" />
|
||||
<div class="bm-form-grid">
|
||||
<label class="bm-label">Name
|
||||
<input type="text" id="bm-name" class="status-input" required placeholder="e.g. 40m FT8" />
|
||||
</label>
|
||||
<label class="bm-label">Category
|
||||
<input type="text" id="bm-category-input" class="status-input" placeholder="Uncategorised" />
|
||||
</label>
|
||||
<label class="bm-label">Frequency (Hz)
|
||||
<input type="number" id="bm-freq" class="status-input" required min="0" placeholder="e.g. 7074000" />
|
||||
</label>
|
||||
<label class="bm-label">Mode
|
||||
<input type="text" id="bm-mode" class="status-input" list="bm-mode-list" required placeholder="e.g. DIG" />
|
||||
<datalist id="bm-mode-list">
|
||||
<option value="LSB">
|
||||
<option value="USB">
|
||||
<option value="AM">
|
||||
<option value="FM">
|
||||
<option value="DIG">
|
||||
<option value="CW">
|
||||
<option value="WFM">
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="bm-label">Bandwidth (Hz)
|
||||
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
|
||||
</label>
|
||||
<label class="bm-label">Decoders (comma-separated)
|
||||
<input type="text" id="bm-decoders-input" class="status-input" placeholder="e.g. ft8, wspr" />
|
||||
</label>
|
||||
<label class="bm-label bm-label-wide">Comment
|
||||
<input type="text" id="bm-comment" class="status-input" placeholder="optional" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="bm-form-actions">
|
||||
<button type="submit" class="bm-save-btn">Save</button>
|
||||
<button type="button" id="bm-form-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="bm-table-wrap">
|
||||
<table class="bm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Frequency</th>
|
||||
<th>Mode</th>
|
||||
<th>BW</th>
|
||||
<th>Category</th>
|
||||
<th>Decoders</th>
|
||||
<th>Comment</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bm-tbody"></tbody>
|
||||
</table>
|
||||
<div id="bm-empty" class="bm-empty" style="display:none;">No bookmarks yet. Click <strong>+ Add Bookmark</strong> to save a frequency.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||
@@ -422,5 +493,6 @@
|
||||
<script src="/ft8.js"></script>
|
||||
<script src="/wspr.js"></script>
|
||||
<script src="/cw.js"></script>
|
||||
<script src="/bookmarks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// --- Bookmarks Tab ---
|
||||
|
||||
let bmList = [];
|
||||
let bmEditId = null;
|
||||
|
||||
function bmFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
function bmEsc(str) {
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(document.createTextNode(String(str)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function bmFetch(categoryFilter) {
|
||||
let url = "/bookmarks";
|
||||
if (categoryFilter && categoryFilter !== "") {
|
||||
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
bmList = await resp.json();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch bookmarks:", e);
|
||||
bmList = [];
|
||||
}
|
||||
bmRender(bmList);
|
||||
bmRefreshCategoryFilter(categoryFilter);
|
||||
}
|
||||
|
||||
async function bmRefreshCategoryFilter(keepValue) {
|
||||
const sel = document.getElementById("bm-category-filter");
|
||||
if (!sel) return;
|
||||
try {
|
||||
const resp = await fetch("/bookmarks");
|
||||
if (!resp.ok) return;
|
||||
const all = await resp.json();
|
||||
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
cats.forEach((cat) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = cat;
|
||||
opt.textContent = cat;
|
||||
sel.add(opt);
|
||||
});
|
||||
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function bmRender(list) {
|
||||
const tbody = document.getElementById("bm-tbody");
|
||||
const emptyEl = document.getElementById("bm-empty");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (list.length === 0) {
|
||||
if (emptyEl) emptyEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
|
||||
// canControl: auth is disabled, or user has control role
|
||||
const canControl =
|
||||
(typeof authEnabled !== "undefined" && !authEnabled) ||
|
||||
(typeof authRole !== "undefined" && authRole === "control");
|
||||
|
||||
list.forEach((bm) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.bmId = bm.id;
|
||||
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||
const catCell = bm.category || "Uncategorised";
|
||||
const decoderCell = (bm.decoders || []).join(", ") || "--";
|
||||
const commentCell = bm.comment || "";
|
||||
tr.innerHTML =
|
||||
`<td class="bm-col-name">${bmEsc(bm.name)}</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>` +
|
||||
`<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
|
||||
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
|
||||
`<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
|
||||
`<td class="bm-col-act">` +
|
||||
`<button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` +
|
||||
(canControl
|
||||
? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button>` +
|
||||
`<button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Del</button>`
|
||||
: "") +
|
||||
`</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function bmOpenForm(bm) {
|
||||
const wrap = document.getElementById("bm-form-wrap");
|
||||
if (!wrap) return;
|
||||
bmEditId = bm ? bm.id : null;
|
||||
|
||||
document.getElementById("bm-id").value = bm ? bm.id : "";
|
||||
document.getElementById("bm-name").value = bm ? bm.name : "";
|
||||
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
||||
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
||||
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||
document.getElementById("bm-decoders-input").value = bm ? (bm.decoders || []).join(", ") : "";
|
||||
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||
|
||||
wrap.style.display = "";
|
||||
document.getElementById("bm-name").focus();
|
||||
}
|
||||
|
||||
function bmCloseForm() {
|
||||
const wrap = document.getElementById("bm-form-wrap");
|
||||
if (wrap) wrap.style.display = "none";
|
||||
bmEditId = null;
|
||||
}
|
||||
|
||||
function bmPrefillFromStatus() {
|
||||
// Use globals maintained by app.js (updated by SSE stream)
|
||||
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
||||
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
||||
}
|
||||
if (typeof lastModeName === "string" && lastModeName) {
|
||||
document.getElementById("bm-mode").value = lastModeName;
|
||||
}
|
||||
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
||||
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmSave(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById("bm-id").value;
|
||||
const name = document.getElementById("bm-name").value.trim();
|
||||
const freqStr = document.getElementById("bm-freq").value;
|
||||
const freq_hz = parseInt(freqStr, 10);
|
||||
const mode = document.getElementById("bm-mode").value.trim();
|
||||
const bwStr = document.getElementById("bm-bw").value;
|
||||
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||
const category = document.getElementById("bm-category-input").value.trim();
|
||||
const comment = document.getElementById("bm-comment").value.trim();
|
||||
const decoderStr = document.getElementById("bm-decoders-input").value;
|
||||
const decoders = decoderStr
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||
alert("Name, Frequency, and Mode are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { name, freq_hz, mode, bandwidth_hz, category, comment, decoders };
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (id) {
|
||||
resp = await fetch("/bookmarks/" + encodeURIComponent(id), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
resp = await fetch("/bookmarks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text || "HTTP " + resp.status);
|
||||
}
|
||||
bmCloseForm();
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to save bookmark:", err);
|
||||
alert("Failed to save bookmark: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmDelete(id) {
|
||||
if (!confirm("Delete this bookmark?")) return;
|
||||
try {
|
||||
const resp = await fetch("/bookmarks/" + encodeURIComponent(id), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete bookmark:", err);
|
||||
alert("Failed to delete bookmark: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bmApply(bm) {
|
||||
try {
|
||||
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||
if (bm.bandwidth_hz) {
|
||||
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
||||
}
|
||||
// Toggle decoders when in DIG mode
|
||||
if (bm.mode === "DIG" && Array.isArray(bm.decoders)) {
|
||||
const statusResp = await fetch("/status");
|
||||
if (statusResp.ok) {
|
||||
const st = await statusResp.json();
|
||||
const wantFt8 = bm.decoders.includes("ft8");
|
||||
if (wantFt8 !== !!st.ft8_decode_enabled) {
|
||||
await postPath("/toggle_ft8_decode");
|
||||
}
|
||||
const wantWspr = bm.decoders.includes("wspr");
|
||||
if (wantWspr !== !!st.wspr_decode_enabled) {
|
||||
await postPath("/toggle_wspr_decode");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to apply bookmark:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event wiring ---
|
||||
(function initBookmarks() {
|
||||
// Refresh list when the Bookmarks tab is activated
|
||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
||||
if (!btn) return;
|
||||
bmFetch(document.getElementById("bm-category-filter").value);
|
||||
});
|
||||
|
||||
// Add Bookmark button — open form and prefill from current rig state
|
||||
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
||||
bmOpenForm(null);
|
||||
bmPrefillFromStatus();
|
||||
});
|
||||
|
||||
// Category filter dropdown
|
||||
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
||||
bmFetch(e.target.value);
|
||||
});
|
||||
|
||||
// Form submit
|
||||
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
||||
|
||||
// Form cancel
|
||||
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||
|
||||
// Table action buttons (event delegation)
|
||||
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||
const editBtn = e.target.closest(".bm-edit-btn");
|
||||
const delBtn = e.target.closest(".bm-del-btn");
|
||||
|
||||
if (tuneBtn) {
|
||||
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||
if (bm) await bmApply(bm);
|
||||
} else if (editBtn) {
|
||||
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||
if (bm) bmOpenForm(bm);
|
||||
} else if (delBtn) {
|
||||
await bmDelete(delBtn.dataset.bmId);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1904,3 +1904,156 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
--wavelength-fg: #7030a0;
|
||||
--spectrum-bg: #f0d8ff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Bookmarks tab
|
||||
============================================================ */
|
||||
|
||||
.bm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bm-add-btn {
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bm-add-btn:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.bm-form {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin: 0 0.75rem 0.6rem;
|
||||
}
|
||||
|
||||
.bm-form-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.65rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.bm-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.bm-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bm-label input {
|
||||
font-size: 0.88rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.bm-label-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bm-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.bm-save-btn {
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bm-save-btn:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.bm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.bm-table th {
|
||||
text-align: left;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bm-table td {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bm-table tr:hover td {
|
||||
background: var(--btn-bg);
|
||||
}
|
||||
|
||||
.bm-col-freq,
|
||||
.bm-col-bw {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bm-col-mode,
|
||||
.bm-col-dec {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bm-col-act {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bm-col-act button {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--btn-border);
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bm-col-act button:hover {
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
.bm-del-btn {
|
||||
color: var(--accent-red) !important;
|
||||
border-color: var(--accent-red) !important;
|
||||
}
|
||||
|
||||
.bm-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user