mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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:
@@ -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); } }
|
||||
|
||||
@@ -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); });
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
505
dDashboardServer/static/js/nav.js
Normal file
505
dDashboardServer/static/js/nav.js
Normal 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 });
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
65
tests/dWebTests/nav.test.mjs
Normal file
65
tests/dWebTests/nav.test.mjs
Normal 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');
|
||||
Reference in New Issue
Block a user