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 <main>,
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 <body>. 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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 01:57:06 -05:00
parent f13d15b4dd
commit f58255ab8a
10 changed files with 691 additions and 44 deletions

View File

@@ -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); } }

View File

@@ -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); });
})();

View File

@@ -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 <nav data-crumbs data-crumb-label="..."> holding its natural parents as links (what shows when
* the page is opened directly); every other page starts a new trail. Arriving from a page on the trail (by link
* or script, seen through the same-origin referrer) continues it from there; returning to a page on the trail
* or script, seen through the same-origin referrer, or told by nav.js through Crumbs.start) continues it from there; returning to a page on the trail
* (back, a crumb, reload) cuts it back to that page. Crumbs.label(text) renames the current page once its name
* has loaded. A link marked data-crumb-back points at the previous page on the trail.
*/
window.Crumbs = (function () {
var KEY = 'dash.trail', MAX = 7;
var here = window.location.pathname + window.location.search;
var nav = document.querySelector('[data-crumbs]');
var here, nav, trail;
function load() {
try { var t = JSON.parse(sessionStorage.getItem(KEY) || '[]'); return Array.isArray(t) ? t : []; } catch (e) { return []; }
}
@@ -395,29 +400,38 @@
var title = document.title.replace(/\s*-\s*DarkflameServer\s*$/, '').trim();
return !title || title === 'DarkflameServer' ? 'Home' : title;
}
var from = '';
try {
var ref = document.referrer ? new URL(document.referrer) : null;
if (ref && ref.origin === window.location.origin) from = ref.pathname + ref.search;
} catch (e) {}
var trail = load(), label = nav ? (nav.dataset.crumbLabel || titleLabel()) : titleLabel();
if (!nav) {
// Lists and other top-level pages start the trail
trail = [{ href: here, label: label }];
} else {
var at = indexOf(trail, here), came = from && from !== here ? indexOf(trail, from) : -1;
if (at >= 0) trail = trail.slice(0, at + 1);
else if (came >= 0) trail = trail.slice(0, came + 1).concat([{ href: here, label: label }]);
else {
// Opened directly or from outside the trail: the page's natural parents
trail = Array.prototype.map.call(nav.querySelectorAll('a[href]'), function (a) {
return { href: a.getAttribute('href'), label: a.textContent.trim() };
}).concat([{ href: here, label: label }]);
}
if (trail.length > MAX) trail = [trail[0]].concat(trail.slice(trail.length - MAX + 1));
function referrer() {
try {
var ref = document.referrer ? new URL(document.referrer) : null;
if (ref && ref.origin === window.location.origin) return ref.pathname + ref.search;
} catch (e) {}
return '';
}
// from: the page this one was reached from (path and query), for a page changed without a reload (nav.js)
function start(from) {
here = window.location.pathname + window.location.search;
nav = document.querySelector('[data-crumbs]');
trail = load();
var label = nav ? (nav.dataset.crumbLabel || titleLabel()) : titleLabel();
if (!nav) {
// Lists and other top-level pages start the trail
trail = [{ href: here, label: label }];
} else {
var at = indexOf(trail, here), came = from && from !== here ? indexOf(trail, from) : -1;
if (at >= 0) trail = trail.slice(0, at + 1);
else if (came >= 0) trail = trail.slice(0, came + 1).concat([{ href: here, label: label }]);
else {
// Opened directly or from outside the trail: the page's natural parents
trail = Array.prototype.map.call(nav.querySelectorAll('a[href]'), function (a) {
return { href: a.getAttribute('href'), label: a.textContent.trim() };
}).concat([{ href: here, label: label }]);
}
if (trail.length > MAX) trail = [trail[0]].concat(trail.slice(trail.length - MAX + 1));
}
save(trail);
render();
}
save(trail);
function render() {
if (!nav) return;
@@ -439,8 +453,9 @@
a.textContent = '← ' + prev.label;
});
}
render();
start(referrer());
return {
start: start,
label: function (text) {
if (!nav || !text) return;
trail[trail.length - 1].label = String(text);

View File

@@ -7,9 +7,10 @@
* - moderation_counts : moderation queue sizes for the sidebar badges (GM 3+)
*
* Pages call Live.watchTable(name, dataTable), Live.on(name, callback) or Live.refreshPage(names, id) to react to
* changes. Game servers report their writes as they happen (world -> master -> dashboard), and the dashboard also
* diffs a database snapshot for writers outside the game. After a reconnect every watcher is told to resync, since
* changes may have been missed while the socket was down.
* changes; what a page registers lasts until nav.js swaps in another page (Live.resetPage). Game servers report their
* writes as they happen (world -> master -> dashboard), and the dashboard also diffs a database snapshot for writers
* outside the game. After a reconnect every watcher is told to resync, since changes may have been missed while the
* socket was down.
*/
(function () {
'use strict';
@@ -25,6 +26,9 @@
var actionWaiters = {}; // requestId -> callback for actions started from this tab
var topicWatchers = {}; // extra topics a page asked for (e.g. player_positions) -> [callback]
var wasConnected = false;
var pageGeneration = 0; // goes up on every page change; callbacks of a page that's gone do nothing
// The restart countdown lives across page changes, so it keeps the browser's own timer (nav.js stops the page's)
var coreSetInterval = window.setInterval.bind(window);
function setIndicator(text, cls) {
var el = document.getElementById('live-indicator');
@@ -213,7 +217,7 @@
banner.textContent = 'Server restart in ' + text + (restart.reason ? ': ' + restart.reason : '') + (restart.by ? ' (scheduled by ' + restart.by + ')' : '');
}
tick();
restartTimer = setInterval(tick, 1000);
restartTimer = coreSetInterval(tick, 1000);
}
window.Live = {
@@ -229,7 +233,8 @@
},
// Reload a DataTable (keeping its page) whenever the named table changes
watchTable: function (table, dataTable) {
Live.on(table, throttle(function () { dataTable.ajax.reload(null, false); }, RELOAD_THROTTLE_MS));
var generation = pageGeneration;
Live.on(table, throttle(function () { if (generation === pageGeneration) dataTable.ajax.reload(null, false); }, RELOAD_THROTTLE_MS));
},
/**
* For server-rendered pages about one row: reload the page when that row changes in any of the given tables
@@ -237,7 +242,11 @@
*/
refreshPage: function (tables, id, options) {
options = options || {};
var refresh = throttle(function () { if (isBusy()) showStaleBanner(); else reloadKeepingScroll(); }, PAGE_REFRESH_MIN_MS);
var generation = pageGeneration;
var refresh = throttle(function () {
if (generation !== pageGeneration) return;
if (isBusy()) showStaleBanner(); else reloadKeepingScroll();
}, PAGE_REFRESH_MIN_MS);
[].concat(tables).forEach(function (table) {
Live.on(table, function (e) {
if (e.resync || options.anyRow || String(e.id) === String(id)) refresh();
@@ -246,6 +255,22 @@
},
throttle: throttle,
onStatus: function (cb) { statusWatchers.push(cb); },
// Forget what the page registered (nav.js, when it swaps in another page). Topics only it asked for are dropped.
resetPage: function () {
pageGeneration++;
tableWatchers = {};
statusWatchers = [];
Object.keys(topicWatchers).forEach(function (topic) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event: 'unsubscribe', subscription: topic }));
});
topicWatchers = {};
var stale = document.getElementById('live-stale');
if (stale) stale.remove();
},
// The sidebar's waiting counts again (nav.js, when the menu was rebuilt)
refreshBadges: function () {
if (DASH.can('moderate_names')) api.get('/api/moderation/counts').then(updateBadges).catch(function () {});
},
/**
* Wait for background work (a scan, a search) and fetch its result, including its data, which only the account
* that started it can read. Resolves with {success, message, data}. Keeps checking with a growing interval

View File

@@ -15,6 +15,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', 'decideTitle');
el.innerHTML =

View File

@@ -0,0 +1,505 @@
/**
* Page changes without reloading the whole dashboard.
*
* A click on a link to another dashboard page (or a GET form) fetches that page and swaps its <main>, title, page
* styles and page scripts in, keeping the sidebar, the top bar and the live WebSocket. Back, forward, deep links and
* #hash filters behave as before. Before a swap, what the old page set up is taken down: its document and window
* listeners, intervals, Live watchers, DataTables and anything it added to <body>. Page scripts then run again in
* order; DOMContentLoaded and load handlers they add run once they all have.
*
* Falls back to a normal page load when fetching fails on back/forward, the answer isn't a signed-in dashboard page,
* the permissions or user changed, or either page is one that has to load on its own: module scripts, an import map
* (the 3D views) or data-nav="reload" anywhere in it. A link with data-nav="off" always loads normally.
*
* Nav.go(url) opens a page from a script.
*/
(function () {
'use strict';
// ---- Rules (no DOM; tests/dWebTests/nav.test.mjs) ----
// Paths that are never swapped in: the API, files, the signed-out pages and the metrics
var NO_SWAP = /^\/(api|css|js|ws|metrics|login|logout|register|forgot_password|reset_password|verify_email|oauth2)(\/|$)|^\/status\/widget(\/|$)|\.[A-Za-z0-9]{1,5}$/;
var rules = {
/**
* How to follow a link from `here` to `target` (URL objects or {origin, protocol, pathname, search, hash}):
* 'swap' to fetch and swap it in, 'hash' when only the fragment of this page changes (left to the browser),
* null for a normal load.
*/
kind: function (target, here) {
if (target.origin !== here.origin || !/^https?:$/.test(target.protocol)) return null;
if (target.pathname === here.pathname && target.search === here.search && target.hash) return 'hash';
if (NO_SWAP.test(target.pathname)) return null;
return 'swap';
},
// The kind of page a path is, with its numbers folded: every property's 3D view is '/properties/*/3d'
pageKind: function (path) {
return String(path).replace(/\/\d+(?=\/|$)/g, '/*');
},
// Whether a <script type> is JavaScript that runs as a classic script (JSON data and modules aren't)
runnable: function (type) {
return !type || /^(text|application)\/(x-)?(java|ecma)script$/i.test(type.trim());
},
// Whether two lists of child keys ('DIV#id', '#text') line up one to one
sameKeys: function (a, b) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
};
window.NavRules = rules;
if (typeof document === 'undefined' || !window.fetch || !window.DOMParser || !window.history || !history.pushState) return;
var main = document.querySelector('main'), sidebar = document.getElementById('sidebar');
if (!main || !sidebar) return;
// ---- What the current page sets up, so it can be taken down ----
var nativeAdd = EventTarget.prototype.addEventListener, nativeRemove = EventTarget.prototype.removeEventListener;
var nativeSetInterval = window.setInterval, nativeClearInterval = window.clearInterval;
var nativeReplace = history.replaceState, nativePush = history.pushState;
var nativeFetch = window.fetch;
var page = newPage();
var runningScripts = false, readyQueue = [];
var keepInBody = Array.prototype.slice.call(document.body.children);
var inFlight = 0; // fetches of any kind, to know when a swapped-in page has loaded its data
function newPage() { return { listeners: [], intervals: [], hard: null }; }
function isJqueryHandle(target, fn) {
try { var data = window.jQuery && jQuery._data && jQuery._data(target); return !!(data && data.handle === fn); } catch (e) { return false; }
}
[document, window].forEach(function (target) {
target.addEventListener = function (type, fn, options) {
// Scripts run after a swap still wait for the document to be ready: run those handlers once they're all in
if (runningScripts && (type === 'DOMContentLoaded' || (type === 'load' && target === window))) {
readyQueue.push({ target: target, type: type, fn: fn });
return;
}
nativeAdd.call(target, type, fn, options);
// jQuery keeps one handler per element and event for all of its own; DataTables take theirs off themselves
if (fn && !isJqueryHandle(target, fn)) page.listeners.push([target, type, fn, options]);
};
});
window.setInterval = function () {
var id = nativeSetInterval.apply(window, arguments);
page.intervals.push(id);
return id;
};
window.fetch = function () {
inFlight++;
return nativeFetch.apply(window, arguments).finally(function () { inFlight--; });
};
// ---- History ----
var rendered = location.pathname + location.search; // the page shown now
var scrolls = {}; // history entry key -> scroll position
var entryKey = 0;
function newKey() { return Date.now().toString(36) + '.' + (++entryKey); }
function state(extra) {
var s = {};
var current = history.state;
if (current && typeof current === 'object') for (var k in current) s[k] = current[k];
if (!s.dashNav) s.dashNav = newKey();
if (extra) for (var e in extra) s[e] = extra[e];
return s;
}
// Pages set the fragment with replaceState(null, ...): keep this entry's key so back and forward still find it
history.replaceState = function (s, title, url) {
nativeReplace.call(history, s === null || s === undefined ? state() : s, title, url);
rendered = location.pathname + location.search;
};
history.pushState = function (s, title, url) {
nativePush.call(history, s, title, url);
rendered = location.pathname + location.search;
};
nativeReplace.call(history, state(), '');
// Scroll positions are put back by this file, after the page's content is in (the browser would do it too early).
// For a reload they're kept in sessionStorage.
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
var SCROLL_KEY = 'dash.scroll';
try {
var savedScrolls = JSON.parse(sessionStorage.getItem(SCROLL_KEY) || '{}') || {};
for (var k in savedScrolls) scrolls[k] = savedScrolls[k];
} catch (e) { /* storage unavailable */ }
var startKey = history.state.dashNav;
if (typeof scrolls[startKey] === 'number') {
nativeAdd.call(window, 'load', function () {
var y = scrolls[startKey];
if (window.scrollY !== 0) return; // something else scrolled already (Live's own restore, a #fragment)
window.scrollTo(0, y);
var landed = window.scrollY;
whenLoaded(function () { if (window.scrollY === landed) window.scrollTo(0, y); });
});
}
nativeAdd.call(window, 'pagehide', function () {
if (history.state && history.state.dashNav) scrolls[history.state.dashNav] = window.scrollY;
var keys = Object.keys(scrolls).slice(-50), keep = {};
keys.forEach(function (key) { keep[key] = scrolls[key]; });
try { sessionStorage.setItem(SCROLL_KEY, JSON.stringify(keep)); } catch (e) { /* storage unavailable */ }
});
var scrollTimer = null;
nativeAdd.call(window, 'scroll', function () {
if (scrollTimer) return;
scrollTimer = setTimeout(function () {
scrollTimer = null;
if (history.state && history.state.dashNav) scrolls[history.state.dashNav] = window.scrollY;
}, 100);
}, { passive: true });
// ---- Loading state and errors ----
var progress = document.createElement('div');
progress.className = 'nav-progress';
progress.setAttribute('aria-hidden', 'true');
progress.dataset.navKeep = '';
document.body.appendChild(progress);
keepInBody.push(progress);
var loadingTimer = null;
function setLoading(on) {
clearTimeout(loadingTimer);
if (on) loadingTimer = setTimeout(function () { document.documentElement.classList.add('nav-loading'); main.setAttribute('aria-busy', 'true'); }, 120);
else { document.documentElement.classList.remove('nav-loading'); main.removeAttribute('aria-busy'); }
}
function showError(url, message) {
var old = document.getElementById('nav-error');
if (old) old.remove();
var box = document.createElement('div');
box.id = 'nav-error';
box.className = 'alert alert-danger d-flex flex-wrap align-items-center gap-2';
box.setAttribute('role', 'alert');
var text = document.createElement('span');
text.className = 'me-auto';
text.textContent = message;
var retry = document.createElement('button');
retry.type = 'button';
retry.className = 'btn btn-sm btn-light';
retry.textContent = 'Try again';
retry.addEventListener('click', function () { box.remove(); go(url); });
var plain = document.createElement('a');
plain.className = 'btn btn-sm btn-outline-light';
plain.href = url;
plain.dataset.nav = 'off';
plain.textContent = 'Open it normally';
box.append(text, retry, plain);
main.insertBefore(box, main.firstChild);
window.scrollTo(0, 0);
}
// ---- Reading a fetched page ----
function isHard(doc) {
return !!doc.querySelector('script[type="module"], script[type="importmap"], [data-nav="reload"]');
}
function currentIsHard() {
if (page.hard === null) page.hard = isHard(document);
return page.hard;
}
// Why a fetched page can't be swapped in (a normal load then), or '' when it can
function unusable(doc) {
if (!doc.querySelector('main') || !doc.getElementById('sidebar') || !doc.getElementById('page-scripts')) return 'not a dashboard page';
var a = document.body, b = doc.body;
if (a.dataset.username !== b.dataset.username || a.dataset.can !== b.dataset.can || a.dataset.gmLevel !== b.dataset.gmLevel) return 'the account changed';
if (isHard(doc)) return 'loads on its own';
return '';
}
function fetchPage(url, signal) {
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' }, signal: signal }).then(function (r) {
if (r.status === 401) { window.location.href = '/login'; throw new Error('Not signed in'); }
var type = r.headers.get('Content-Type') || '';
if (type.indexOf('text/html') === -1) return { url: r.url || url, doc: null };
return r.text().then(function (html) {
return { url: r.url || url, doc: new DOMParser().parseFromString(html, 'text/html') };
});
});
}
// ---- Taking the old page down ----
function leave() {
try { document.dispatchEvent(new CustomEvent('dash:leave')); } catch (e) { console.error(e); }
if (window.Live && Live.resetPage) Live.resetPage();
page.listeners.forEach(function (l) { nativeRemove.call(l[0], l[1], l[2], l[3]); });
page.intervals.forEach(function (id) { nativeClearInterval.call(window, id); });
if (window.jQuery && jQuery.fn.dataTable) {
try { jQuery.fn.dataTable.tables({ api: true }).destroy(); } catch (e) { console.error(e); }
}
// Dialogs: the page's go with it; shared ones (Decide, the AI helper) are closed
document.querySelectorAll('.modal.show').forEach(function (el) {
var modal = window.bootstrap && bootstrap.Modal.getInstance(el);
if (el.hasAttribute('data-nav-keep')) { if (modal) modal.hide(); return; }
if (modal) modal.dispose();
});
document.querySelectorAll('.modal-backdrop, .tooltip, .popover').forEach(function (el) { el.remove(); });
if (!document.querySelector('.modal.show[data-nav-keep]')) {
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
}
Array.prototype.slice.call(document.body.children).forEach(function (el) {
if (keepInBody.indexOf(el) !== -1 || el.id === 'page-scripts' || el.id === 'toast-container' || el.hasAttribute('data-nav-keep') || el.tagName === 'SCRIPT') return;
el.remove();
});
page = newPage();
}
// ---- Page styles (between the page-head markers in base.jinja2) ----
function pageHead(doc) {
var start = doc.querySelector('meta[name="page-head-start"]'), end = doc.querySelector('meta[name="page-head-end"]');
var nodes = [];
for (var n = start && start.nextSibling; n && n !== end; n = n.nextSibling) if (n.nodeType === 1) nodes.push(n);
return nodes;
}
// Put the new page's styles in (ready once its style sheets have loaded) and say which old ones to take out after
function prepareHead(doc) {
var old = pageHead(document), end = document.querySelector('meta[name="page-head-end"]');
var keep = [], loads = [];
pageHead(doc).forEach(function (n) {
var same = old.filter(function (o) { return keep.indexOf(o) === -1 && o.outerHTML === n.outerHTML; })[0];
if (same) { keep.push(same); return; }
var el = document.importNode(n, true);
if (el.tagName === 'LINK' && /stylesheet/i.test(el.rel)) {
loads.push(new Promise(function (resolve) {
el.addEventListener('load', resolve);
el.addEventListener('error', resolve);
setTimeout(resolve, 1500);
}));
}
end.parentNode.insertBefore(el, end);
});
return { ready: Promise.all(loads), stale: old.filter(function (o) { return keep.indexOf(o) === -1; }) };
}
// ---- The sidebar ----
function links(nav) {
return Array.prototype.map.call(nav.querySelectorAll('a[href]'), function (a) { return a.getAttribute('href'); });
}
function updateSidebar(doc) {
var next = doc.getElementById('sidebar');
if (!rules.sameKeys(links(sidebar), links(next))) {
// Other pages are allowed now: the server's menu, with its groups as the user left them
sidebar.replaceChildren.apply(sidebar, Array.prototype.map.call(next.childNodes, function (n) { return document.importNode(n, true); }));
if (window.Live && Live.refreshBadges) Live.refreshBadges();
} else {
var mine = sidebar.querySelectorAll('a[href]'), theirs = next.querySelectorAll('a[href]');
for (var i = 0; i < mine.length; i++) {
var active = theirs[i].classList.contains('active');
mine[i].classList.toggle('active', active);
if (active) mine[i].setAttribute('aria-current', 'page'); else mine[i].removeAttribute('aria-current');
}
}
if (window.Sidebar) Sidebar.apply();
var offcanvas = window.bootstrap && bootstrap.Offcanvas.getInstance(sidebar);
if (offcanvas) offcanvas.hide();
}
// ---- Swapping a page in ----
function scriptsIn(root) {
return Array.prototype.filter.call(root.querySelectorAll('script'), function (s) { return rules.runnable(s.getAttribute('type')); });
}
function runScripts(list) {
runningScripts = true;
return list.reduce(function (done, old) {
return done.then(function () {
if (!old.parentNode) return;
var s = document.createElement('script');
Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); });
if (!old.src) {
s.text = old.text;
old.parentNode.replaceChild(s, old);
return;
}
s.async = false;
return new Promise(function (resolve) {
s.addEventListener('load', resolve);
s.addEventListener('error', resolve);
old.parentNode.replaceChild(s, old);
});
});
}, Promise.resolve()).then(function () {
runningScripts = false;
var queue = readyQueue;
readyQueue = [];
queue.forEach(function (q) {
try { q.fn.call(q.target, new Event(q.type)); } catch (e) { console.error(e); }
});
}, function (e) { runningScripts = false; readyQueue = []; throw e; });
}
// Once the page's first requests are done (or after a few seconds): scroll again, the content has its height now
function whenLoaded(fn) {
var until = Date.now() + 3000;
(function check() {
var busy = inFlight > 0 || (window.jQuery && jQuery.active > 0);
if (busy && Date.now() < until) setTimeout(check, 100); else fn();
})();
}
/**
* Swap the fetched page in. options: push (a new history entry) or replace, from (the page it was reached from),
* scroll (restore to this position).
*/
function swap(doc, url, options) {
var head = prepareHead(doc);
return head.ready.then(function () {
leave();
head.stale.forEach(function (n) { n.remove(); });
if (options.push) {
if (history.state && history.state.dashNav) scrolls[history.state.dashNav] = window.scrollY;
nativePush.call(history, { dashNav: newKey() }, '', url);
} else if (options.replace) nativeReplace.call(history, state(), '', url);
rendered = location.pathname + location.search;
document.title = doc.title;
updateSidebar(doc);
var banner = document.getElementById('restart-banner');
if (banner) banner.remove();
var nextMain = doc.querySelector('main');
var nodes = Array.prototype.slice.call(nextMain.childNodes).map(function (n) { return document.adoptNode(n); });
main.replaceChildren.apply(main, nodes);
if (banner) main.insertBefore(banner, main.firstChild);
var scripts = document.getElementById('page-scripts');
scripts.replaceChildren.apply(scripts, Array.prototype.slice.call(doc.getElementById('page-scripts').childNodes).map(function (n) { return document.adoptNode(n); }));
if (window.Prefs && Prefs.apply) Prefs.apply(main);
if (window.Crumbs && Crumbs.start) Crumbs.start(options.from || '');
var hash = new URL(url, location.href).hash;
var target = hash ? document.getElementById(decodeURIComponent(hash.slice(1))) : null;
var y = options.scroll, landed = 0;
if (typeof y === 'number') {
// The page gets its full height once its data is in: hold enough height until then to scroll back to y
main.style.minHeight = Math.max(0, y + window.innerHeight - (main.getBoundingClientRect().top + window.scrollY)) + 'px';
window.scrollTo(0, y);
landed = window.scrollY;
} else if (target) target.scrollIntoView();
else window.scrollTo(0, 0);
main.focus({ preventScroll: true });
return runScripts(scriptsIn(main).concat(scriptsIn(scripts))).then(function () {
if (window.fitLayout) fitLayout();
try { document.dispatchEvent(new CustomEvent('dash:page')); } catch (e) { console.error(e); }
if (typeof y === 'number') {
whenLoaded(function () {
if (window.scrollY === landed) window.scrollTo(0, y); // unless the user scrolled meanwhile
main.style.minHeight = '';
});
}
});
});
}
// ---- Leaving a page with unsaved changes ----
function confirmLeave() {
var asked = false;
page.listeners.forEach(function (l) {
if (asked || l[0] !== window || l[1] !== 'beforeunload') return;
var e = { type: 'beforeunload', defaultPrevented: false, returnValue: '', preventDefault: function () { this.defaultPrevented = true; } };
try {
var r = (typeof l[2] === 'function' ? l[2] : l[2].handleEvent).call(window, e);
if (e.defaultPrevented || e.returnValue || (typeof r === 'string' && r)) asked = true;
} catch (err) { /* ignore */ }
});
if (typeof window.onbeforeunload === 'function') {
var ev = { returnValue: '', preventDefault: function () { ev.returnValue = 'x'; } };
try { if (window.onbeforeunload(ev) || ev.returnValue) asked = true; } catch (err) { /* ignore */ }
}
return !asked || window.confirm('Leave this page? Changes you made may not be saved.');
}
// ---- Going somewhere ----
var sequence = 0, controller = null;
var loadsOnItsOwn = {}; // kinds of page found to need a normal load, so they aren't fetched twice
/**
* Open url. options: pop (back/forward: the address already changed), replace (no new history entry),
* scroll (where to scroll to afterwards).
*/
function go(url, options) {
options = options || {};
url = new URL(url, location.href).href;
var kind = rules.kind(new URL(url), location);
if (kind === 'hash' && !options.pop) { location.assign(url); return Promise.resolve(); }
if (currentIsHard() || kind === null || loadsOnItsOwn[rules.pageKind(new URL(url).pathname)]) {
if (options.pop || options.replace) location.replace(url); else location.assign(url);
return Promise.resolve();
}
if (!confirmLeave()) {
// Back/forward already changed the address: put this page's back
if (options.pop) nativePush.call(history, { dashNav: newKey() }, '', rendered);
return Promise.resolve();
}
var id = ++sequence;
if (controller) controller.abort();
controller = window.AbortController ? new AbortController() : null;
var from = rendered;
setLoading(true);
var old = document.getElementById('nav-error');
if (old) old.remove();
return fetchPage(url, controller && controller.signal).then(function (r) {
if (id !== sequence) return;
// fetch drops the fragment; keep the one asked for when there was no redirect elsewhere
var target = new URL(r.url);
var asked = new URL(url);
if (!target.hash && target.pathname === asked.pathname && target.search === asked.search) target.hash = asked.hash;
var why = r.doc ? unusable(r.doc) : 'not a page';
if (why) {
if (why === 'loads on its own') loadsOnItsOwn[rules.pageKind(target.pathname)] = true;
if (options.pop) location.replace(target.href); else location.assign(target.href);
return;
}
return swap(r.doc, target.href, { push: !options.pop && !options.replace, replace: !!options.replace || !!options.pop, from: from, scroll: options.scroll });
}).catch(function (e) {
if (id !== sequence || (e && e.name === 'AbortError')) return;
if (options.pop) { location.replace(url); return; }
console.error(e);
showError(url, 'Couldn\'t open that page' + (navigator.onLine === false ? ': you\'re offline.' : '. The server may be restarting.'));
}).finally(function () {
if (id === sequence) setLoading(false);
});
}
window.Nav = { go: go };
// ---- Links, forms, back and forward ----
nativeAdd.call(window, 'click', function (e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target.closest && e.target.closest('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download') || a.getAttribute('data-nav') === 'off' || a.hasAttribute('data-bs-toggle')) return;
var url;
try { url = new URL(a.href, location.href); } catch (err) { return; }
if (rules.kind(url, location) !== 'swap') return;
e.preventDefault();
// The page that is open already: shown again, without a second history entry
go(url.href, { replace: url.href === location.href });
});
nativeAdd.call(window, 'submit', function (e) {
var form = e.target;
if (e.defaultPrevented || !form || form.tagName !== 'FORM' || (form.getAttribute('method') || 'get').toLowerCase() !== 'get') return;
if ((form.target && form.target !== '_self') || form.getAttribute('data-nav') === 'off') return;
var url = new URL(form.getAttribute('action') || location.pathname, location.href);
var data;
try { data = new FormData(form, e.submitter); } catch (err) { data = new FormData(form); }
url.search = new URLSearchParams(data).toString();
url.hash = '';
if (rules.kind(url, location) !== 'swap') return;
e.preventDefault();
go(url.href);
});
nativeAdd.call(window, 'popstate', function (e) {
if (currentIsHard()) return; // its own history entries (the UGC page)
if (location.pathname + location.search === rendered) return; // only the fragment changed
var key = e.state && e.state.dashNav;
go(location.href, { pop: true, scroll: key && key in scrolls ? scrolls[key] : undefined });
});
})();

View File

@@ -10,7 +10,10 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link href="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.css" rel="stylesheet" integrity="sha384-XMNDGLb5fN9IqhIrVXOAtGKcz4KCr+JSHXGZ1TDXQPDukbEAfmLPjHdCXhgK93fv" crossorigin="anonymous">
<link rel="stylesheet" href="/css/dashboard.css">
{# A page's own styles: nav.js swaps what is between these two when it changes pages without a reload #}
<meta name="page-head-start">
{% block css %}{% endblock %}
<meta name="page-head-end">
</head>
<body data-username="{{ username }}" data-gm-level="{{ gmLevel }}" data-account-id="{{ accountId }}" data-prefs="{{ prefs }}" data-labels="{{ labelsJson }}" data-can="{% for key, allowed in can %}{% if allowed %}{{ key }} {% endif %}{% endfor %}">
{% if username and username != "" %}
@@ -37,7 +40,7 @@
</ul>
</div>
</header>
<main class="flex-grow-1 p-2 p-sm-3 p-lg-4">
<main class="flex-grow-1 p-2 p-sm-3 p-lg-4" tabindex="-1">
{% else if exists("public_page") %}
{# Pages open to visitors who aren't signed in (public_page: {name, status, showcase}) show their normal content under a small top bar #}
<div class="d-flex flex-column min-vh-100">
@@ -67,8 +70,12 @@
<script src="/js/dashboard.js"></script>
<script src="/js/decide.js"></script>
<script src="/js/ai-suggest.js"></script>
<script src="/js/nav.js"></script>
{% endif %}
{# The page's own scripts: nav.js runs these again when it swaps a page in #}
<div id="page-scripts" hidden>
{% block scripts %}{% endblock %}
</div>
</body>
</html>

View File

@@ -363,6 +363,19 @@ Pages update on their own: world servers tell the dashboard (through master) as
and it pushes that to open browsers. Pages about one thing (a character, an account) reload in place when it changes,
unless you're typing, in which case they offer a refresh instead.
Moving between pages doesn't reload the dashboard: the next page is fetched and swapped in, and the menu, the top bar
and the live connection stay. A thin bar at the top shows while it loads; if it can't be fetched, the page says so with
**Try again** and **Open it normally**. Back, forward, reloading, links opened in a new tab and links with a `#` filter
work as before. Pages with a 3D view (World 3D, a property and its 3D view, the UGC server page) and pages reached from
them load normally. Leaving a page with unsaved changes (Settings, Vanity) asks first.
For page scripts (`static/js/nav.js`): listeners a page adds to `document` or `window`, its `setInterval` timers, its
`Live` watchers and its DataTables are removed when another page is swapped in; its scripts run again when it's opened
again, and `DOMContentLoaded`/`load` handlers they add run once they have all run. Elements a page appends to `<body>`
are removed unless marked `data-nav-keep`. `Nav.go(url)` opens a page. `document` gets `dash:page` after a page is
swapped in and `dash:leave` before. A page that must always load on its own adds `data-nav="reload"` to any element; a link with `data-nav="off"`
always loads normally.
View choices you make on the pages (show staff, filters, the 3D viewer's switches, ...) are saved to your account, so
they follow you to other browsers.

View File

@@ -94,4 +94,7 @@ if(NODE_EXECUTABLE)
# The sidebar's saved group state (the script at the end of templates/header.jinja2)
add_test(NAME SidebarStateJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/sidebar-state.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/templates/header.jinja2")
# Which links the dashboard follows without a reload (static/js/nav.js)
add_test(NAME NavRulesJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/nav.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/nav.js")
endif()

View File

@@ -0,0 +1,65 @@
// Which links the dashboard follows without a reload (static/js/nav.js).
// Run by ctest: node nav.test.mjs <nav.js>
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const [navPath] = process.argv.slice(2);
const window = {};
vm.runInNewContext(readFileSync(navPath, 'utf8'), { window });
const R = window.NavRules;
let failures = 0;
const same = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures++;
console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
}
};
const here = new URL('http://127.0.0.1:2006/accounts?x=1');
const kind = (href) => R.kind(new URL(href, here), here);
// Dashboard pages are swapped in
same(kind('/characters'), 'swap', 'a list page');
same(kind('/characters/1234'), 'swap', 'a detail page');
same(kind('/properties/5/3d'), 'swap', 'the 3D view (the fetched page then asks for a normal load)');
same(kind('/system_log?server=world'), 'swap', 'a page with a query');
same(kind('/activity_log#search=12'), 'swap', 'another page with a hash filter');
same(kind('/accounts'), 'swap', 'the same page without its query');
same(kind('/accounts?x=1'), 'swap', 'the page that is open, again');
same(kind('/'), 'swap', 'home');
same(kind('/status'), 'swap', 'server status');
// Only the fragment of this page: the browser does it
same(kind('/accounts?x=1#tab'), 'hash', 'fragment of this page');
same(kind('#tab'), 'hash', 'bare fragment');
// Normal loads
same(kind('https://example.com/characters'), null, 'another site');
same(kind('http://127.0.0.1:2007/characters'), null, 'another port');
same(kind('mailto:a@b.c'), null, 'mailto');
for (const path of ['/api/backups/1/download', '/css/dashboard.css', '/js/nav.js', '/ws', '/metrics', '/login', '/logout',
'/register', '/forgot_password', '/reset_password?token=1', '/verify_email', '/oauth2/callback', '/status/widget', '/ugc/model.nif', '/files/log.zip']) {
same(kind(path), null, path);
}
same(kind('/logins'), 'swap', 'a path that only starts like a signed-out page');
// Pages of one kind share what was learnt about them
same(R.pageKind('/properties/12/3d'), '/properties/*/3d', 'a 3D view');
same(R.pageKind('/characters/1152921504606846978'), '/characters/*', 'a character');
same(R.pageKind('/world3d'), '/world3d', 'no numbers');
// Scripts: classic JavaScript runs again, data and modules don't
for (const type of [null, '', 'text/javascript', 'application/javascript', 'TEXT/JavaScript ', 'application/x-javascript', 'text/ecmascript']) same(R.runnable(type), true, 'runs: ' + type);
for (const type of ['application/json', 'module', 'importmap', 'text/template']) same(R.runnable(type), false, 'inert: ' + type);
// Child lists line up only when every key does
same(R.sameKeys(['DIV#a', '#3', 'P'], ['DIV#a', '#3', 'P']), true, 'same keys');
same(R.sameKeys(['DIV#a', 'P'], ['DIV#a', '#3', 'P']), false, 'one more');
same(R.sameKeys(['DIV#a'], ['DIV#b']), false, 'other id');
same(R.sameKeys([], []), true, 'empty');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('nav rules: all passed');