From f58255ab8aa134891231331707afddd8984a0f53 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 01:57:06 -0500 Subject: [PATCH] feat(dashboard): change pages without reloading the dashboard nav.js (loaded on every signed-in page) follows links and GET forms to other dashboard pages by fetching the page and swapping in its
, title, page styles and page scripts; the sidebar, top bar and live WebSocket stay. History entries, back/forward (with scroll positions, also kept for a reload), deep links, #hash filters and breadcrumbs keep working. What the old page set up is taken down first: its document/window listeners, setInterval timers, Live watchers and topics, DataTables, dialogs and what it appended to . Page scripts run again in order; DOMContentLoaded/load handlers they add run once they have all run. A thin bar shows while loading; a failed fetch shows an inline error with Try again / Open it normally. Falls back to a normal load for anything that isn't a signed-in dashboard page, when the account or permissions changed, and for pages with module scripts or an import map (World 3D, property view and 3D, UGC server) or data-nav="reload", and when leaving those. Unsaved-change prompts (beforeunload) are asked before swapping. Check: click around the sidebar and into accounts/characters: no white flash, the footer's live indicator stays "live", back/forward return to the same scroll position, breadcrumbs follow the path (Accounts > an account > a character). Activity Log links with #search= still filter. System Log's server picker works. Leaving Settings with a change asks first. World 3D and a property's 3D view still load (normally). Pages that poll (Server Health, Instance Load) stop polling once left (browser network tab). Test: NavRulesJs. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/static/css/dashboard.css | 11 + dDashboardServer/static/js/ai-suggest.js | 4 +- dDashboardServer/static/js/common.js | 87 ++-- dDashboardServer/static/js/dashboard.js | 37 +- dDashboardServer/static/js/decide.js | 1 + dDashboardServer/static/js/nav.js | 505 ++++++++++++++++++++++ dDashboardServer/templates/base.jinja2 | 9 +- docs/Dashboard.md | 13 + tests/dWebTests/CMakeLists.txt | 3 + tests/dWebTests/nav.test.mjs | 65 +++ 10 files changed, 691 insertions(+), 44 deletions(-) create mode 100644 dDashboardServer/static/js/nav.js create mode 100644 tests/dWebTests/nav.test.mjs diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css index 9e9c0fdc2..2826314f5 100644 --- a/dDashboardServer/static/css/dashboard.css +++ b/dDashboardServer/static/css/dashboard.css @@ -238,3 +238,14 @@ details.about-text > :not(summary) { color: var(--bs-secondary-color); font-size @media (min-width: 992px) { html.sidebar-hidden nav.sidebar { display: none !important; } } + +/* Changing pages without a reload (nav.js): a thin bar at the top while the next page loads */ +main:focus { outline: none; } +.nav-progress { + position: fixed; top: 0; left: 0; z-index: 1100; width: 100%; height: 3px; + background: var(--bs-primary); transform-origin: left; transform: scaleX(0); opacity: 0; pointer-events: none; +} +html.nav-loading .nav-progress { opacity: 1; animation: nav-progress 10s cubic-bezier(.08, .7, .2, 1) forwards; } +html.nav-loading main { opacity: .65; transition: opacity .2s; } +@keyframes nav-progress { from { transform: scaleX(.08); } to { transform: scaleX(.92); } } +@media (prefers-reduced-motion: reduce) { html.nav-loading .nav-progress { animation: none; transform: scaleX(.5); } } diff --git a/dDashboardServer/static/js/ai-suggest.js b/dDashboardServer/static/js/ai-suggest.js index 618c9f619..6c71310fd 100644 --- a/dDashboardServer/static/js/ai-suggest.js +++ b/dDashboardServer/static/js/ai-suggest.js @@ -49,6 +49,7 @@ function build() { el = document.createElement('div'); el.className = 'modal fade'; + el.dataset.navKeep = ''; // shared by every page: kept when nav.js swaps pages el.tabIndex = -1; el.setAttribute('aria-labelledby', 'aiSuggestTitle'); el.innerHTML = @@ -322,7 +323,8 @@ } }); } - // After the page's own scripts have set up their dialogs + // After the page's own scripts have set up their dialogs, and again for every page nav.js swaps in if (document.readyState === 'complete') setTimeout(start, 0); else window.addEventListener('load', function () { setTimeout(start, 0); }); + document.addEventListener('dash:page', function () { setTimeout(start, 0); }); })(); diff --git a/dDashboardServer/static/js/common.js b/dDashboardServer/static/js/common.js index 568342582..6efe1c9a9 100644 --- a/dDashboardServer/static/js/common.js +++ b/dDashboardServer/static/js/common.js @@ -295,7 +295,8 @@ else if (input.type === 'radio') input.checked = input.value === String(value); else if (input.tagName !== 'SELECT' || input.querySelector('option[value="' + CSS.escape(String(value)) + '"]')) input.value = value; } - document.querySelectorAll('[data-pref]').forEach(apply); + function applyAll(root) { (root || document).querySelectorAll('[data-pref]').forEach(apply); } + applyAll(); document.addEventListener('change', function (e) { var input = e.target.closest && e.target.closest('[data-pref]'); if (input) set(input.dataset.pref, valueOf(input)); @@ -311,7 +312,9 @@ }); return { get: function (name, fallback) { return name in saved ? saved[name] : fallback; }, - set: set + set: set, + // Set the inputs (and folding cards) under root as they were left: for content put in without a reload (nav.js) + apply: function (root) { applyAll(root); applyOpen(root); } }; })(); @@ -362,15 +365,18 @@ /** * A .collapse marked data-pref-open="page.name" (a folding card's body) opens or stays folded as it was last left. */ - document.querySelectorAll('.collapse[data-pref-open]').forEach(function (el) { - var open = Prefs.get(el.dataset.prefOpen, null); - if (open === null) return; - el.classList.toggle('show', !!open); - document.querySelectorAll('[data-bs-target="#' + CSS.escape(el.id) + '"]').forEach(function (b) { - b.classList.toggle('collapsed', !open); - b.setAttribute('aria-expanded', open ? 'true' : 'false'); + function applyOpen(root) { + (root || document).querySelectorAll('.collapse[data-pref-open]').forEach(function (el) { + var open = Prefs.get(el.dataset.prefOpen, null); + if (open === null) return; + el.classList.toggle('show', !!open); + document.querySelectorAll('[data-bs-target="#' + CSS.escape(el.id) + '"]').forEach(function (b) { + b.classList.toggle('collapsed', !open); + b.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); }); - }); + } + applyOpen(); document.addEventListener('shown.bs.collapse', function (e) { if (e.target.dataset.prefOpen) Prefs.set(e.target.dataset.prefOpen, true); }); document.addEventListener('hidden.bs.collapse', function (e) { if (e.target.dataset.prefOpen) Prefs.set(e.target.dataset.prefOpen, false); }); @@ -378,14 +384,13 @@ * Breadcrumbs that follow how you actually got to a page, kept per browser tab (sessionStorage). * A detail page has