diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html index c68c1ba..70c8e9a 100644 --- a/meshview/templates/nodelist.html +++ b/meshview/templates/nodelist.html @@ -103,6 +103,21 @@ select, .export-btn, .search-box, .clear-btn { font-weight: bold; color: white; } +.node-status { + margin-left: 10px; + padding: 2px 8px; + border-radius: 12px; + border: 1px solid #2a6a8a; + background: #0d2a3a; + color: #9fd4ff; + font-size: 0.9em; + display: inline-block; + opacity: 0; + transition: opacity 0.15s ease-in-out; +} +.node-status.active { + opacity: 1; +} /* Favorite stars */ .favorite-star { @@ -235,6 +250,7 @@ select, .export-btn, .search-box, .clear-btn { Showing 0 nodes + @@ -305,6 +321,11 @@ let allNodes = []; let sortColumn = "short_name"; let sortAsc = true; let showOnlyFavorites = false; +let favoritesSet = new Set(); +let isBusy = false; +let statusHideTimer = null; +let statusShownAt = 0; +const minStatusMs = 300; const headers = document.querySelectorAll("thead th"); const keyMap = [ @@ -320,22 +341,38 @@ function debounce(fn, delay = 250) { }; } -function getFavorites() { - const favorites = localStorage.getItem('nodelist_favorites'); - return favorites ? JSON.parse(favorites) : []; +function nextFrame() { + return new Promise(resolve => requestAnimationFrame(() => resolve())); } -function saveFavorites(favs) { - localStorage.setItem('nodelist_favorites', JSON.stringify(favs)); + +function loadFavorites() { + const favorites = localStorage.getItem('nodelist_favorites'); + if (!favorites) { + favoritesSet = new Set(); + return; + } + + try { + const parsed = JSON.parse(favorites); + favoritesSet = new Set(Array.isArray(parsed) ? parsed : []); + } catch (err) { + console.warn("Failed to parse favorites, resetting.", err); + favoritesSet = new Set(); + } +} +function saveFavorites() { + localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet])); } function toggleFavorite(nodeId) { - let favs = getFavorites(); - const idx = favs.indexOf(nodeId); - if (idx >= 0) favs.splice(idx, 1); - else favs.push(nodeId); - saveFavorites(favs); + if (favoritesSet.has(nodeId)) { + favoritesSet.delete(nodeId); + } else { + favoritesSet.add(nodeId); + } + saveFavorites(); } function isFavorite(nodeId) { - return getFavorites().includes(nodeId); + return favoritesSet.has(nodeId); } function timeAgoFromMs(msTimestamp) { @@ -357,6 +394,7 @@ function timeAgoFromMs(msTimestamp) { document.addEventListener("DOMContentLoaded", async function() { await loadTranslationsNodelist(); + loadFavorites(); const tbody = document.getElementById("node-table-body"); const mobileList = document.getElementById("mobile-node-list"); @@ -367,6 +405,7 @@ document.addEventListener("DOMContentLoaded", async function() { const firmwareFilter = document.getElementById("firmware-filter"); const searchBox = document.getElementById("search-box"); const countSpan = document.getElementById("node-count"); + const statusSpan = document.getElementById("node-status"); const exportBtn = document.getElementById("export-btn"); const clearBtn = document.getElementById("clear-btn"); const favoritesBtn = document.getElementById("favorites-btn"); @@ -374,6 +413,8 @@ document.addEventListener("DOMContentLoaded", async function() { let lastIsMobile = (window.innerWidth <= 768); try { + setStatus("Loading nodes…"); + await nextFrame(); const res = await fetch("/api/nodes?days_active=3"); if (!res.ok) throw new Error("Failed to fetch nodes"); @@ -404,11 +445,13 @@ document.addEventListener("DOMContentLoaded", async function() { populateFilters(allNodes); applyFilters(); // ensures initial sort + render uses same path updateSortIcons(); + setStatus(""); } catch (err) { tbody.innerHTML = ` ${nodelistTranslations.error_loading_nodes || "Error loading nodes"} `; + setStatus(""); return; } @@ -499,7 +542,9 @@ document.addEventListener("DOMContentLoaded", async function() { applyFilters(); } - function applyFilters() { + async function applyFilters() { + setStatus("Updating…"); + await nextFrame(); const searchTerm = searchBox.value.trim().toLowerCase(); let filtered = allNodes.filter(n => { @@ -519,27 +564,34 @@ document.addEventListener("DOMContentLoaded", async function() { renderTable(filtered); updateSortIcons(); + setStatus(""); } function renderTable(nodes) { - tbody.innerHTML = ""; - mobileList.innerHTML = ""; - - const tableFrag = document.createDocumentFragment(); - const mobileFrag = document.createDocumentFragment(); - const isMobile = window.innerWidth <= 768; + const shouldRenderTable = !isMobile; + + if (shouldRenderTable) { + tbody.innerHTML = ""; + } else { + mobileList.innerHTML = ""; + } + + const tableFrag = shouldRenderTable ? document.createDocumentFragment() : null; + const mobileFrag = shouldRenderTable ? null : document.createDocumentFragment(); if (!nodes.length) { - tbody.innerHTML = ` - + if (shouldRenderTable) { + tbody.innerHTML = ` + + ${nodelistTranslations.no_nodes_found || "No nodes found"} + + `; + } else { + mobileList.innerHTML = `
${nodelistTranslations.no_nodes_found || "No nodes found"} - - `; - - mobileList.innerHTML = `
- ${nodelistTranslations.no_nodes_found || "No nodes found"} -
`; +
`; + } countSpan.textContent = 0; return; @@ -549,54 +601,56 @@ document.addEventListener("DOMContentLoaded", async function() { const fav = isFavorite(node.node_id); const star = fav ? "★" : "☆"; - // DESKTOP TABLE ROW - const row = document.createElement("tr"); - row.innerHTML = ` - ${node.short_name || "N/A"} - ${node.long_name || "N/A"} - ${node.hw_model || "N/A"} - ${node.firmware || "N/A"} - ${node.role || "N/A"} - ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} - ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} - ${node.channel || "N/A"} - ${timeAgoFromMs(node.last_seen_ms)} - - - ${star} - - - `; - tableFrag.appendChild(row); + if (shouldRenderTable) { + // DESKTOP TABLE ROW + const row = document.createElement("tr"); + row.innerHTML = ` + ${node.short_name || "N/A"} + ${node.long_name || "N/A"} + ${node.hw_model || "N/A"} + ${node.firmware || "N/A"} + ${node.role || "N/A"} + ${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"} + ${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"} + ${node.channel || "N/A"} + ${timeAgoFromMs(node.last_seen_ms)} + + + ${star} + + + `; + tableFrag.appendChild(row); + } else { + // MOBILE CARD VIEW + const card = document.createElement("div"); + card.className = "node-card"; + card.innerHTML = ` +
+ ${node.short_name || node.long_name || node.node_id} + + ${star} + +
- // MOBILE CARD VIEW - const card = document.createElement("div"); - card.className = "node-card"; - card.innerHTML = ` -
- ${node.short_name || node.long_name || node.node_id} - - ${star} - -
+
ID: ${node.node_id}
+
Name: ${node.long_name || "N/A"}
+
HW: ${node.hw_model || "N/A"}
+
Firmware: ${node.firmware || "N/A"}
+
Role: ${node.role || "N/A"}
+
Location: + ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, + ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"} +
+
Channel: ${node.channel || "N/A"}
+
Last Seen: ${timeAgoFromMs(node.last_seen_ms)}
-
ID: ${node.node_id}
-
Name: ${node.long_name || "N/A"}
-
HW: ${node.hw_model || "N/A"}
-
Firmware: ${node.firmware || "N/A"}
-
Role: ${node.role || "N/A"}
-
Location: - ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, - ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"} -
-
Channel: ${node.channel || "N/A"}
-
Last Seen: ${timeAgoFromMs(node.last_seen_ms)}
- - - View Node → - - `; - mobileFrag.appendChild(card); + + View Node → + + `; + mobileFrag.appendChild(card); + } }); // Toggle correct view @@ -604,8 +658,11 @@ document.addEventListener("DOMContentLoaded", async function() { countSpan.textContent = nodes.length; - tbody.appendChild(tableFrag); - mobileList.appendChild(mobileFrag); + if (shouldRenderTable) { + tbody.appendChild(tableFrag); + } else { + mobileList.appendChild(mobileFrag); + } } function clearFilters() { @@ -676,6 +733,41 @@ document.addEventListener("DOMContentLoaded", async function() { keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : ""; }); } + + function setStatus(message) { + if (!statusSpan) return; + if (statusHideTimer) { + clearTimeout(statusHideTimer); + statusHideTimer = null; + } + + if (message) { + statusShownAt = Date.now(); + console.log("[nodelist] status:", message); + statusSpan.textContent = message; + statusSpan.classList.add("active"); + isBusy = true; + return; + } + + const elapsed = Date.now() - statusShownAt; + const remaining = Math.max(0, minStatusMs - elapsed); + if (remaining > 0) { + statusHideTimer = setTimeout(() => { + statusHideTimer = null; + console.log("[nodelist] status: cleared"); + statusSpan.textContent = ""; + statusSpan.classList.remove("active"); + isBusy = false; + }, remaining); + return; + } + + console.log("[nodelist] status: cleared"); + statusSpan.textContent = ""; + statusSpan.classList.remove("active"); + isBusy = false; + } }); {% endblock %}