|
103 | 103 | font-weight: bold; |
104 | 104 | color: white; |
105 | 105 | } |
| 106 | +.node-status { |
| 107 | + margin-left: 10px; |
| 108 | + padding: 2px 8px; |
| 109 | + border-radius: 12px; |
| 110 | + border: 1px solid #2a6a8a; |
| 111 | + background: #0d2a3a; |
| 112 | + color: #9fd4ff; |
| 113 | + font-size: 0.9em; |
| 114 | + display: inline-block; |
| 115 | + opacity: 0; |
| 116 | + transition: opacity 0.15s ease-in-out; |
| 117 | +} |
| 118 | +.node-status.active { |
| 119 | + opacity: 1; |
| 120 | +} |
106 | 121 |
|
107 | 122 | /* Favorite stars */ |
108 | 123 | .favorite-star { |
|
235 | 250 | <span data-translate-lang="showing_nodes">Showing</span> |
236 | 251 | <span id="node-count">0</span> |
237 | 252 | <span data-translate-lang="nodes_suffix">nodes</span> |
| 253 | + <span id="node-status" class="node-status" aria-live="polite"></span> |
238 | 254 | </div> |
239 | 255 |
|
240 | 256 | <!-- Desktop table --> |
|
305 | 321 | let sortColumn = "short_name"; |
306 | 322 | let sortAsc = true; |
307 | 323 | let showOnlyFavorites = false; |
| 324 | +let favoritesSet = new Set(); |
| 325 | +let isBusy = false; |
| 326 | +let statusHideTimer = null; |
| 327 | +let statusShownAt = 0; |
| 328 | +const minStatusMs = 300; |
308 | 329 |
|
309 | 330 | const headers = document.querySelectorAll("thead th"); |
310 | 331 | const keyMap = [ |
|
320 | 341 | }; |
321 | 342 | } |
322 | 343 |
|
323 | | -function getFavorites() { |
| 344 | +function nextFrame() { |
| 345 | + return new Promise(resolve => requestAnimationFrame(() => resolve())); |
| 346 | +} |
| 347 | + |
| 348 | +function loadFavorites() { |
324 | 349 | const favorites = localStorage.getItem('nodelist_favorites'); |
325 | | - return favorites ? JSON.parse(favorites) : []; |
| 350 | + if (!favorites) { |
| 351 | + favoritesSet = new Set(); |
| 352 | + return; |
| 353 | + } |
| 354 | + |
| 355 | + try { |
| 356 | + const parsed = JSON.parse(favorites); |
| 357 | + favoritesSet = new Set(Array.isArray(parsed) ? parsed : []); |
| 358 | + } catch (err) { |
| 359 | + console.warn("Failed to parse favorites, resetting.", err); |
| 360 | + favoritesSet = new Set(); |
| 361 | + } |
326 | 362 | } |
327 | | -function saveFavorites(favs) { |
328 | | - localStorage.setItem('nodelist_favorites', JSON.stringify(favs)); |
| 363 | +function saveFavorites() { |
| 364 | + localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet])); |
329 | 365 | } |
330 | 366 | function toggleFavorite(nodeId) { |
331 | | - let favs = getFavorites(); |
332 | | - const idx = favs.indexOf(nodeId); |
333 | | - if (idx >= 0) favs.splice(idx, 1); |
334 | | - else favs.push(nodeId); |
335 | | - saveFavorites(favs); |
| 367 | + if (favoritesSet.has(nodeId)) { |
| 368 | + favoritesSet.delete(nodeId); |
| 369 | + } else { |
| 370 | + favoritesSet.add(nodeId); |
| 371 | + } |
| 372 | + saveFavorites(); |
336 | 373 | } |
337 | 374 | function isFavorite(nodeId) { |
338 | | - return getFavorites().includes(nodeId); |
| 375 | + return favoritesSet.has(nodeId); |
339 | 376 | } |
340 | 377 |
|
341 | 378 | function timeAgoFromMs(msTimestamp) { |
|
357 | 394 | document.addEventListener("DOMContentLoaded", async function() { |
358 | 395 |
|
359 | 396 | await loadTranslationsNodelist(); |
| 397 | + loadFavorites(); |
360 | 398 |
|
361 | 399 | const tbody = document.getElementById("node-table-body"); |
362 | 400 | const mobileList = document.getElementById("mobile-node-list"); |
|
367 | 405 | const firmwareFilter = document.getElementById("firmware-filter"); |
368 | 406 | const searchBox = document.getElementById("search-box"); |
369 | 407 | const countSpan = document.getElementById("node-count"); |
| 408 | + const statusSpan = document.getElementById("node-status"); |
370 | 409 | const exportBtn = document.getElementById("export-btn"); |
371 | 410 | const clearBtn = document.getElementById("clear-btn"); |
372 | 411 | const favoritesBtn = document.getElementById("favorites-btn"); |
373 | 412 |
|
374 | 413 | let lastIsMobile = (window.innerWidth <= 768); |
375 | 414 |
|
376 | 415 | try { |
| 416 | + setStatus("Loading nodes…"); |
| 417 | + await nextFrame(); |
377 | 418 | const res = await fetch("/api/nodes?days_active=3"); |
378 | 419 | if (!res.ok) throw new Error("Failed to fetch nodes"); |
379 | 420 |
|
|
404 | 445 | populateFilters(allNodes); |
405 | 446 | applyFilters(); // ensures initial sort + render uses same path |
406 | 447 | updateSortIcons(); |
| 448 | + setStatus(""); |
407 | 449 | } catch (err) { |
408 | 450 | tbody.innerHTML = `<tr> |
409 | 451 | <td colspan="10" style="text-align:center; color:red;"> |
410 | 452 | ${nodelistTranslations.error_loading_nodes || "Error loading nodes"} |
411 | 453 | </td></tr>`; |
| 454 | + setStatus(""); |
412 | 455 | return; |
413 | 456 | } |
414 | 457 |
|
|
499 | 542 | applyFilters(); |
500 | 543 | } |
501 | 544 |
|
502 | | - function applyFilters() { |
| 545 | + async function applyFilters() { |
| 546 | + setStatus("Updating…"); |
| 547 | + await nextFrame(); |
503 | 548 | const searchTerm = searchBox.value.trim().toLowerCase(); |
504 | 549 |
|
505 | 550 | let filtered = allNodes.filter(n => { |
|
519 | 564 |
|
520 | 565 | renderTable(filtered); |
521 | 566 | updateSortIcons(); |
| 567 | + setStatus(""); |
522 | 568 | } |
523 | 569 |
|
524 | 570 | function renderTable(nodes) { |
525 | | - tbody.innerHTML = ""; |
526 | | - mobileList.innerHTML = ""; |
| 571 | + const isMobile = window.innerWidth <= 768; |
| 572 | + const shouldRenderTable = !isMobile; |
527 | 573 |
|
528 | | - const tableFrag = document.createDocumentFragment(); |
529 | | - const mobileFrag = document.createDocumentFragment(); |
| 574 | + if (shouldRenderTable) { |
| 575 | + tbody.innerHTML = ""; |
| 576 | + } else { |
| 577 | + mobileList.innerHTML = ""; |
| 578 | + } |
530 | 579 |
|
531 | | - const isMobile = window.innerWidth <= 768; |
| 580 | + const tableFrag = shouldRenderTable ? document.createDocumentFragment() : null; |
| 581 | + const mobileFrag = shouldRenderTable ? null : document.createDocumentFragment(); |
532 | 582 |
|
533 | 583 | if (!nodes.length) { |
534 | | - tbody.innerHTML = `<tr> |
535 | | - <td colspan="10" style="text-align:center; color:white;"> |
| 584 | + if (shouldRenderTable) { |
| 585 | + tbody.innerHTML = `<tr> |
| 586 | + <td colspan="10" style="text-align:center; color:white;"> |
| 587 | + ${nodelistTranslations.no_nodes_found || "No nodes found"} |
| 588 | + </td> |
| 589 | + </tr>`; |
| 590 | + } else { |
| 591 | + mobileList.innerHTML = `<div style="text-align:center; color:white;"> |
536 | 592 | ${nodelistTranslations.no_nodes_found || "No nodes found"} |
537 | | - </td> |
538 | | - </tr>`; |
539 | | - |
540 | | - mobileList.innerHTML = `<div style="text-align:center; color:white;"> |
541 | | - ${nodelistTranslations.no_nodes_found || "No nodes found"} |
542 | | - </div>`; |
| 593 | + </div>`; |
| 594 | + } |
543 | 595 |
|
544 | 596 | countSpan.textContent = 0; |
545 | 597 | return; |
|
549 | 601 | const fav = isFavorite(node.node_id); |
550 | 602 | const star = fav ? "★" : "☆"; |
551 | 603 |
|
552 | | - // DESKTOP TABLE ROW |
553 | | - const row = document.createElement("tr"); |
554 | | - row.innerHTML = ` |
555 | | - <td>${node.short_name || "N/A"}</td> |
556 | | - <td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td> |
557 | | - <td>${node.hw_model || "N/A"}</td> |
558 | | - <td>${node.firmware || "N/A"}</td> |
559 | | - <td>${node.role || "N/A"}</td> |
560 | | - <td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td> |
561 | | - <td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td> |
562 | | - <td>${node.channel || "N/A"}</td> |
563 | | - <td>${timeAgoFromMs(node.last_seen_ms)}</td> |
564 | | - <td style="text-align:center;"> |
565 | | - <span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}"> |
566 | | - ${star} |
567 | | - </span> |
568 | | - </td> |
569 | | - `; |
570 | | - tableFrag.appendChild(row); |
571 | | - |
572 | | - // MOBILE CARD VIEW |
573 | | - const card = document.createElement("div"); |
574 | | - card.className = "node-card"; |
575 | | - card.innerHTML = ` |
576 | | - <div class="node-card-header"> |
577 | | - <span>${node.short_name || node.long_name || node.node_id}</span> |
578 | | - <span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}"> |
579 | | - ${star} |
580 | | - </span> |
581 | | - </div> |
582 | | -
|
583 | | - <div class="node-card-field"><b>ID:</b> ${node.node_id}</div> |
584 | | - <div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div> |
585 | | - <div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div> |
586 | | - <div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div> |
587 | | - <div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div> |
588 | | - <div class="node-card-field"><b>Location:</b> |
589 | | - ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, |
590 | | - ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"} |
591 | | - </div> |
592 | | - <div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div> |
593 | | - <div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div> |
594 | | -
|
595 | | - <a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;"> |
596 | | - View Node → |
597 | | - </a> |
598 | | - `; |
599 | | - mobileFrag.appendChild(card); |
| 604 | + if (shouldRenderTable) { |
| 605 | + // DESKTOP TABLE ROW |
| 606 | + const row = document.createElement("tr"); |
| 607 | + row.innerHTML = ` |
| 608 | + <td>${node.short_name || "N/A"}</td> |
| 609 | + <td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td> |
| 610 | + <td>${node.hw_model || "N/A"}</td> |
| 611 | + <td>${node.firmware || "N/A"}</td> |
| 612 | + <td>${node.role || "N/A"}</td> |
| 613 | + <td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td> |
| 614 | + <td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td> |
| 615 | + <td>${node.channel || "N/A"}</td> |
| 616 | + <td>${timeAgoFromMs(node.last_seen_ms)}</td> |
| 617 | + <td style="text-align:center;"> |
| 618 | + <span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}"> |
| 619 | + ${star} |
| 620 | + </span> |
| 621 | + </td> |
| 622 | + `; |
| 623 | + tableFrag.appendChild(row); |
| 624 | + } else { |
| 625 | + // MOBILE CARD VIEW |
| 626 | + const card = document.createElement("div"); |
| 627 | + card.className = "node-card"; |
| 628 | + card.innerHTML = ` |
| 629 | + <div class="node-card-header"> |
| 630 | + <span>${node.short_name || node.long_name || node.node_id}</span> |
| 631 | + <span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}"> |
| 632 | + ${star} |
| 633 | + </span> |
| 634 | + </div> |
| 635 | +
|
| 636 | + <div class="node-card-field"><b>ID:</b> ${node.node_id}</div> |
| 637 | + <div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div> |
| 638 | + <div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div> |
| 639 | + <div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div> |
| 640 | + <div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div> |
| 641 | + <div class="node-card-field"><b>Location:</b> |
| 642 | + ${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, |
| 643 | + ${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"} |
| 644 | + </div> |
| 645 | + <div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div> |
| 646 | + <div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div> |
| 647 | +
|
| 648 | + <a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;"> |
| 649 | + View Node → |
| 650 | + </a> |
| 651 | + `; |
| 652 | + mobileFrag.appendChild(card); |
| 653 | + } |
600 | 654 | }); |
601 | 655 |
|
602 | 656 | // Toggle correct view |
603 | 657 | mobileList.style.display = isMobile ? "block" : "none"; |
604 | 658 |
|
605 | 659 | countSpan.textContent = nodes.length; |
606 | 660 |
|
607 | | - tbody.appendChild(tableFrag); |
608 | | - mobileList.appendChild(mobileFrag); |
| 661 | + if (shouldRenderTable) { |
| 662 | + tbody.appendChild(tableFrag); |
| 663 | + } else { |
| 664 | + mobileList.appendChild(mobileFrag); |
| 665 | + } |
609 | 666 | } |
610 | 667 |
|
611 | 668 | function clearFilters() { |
|
676 | 733 | keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : ""; |
677 | 734 | }); |
678 | 735 | } |
| 736 | + |
| 737 | + function setStatus(message) { |
| 738 | + if (!statusSpan) return; |
| 739 | + if (statusHideTimer) { |
| 740 | + clearTimeout(statusHideTimer); |
| 741 | + statusHideTimer = null; |
| 742 | + } |
| 743 | + |
| 744 | + if (message) { |
| 745 | + statusShownAt = Date.now(); |
| 746 | + console.log("[nodelist] status:", message); |
| 747 | + statusSpan.textContent = message; |
| 748 | + statusSpan.classList.add("active"); |
| 749 | + isBusy = true; |
| 750 | + return; |
| 751 | + } |
| 752 | + |
| 753 | + const elapsed = Date.now() - statusShownAt; |
| 754 | + const remaining = Math.max(0, minStatusMs - elapsed); |
| 755 | + if (remaining > 0) { |
| 756 | + statusHideTimer = setTimeout(() => { |
| 757 | + statusHideTimer = null; |
| 758 | + console.log("[nodelist] status: cleared"); |
| 759 | + statusSpan.textContent = ""; |
| 760 | + statusSpan.classList.remove("active"); |
| 761 | + isBusy = false; |
| 762 | + }, remaining); |
| 763 | + return; |
| 764 | + } |
| 765 | + |
| 766 | + console.log("[nodelist] status: cleared"); |
| 767 | + statusSpan.textContent = ""; |
| 768 | + statusSpan.classList.remove("active"); |
| 769 | + isBusy = false; |
| 770 | + } |
679 | 771 | }); |
680 | 772 | </script> |
681 | 773 | {% endblock %} |
0 commit comments