feat(dashboard): breadcrumbs that follow how you reached a page

Detail pages carry a breadcrumb bar built from a per-tab trail in
sessionStorage: following a link from a page on the trail extends it,
going back to one cuts it there, and opening a page directly shows its
natural parents. The property 3D view's back button goes to the previous
crumb, and the property page renames its crumb once its name loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 03:43:21 -05:00
parent 542f0a89f0
commit 2961089d6b
8 changed files with 89 additions and 14 deletions

View File

@@ -353,6 +353,83 @@
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); });
/**
* 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
* (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]');
function load() {
try { var t = JSON.parse(sessionStorage.getItem(KEY) || '[]'); return Array.isArray(t) ? t : []; } catch (e) { return []; }
}
function save(t) { try { sessionStorage.setItem(KEY, JSON.stringify(t)); } catch (e) { /* storage unavailable */ } }
function indexOf(t, href) { for (var i = 0; i < t.length; i++) if (t[i] && t[i].href === href) return i; return -1; }
function titleLabel() {
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));
}
save(trail);
function render() {
if (!nav) return;
nav.hidden = trail.length < 2;
var ol = document.createElement('ol');
ol.className = 'breadcrumb mb-3';
trail.forEach(function (c, i) {
var li = document.createElement('li');
li.className = 'breadcrumb-item' + (i === trail.length - 1 ? ' active' : '');
if (i === trail.length - 1) { li.setAttribute('aria-current', 'page'); li.textContent = c.label; }
else { var a = document.createElement('a'); a.href = c.href; a.textContent = c.label; li.appendChild(a); }
ol.appendChild(li);
});
nav.replaceChildren(ol);
var prev = trail.length > 1 ? trail[trail.length - 2] : null;
document.querySelectorAll('a[data-crumb-back]').forEach(function (a) {
if (!prev) return;
a.href = prev.href;
a.textContent = '← ' + prev.label;
});
}
render();
return {
label: function (text) {
if (!nav || !text) return;
trail[trail.length - 1].label = String(text);
save(trail);
render();
},
previous: function () { return trail.length > 1 ? trail[trail.length - 2] : null; }
};
})();
if (window.jQuery && $.fn.dataTable) {
// Columns without a custom renderer are inserted as HTML by default; render them as text instead
$.extend(true, $.fn.dataTable.defaults, {

View File

@@ -3,9 +3,7 @@
{% block title %}Account #{{ account.id }} - DarkflameServer{% endblock %}
{% block content %}
{% if can.accounts_view %}
<a href="/accounts" class="text-decoration-none mb-3 d-inline-block">&larr; Back to Accounts</a>
{% endif %}
<nav data-crumbs data-crumb-label="{{ account.name }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3">{% if can.accounts_view %}<li class="breadcrumb-item"><a href="/accounts">Accounts</a></li>{% endif %}<li class="breadcrumb-item active" aria-current="page">{{ account.name }}</li></ol></nav>
<div class="card mb-4" id="account" data-account-id="{{ account.id }}" data-gm-level="{{ account.gm_level }}" data-email="{{ account.email }}" data-account-name="{{ account.name }}" data-self="{% if is_self %}1{% endif %}" data-can-moderate="{% if manage.moderation %}1{% endif %}">
<div class="card-header d-flex justify-content-between align-items-center">

View File

@@ -3,7 +3,7 @@
{% block title %}Bug Report #{{ report.id }} - DarkflameServer{% endblock %}
{% block content %}
<a href="/bug_reports" class="text-decoration-none mb-3 d-inline-block">&larr; Back to Bug Reports</a>
<nav data-crumbs data-crumb-label="Bug Report #{{ report.id }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3"><li class="breadcrumb-item"><a href="/bug_reports">Bug Reports</a></li><li class="breadcrumb-item active" aria-current="page">Bug Report #{{ report.id }}</li></ol></nav>
<div class="card mb-4" id="report" data-report-id="{{ report.id }}">
<div class="card-header d-flex justify-content-between align-items-center">

View File

@@ -42,11 +42,7 @@
{% endblock %}
{% block content %}
{% if can.characters_view %}
<a href="/characters" class="text-decoration-none mb-3 d-inline-block">&larr; Back to Characters</a>
{% else %}
<a href="/" class="text-decoration-none mb-3 d-inline-block">&larr; Back to your characters</a>
{% endif %}
<nav data-crumbs data-crumb-label="{{ character.name }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3">{% if can.characters_view %}<li class="breadcrumb-item"><a href="/characters">Characters</a></li>{% else %}<li class="breadcrumb-item"><a href="/">Your characters</a></li>{% endif %}<li class="breadcrumb-item active" aria-current="page">{{ character.name }}</li></ol></nav>
<div class="row g-4">
<div class="col-lg-4">

View File

@@ -3,7 +3,7 @@
{% block title %}Play Key #{{ key.id }} - DarkflameServer{% endblock %}
{% block content %}
<a href="/play_keys" class="text-decoration-none mb-3 d-inline-block">&larr; Back to Play Keys</a>
<nav data-crumbs data-crumb-label="Play Key #{{ key.id }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3"><li class="breadcrumb-item"><a href="/play_keys">Play Keys</a></li><li class="breadcrumb-item active" aria-current="page">Play Key #{{ key.id }}</li></ol></nav>
<div class="row g-4">
<div class="col-lg-5">

View File

@@ -23,9 +23,10 @@
{% block content %}
{# Also the showcase's read-only view of approved public properties (Showcase.cpp passes showcase) #}
{% if not exists("showcase") %}<nav data-crumbs data-crumb-label="3D view" aria-label="Breadcrumb"><ol class="breadcrumb mb-2">{% if can.properties_view %}<li class="breadcrumb-item"><a href="/properties">Properties</a></li>{% endif %}<li class="breadcrumb-item"><a href="/properties/{{ property_id }}">Property {{ property_id }}</a></li><li class="breadcrumb-item active" aria-current="page">3D view</li></ol></nav>{% endif %}
<div id="property" data-property-id="{{ property_id }}" data-behavior-rules="{{ behavior_rules }}" data-showcase="{% if exists("showcase") %}1{% endif %}">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
{% if exists("showcase") %}<a href="/showcase" class="btn btn-sm btn-outline-secondary">&larr; Showcase</a>{% else %}<a href="/properties/{{ property_id }}" class="btn btn-sm btn-outline-secondary">&larr; Property</a>{% endif %}
{% if exists("showcase") %}<a href="/showcase" class="btn btn-sm btn-outline-secondary">&larr; Showcase</a>{% else %}<a href="/properties/{{ property_id }}" class="btn btn-sm btn-outline-secondary" data-crumb-back>&larr; Property</a>{% endif %}
<div class="me-auto">
<h5 class="mb-0 d-inline" id="propName">Loading&hellip;</h5>
<span class="small text-body-secondary ms-2" id="propMeta"></span>

View File

@@ -3,9 +3,7 @@
{% block title %}Property - DarkflameServer{% endblock %}
{% block content %}
{% if can.properties_view %}
<a href="/properties" class="text-decoration-none mb-3 d-inline-block">&larr; Back to Properties</a>
{% endif %}
<nav data-crumbs data-crumb-label="Property {{ property_id }}" aria-label="Breadcrumb"><ol class="breadcrumb mb-3">{% if can.properties_view %}<li class="breadcrumb-item"><a href="/properties">Properties</a></li>{% endif %}<li class="breadcrumb-item active" aria-current="page">Property {{ property_id }}</li></ol></nav>
<div id="property" data-property-id="{{ property_id }}">
<div class="card mb-4">
@@ -189,6 +187,7 @@ function load() {
if (p.success === false) { setText('propName', p.error); return; }
loaded = p;
setText('propName', p.name || 'Unnamed property');
Crumbs.label(p.name || 'Unnamed property');
// This property's own events and statistics (each property is its own instance of the zone, told apart by the clone)
const economy = document.getElementById('propEconomy');
if (economy && p.clone_id) {

View File

@@ -32,6 +32,10 @@ Every table remembers how you last sorted it and how many rows it shows per page
(per dashboard user), so it doesn't follow you to another browser; clearing site data resets it. Searches and the page
you were on aren't kept.
Detail pages (accounts, characters, properties and their 3D view, bug reports, play keys) show breadcrumbs for the way
you actually got there in this tab, for example Accounts > an account > a character > a property, so you can step
back to where you came from. Opened directly, a page shows its usual parent (Properties > a property).
## Files to back up
Next to the server binaries, the dashboard creates: