mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): the sidebar keeps its open groups and can be hidden
The menu's groups stay open or closed from page to page (localStorage "dash.sidebar", this browser only), applied by an inline script right after the menu so the first paint already shows them. The group of the current page opens and stays open until it is closed. A new top bar button hides the menu on wide screens; that choice is applied in <head>, also before the first paint. Check: open and close a few menu groups, switch pages and reload; the groups stay as left, with no flicker. On a wide window the menu button left of the user menu hides the menu, and it stays hidden across pages and reloads. Narrow screens: the Menu button still slides the menu out. Test: SidebarStateJs (ctest, needs node). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -233,3 +233,8 @@ details.about-text > summary::before {
|
||||
details.about-text > summary:hover { color: var(--bs-body-color); }
|
||||
details.about-text[open] > summary { margin-bottom: .35rem; }
|
||||
details.about-text > :not(summary) { color: var(--bs-secondary-color); font-size: .875rem; }
|
||||
|
||||
/* Wide screens: the menu can be hidden (the top bar's menu button; header.jinja2 keeps the choice) */
|
||||
@media (min-width: 992px) {
|
||||
html.sidebar-hidden nav.sidebar { display: none !important; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DarkflameServer{% endblock %}</title>
|
||||
<link rel="icon" href="data:,">
|
||||
{# The menu hidden on wide screens stays hidden from page to page (header.jinja2), set before the first paint #}
|
||||
<script>try { if ((JSON.parse(localStorage.getItem('dash.sidebar') || '{}') || {}).hidden) document.documentElement.classList.add('sidebar-hidden'); } catch (e) {}</script>
|
||||
<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">
|
||||
@@ -17,6 +19,7 @@
|
||||
<div class="flex-grow-1 d-flex flex-column min-vh-100 main-column">
|
||||
<header class="topbar d-flex align-items-center gap-2 border-bottom px-3 py-2 sticky-top bg-body-tertiary">
|
||||
<button class="btn btn-sm btn-outline-secondary d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebar" aria-controls="sidebar">☰ Menu</button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none d-lg-inline-block sidebar-toggle" type="button" data-sidebar-toggle aria-controls="sidebar" title="Show or hide the menu"><span aria-hidden="true">☰</span><span class="visually-hidden">Show or hide the menu</span></button>
|
||||
<a href="/" class="fw-semibold text-body text-decoration-none d-lg-none">DarkflameServer</a>
|
||||
<div class="dropdown ms-auto">
|
||||
<button class="btn btn-sm btn-link text-body text-decoration-none dropdown-toggle d-flex align-items-center gap-2" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{# Sidebar navigation. Entries show only with the permission their page needs (see Permissions.cpp). #}
|
||||
{# Grouped by what staff are doing: the most used pages always show, the rest sit in groups that fold away (the group of #}
|
||||
{# the current page starts open). Inbox holds what players sent in and staff still have to answer. #}
|
||||
{# On narrow screens this is a slide-out menu opened from the top bar in base.jinja2 #}
|
||||
{# On narrow screens this is a slide-out menu opened from the top bar in base.jinja2. Which groups are open, and whether #}
|
||||
{# the menu is hidden on wide screens, is kept in this browser (the script at the end, and the <head> of base.jinja2) #}
|
||||
<nav class="offcanvas-lg offcanvas-start d-flex flex-column flex-shrink-0 px-2 py-3 border-end sidebar" id="sidebar" tabindex="-1" aria-label="Navigation">
|
||||
<div class="d-flex align-items-center justify-content-between px-2 mb-3">
|
||||
<a href="/" class="text-decoration-none text-body"><span class="fs-5 fw-semibold">DarkflameServer</span></a>
|
||||
@@ -155,3 +156,57 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<script data-sidebar-state>
|
||||
/*
|
||||
* The sidebar's groups open or closed as they were last left, in this browser (localStorage "dash.sidebar":
|
||||
* {groups: {id: open}, hidden}). Runs right after the menu is parsed, before the first paint, so it never shows the
|
||||
* server's default first. The group holding the current page always opens, and stays open on the next page until it
|
||||
* is closed. Sidebar.apply() does it again after the page changes without a reload (nav.js); Sidebar.setHidden()
|
||||
* hides the menu on wide screens.
|
||||
*/
|
||||
window.Sidebar = (function () {
|
||||
var KEY = 'dash.sidebar';
|
||||
function load() {
|
||||
try { var s = JSON.parse(localStorage.getItem(KEY) || '{}'); return s && typeof s === 'object' ? s : {}; } catch (e) { return {}; }
|
||||
}
|
||||
function save(state) { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* storage unavailable */ } }
|
||||
function setOpen(el, open) {
|
||||
el.classList.toggle('show', open);
|
||||
document.querySelectorAll('[data-bs-target="#' + el.id + '"]').forEach(function (b) {
|
||||
b.classList.toggle('collapsed', !open);
|
||||
b.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
function apply() {
|
||||
var state = load();
|
||||
var groups = state.groups = state.groups || {};
|
||||
document.querySelectorAll('#sidebar .nav-group > .collapse[id]').forEach(function (el) {
|
||||
var open = (el.id in groups ? !!groups[el.id] : el.classList.contains('show')) || !!el.querySelector('.active');
|
||||
setOpen(el, open);
|
||||
groups[el.id] = open;
|
||||
});
|
||||
save(state);
|
||||
}
|
||||
function remember(e) {
|
||||
var el = e.target;
|
||||
if (!el.id || !el.closest || !el.closest('#sidebar')) return;
|
||||
var state = load();
|
||||
state.groups = state.groups || {};
|
||||
state.groups[el.id] = e.type === 'shown.bs.collapse';
|
||||
save(state);
|
||||
}
|
||||
function setHidden(hidden) {
|
||||
var state = load();
|
||||
state.hidden = !!hidden;
|
||||
save(state);
|
||||
document.documentElement.classList.toggle('sidebar-hidden', !!hidden);
|
||||
}
|
||||
apply();
|
||||
document.addEventListener('shown.bs.collapse', remember);
|
||||
document.addEventListener('hidden.bs.collapse', remember);
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest && e.target.closest('[data-sidebar-toggle]')) setHidden(!document.documentElement.classList.contains('sidebar-hidden'));
|
||||
});
|
||||
return { apply: apply, setHidden: setHidden, load: load };
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -366,6 +366,9 @@ unless you're typing, in which case they offer a refresh instead.
|
||||
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.
|
||||
|
||||
The menu's groups stay open or closed from page to page (kept in this browser): the group of the page you're on opens
|
||||
and stays open until you close it. On wide screens the ☰ button in the top bar hides the menu, and it stays hidden until you show it again.
|
||||
|
||||
On narrow screens the menu folds into a **Menu** button, and the moderation queues and online players show as cards
|
||||
with their buttons, so you can approve names or kick someone from a phone.
|
||||
|
||||
|
||||
@@ -91,4 +91,7 @@ if(NODE_EXECUTABLE)
|
||||
add_test(NAME SceneryCoreJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/scenery-core.test.mjs"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/scenery-core.js" "${PROJECT_SOURCE_DIR}/dCommon/NifFile.h"
|
||||
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/Scenery.cpp")
|
||||
# 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")
|
||||
endif()
|
||||
|
||||
108
tests/dWebTests/sidebar-state.test.mjs
Normal file
108
tests/dWebTests/sidebar-state.test.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
// The sidebar's saved group state (the <script data-sidebar-state> in templates/header.jinja2), run on a small fake DOM.
|
||||
// Run by ctest: node sidebar-state.test.mjs <header.jinja2>
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const [headerPath] = process.argv.slice(2);
|
||||
const source = readFileSync(headerPath, 'utf8').match(/<script data-sidebar-state>([\s\S]*?)<\/script>/)[1];
|
||||
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)}`);
|
||||
}
|
||||
};
|
||||
|
||||
class ClassList {
|
||||
constructor(names) { this.set = new Set(names); }
|
||||
contains(n) { return this.set.has(n); }
|
||||
toggle(n, on) { if (on === undefined) on = !this.set.has(n); if (on) this.set.add(n); else this.set.delete(n); return on; }
|
||||
add(n) { this.set.add(n); }
|
||||
}
|
||||
|
||||
// A sidebar with three groups; "nav-logs" holds the current page
|
||||
function page(storage, shownByServer) {
|
||||
const groups = ['nav-inbox', 'nav-world', 'nav-logs'].map((id) => ({
|
||||
id,
|
||||
classList: new ClassList(shownByServer.includes(id) ? ['collapse', 'show'] : ['collapse']),
|
||||
querySelector: (sel) => (sel === '.active' && id === 'nav-logs' ? {} : null),
|
||||
closest: (sel) => (sel === '#sidebar' ? {} : null)
|
||||
}));
|
||||
const toggles = Object.fromEntries(groups.map((g) => [g.id, { classList: new ClassList([]), attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } }]));
|
||||
const listeners = {};
|
||||
const html = { classList: new ClassList([]) };
|
||||
const document = {
|
||||
documentElement: html,
|
||||
querySelectorAll(sel) {
|
||||
if (sel === '#sidebar .nav-group > .collapse[id]') return groups;
|
||||
const m = sel.match(/^\[data-bs-target="#(.+)"\]$/);
|
||||
return m ? [toggles[m[1]]] : [];
|
||||
},
|
||||
addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }
|
||||
};
|
||||
const localStorage = {
|
||||
getItem: (k) => (k in storage ? storage[k] : null),
|
||||
setItem: (k, v) => { storage[k] = String(v); }
|
||||
};
|
||||
const window = {};
|
||||
vm.runInNewContext(source, { window, document, localStorage, JSON });
|
||||
const open = () => Object.fromEntries(groups.map((g) => [g.id, g.classList.contains('show')]));
|
||||
const fire = (type, id) => (listeners[type] || []).forEach((fn) => fn({ type, target: groups.find((g) => g.id === id) }));
|
||||
return { window, open, fire, toggles, html, listeners };
|
||||
}
|
||||
|
||||
// Nothing saved: the server's choice (the current page's group) stays, and is kept for the next page
|
||||
{
|
||||
const storage = {};
|
||||
const p = page(storage, ['nav-logs']);
|
||||
same(p.open(), { 'nav-inbox': false, 'nav-world': false, 'nav-logs': true }, 'nothing saved');
|
||||
same(JSON.parse(storage['dash.sidebar']).groups, { 'nav-inbox': false, 'nav-world': false, 'nav-logs': true }, 'kept');
|
||||
}
|
||||
|
||||
// Opening and closing groups is saved, and the next page starts that way
|
||||
{
|
||||
const storage = {};
|
||||
const p = page(storage, ['nav-logs']);
|
||||
p.fire('shown.bs.collapse', 'nav-world');
|
||||
p.fire('hidden.bs.collapse', 'nav-inbox');
|
||||
same(JSON.parse(storage['dash.sidebar']).groups, { 'nav-inbox': false, 'nav-world': true, 'nav-logs': true }, 'saved groups');
|
||||
const next = page(storage, ['nav-logs']);
|
||||
same(next.open(), { 'nav-inbox': false, 'nav-world': true, 'nav-logs': true }, 'next page');
|
||||
same(next.toggles['nav-world'].attrs['aria-expanded'], 'true', 'toggle follows the group');
|
||||
same(next.toggles['nav-inbox'].classList.contains('collapsed'), true, 'closed toggle marked collapsed');
|
||||
}
|
||||
|
||||
// A group left open on another page stays open here
|
||||
{
|
||||
const p = page({ 'dash.sidebar': JSON.stringify({ groups: { 'nav-inbox': true, 'nav-world': false } }) }, ['nav-logs']);
|
||||
same(p.open(), { 'nav-inbox': true, 'nav-world': false, 'nav-logs': true }, 'left open');
|
||||
}
|
||||
|
||||
// The current page's group opens even when it was closed last time
|
||||
{
|
||||
const p = page({ 'dash.sidebar': JSON.stringify({ groups: { 'nav-logs': false, 'nav-inbox': true } }) }, []);
|
||||
same(p.open(), { 'nav-inbox': true, 'nav-world': false, 'nav-logs': true }, 'current group opens');
|
||||
}
|
||||
|
||||
// Broken or blocked storage: the server's choice
|
||||
{
|
||||
const p = page({ 'dash.sidebar': '{nope' }, ['nav-inbox']);
|
||||
same(p.open(), { 'nav-inbox': true, 'nav-world': false, 'nav-logs': true }, 'broken storage');
|
||||
}
|
||||
|
||||
// Hiding the menu is saved with the groups
|
||||
{
|
||||
const storage = { 'dash.sidebar': JSON.stringify({ groups: { 'nav-world': true } }) };
|
||||
const p = page(storage, []);
|
||||
p.window.Sidebar.setHidden(true);
|
||||
same(JSON.parse(storage['dash.sidebar']), { groups: { 'nav-world': true, 'nav-inbox': false, 'nav-logs': true }, hidden: true }, 'hidden saved');
|
||||
same(p.html.classList.contains('sidebar-hidden'), true, 'hidden now');
|
||||
p.window.Sidebar.setHidden(false);
|
||||
same(p.html.classList.contains('sidebar-hidden'), false, 'shown again');
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`${failures} failure(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('sidebar state: all passed');
|
||||
Reference in New Issue
Block a user