Files
calminer/static/js/navigation_sidebar.js
zwitschi eb2687829f
Some checks failed
CI / lint (push) Successful in 17s
Deploy - Coolify / deploy (push) Failing after 5s
CI / test (push) Successful in 1m21s
CI / build (push) Successful in 2m25s
refactor(navigation): remove legacy navigation.js and integrate logic into navigation_sidebar.js
2025-11-15 13:53:50 +01:00

494 lines
13 KiB
JavaScript

(function () {
const NAV_ENDPOINT = "/navigation/sidebar";
const SIDEBAR_SELECTOR = ".sidebar-nav";
const DATA_SOURCE_ATTR = "navigationSource";
const ROLE_ATTR = "navigationRoles";
const NAV_PREV_ID = "nav-prev";
const NAV_NEXT_ID = "nav-next";
const CACHE_KEY = "calminer:navigation:sidebar";
const CACHE_VERSION = 1;
const CACHE_TTL_MS = 2 * 60 * 1000;
function hasStorage() {
try {
return typeof window.localStorage !== "undefined";
} catch (error) {
return false;
}
}
function loadCacheRoot() {
if (!hasStorage()) {
return null;
}
let raw;
try {
raw = window.localStorage.getItem(CACHE_KEY);
} catch (error) {
return null;
}
if (!raw) {
return { version: CACHE_VERSION, entries: {} };
}
try {
const parsed = JSON.parse(raw);
if (
!parsed ||
typeof parsed !== "object" ||
parsed.version !== CACHE_VERSION ||
typeof parsed.entries !== "object"
) {
return { version: CACHE_VERSION, entries: {} };
}
return parsed;
} catch (error) {
clearCache();
return { version: CACHE_VERSION, entries: {} };
}
}
function persistCache(root) {
if (!hasStorage()) {
return;
}
try {
window.localStorage.setItem(CACHE_KEY, JSON.stringify(root));
} catch (error) {
/* ignore storage write failures */
}
}
function clearCache() {
if (!hasStorage()) {
return;
}
try {
window.localStorage.removeItem(CACHE_KEY);
} catch (error) {
/* ignore */
}
}
function normaliseRoles(roles) {
if (!Array.isArray(roles)) {
return [];
}
const seen = new Set();
const cleaned = [];
for (const value of roles) {
const role = typeof value === "string" ? value.trim() : "";
if (!role || seen.has(role)) {
continue;
}
seen.add(role);
cleaned.push(role);
}
cleaned.sort();
return cleaned;
}
function serialiseRoles(roles) {
const cleaned = normaliseRoles(roles);
if (cleaned.length === 0) {
return "anonymous";
}
return cleaned.join("|");
}
function getCurrentRoles(navContainer) {
const attr = navContainer.dataset[ROLE_ATTR];
if (!attr) {
return null;
}
const roles = attr
.split(",")
.map((role) => role.trim())
.filter(Boolean);
if (roles.length === 0) {
return null;
}
return roles;
}
function readCache(rolesKey) {
if (!rolesKey) {
return null;
}
const root = loadCacheRoot();
if (!root || !root.entries || typeof root.entries !== "object") {
return null;
}
const entry = root.entries[rolesKey];
if (!entry || !entry.payload) {
return null;
}
const cachedAt = typeof entry.cachedAt === "number" ? entry.cachedAt : 0;
const expired = Date.now() - cachedAt > CACHE_TTL_MS;
return { payload: entry.payload, expired };
}
function saveCache(rolesKey, payload) {
if (!rolesKey || !payload) {
return;
}
const root = loadCacheRoot();
if (!root) {
return;
}
if (!root.entries || typeof root.entries !== "object") {
root.entries = {};
}
root.entries[rolesKey] = {
cachedAt: Date.now(),
payload,
};
root.version = CACHE_VERSION;
persistCache(root);
}
function onReady(callback) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
} else {
callback();
}
}
function isActivePath(pathname, matchPrefix) {
if (!matchPrefix) {
return false;
}
if (matchPrefix === "/") {
return pathname === "/";
}
return pathname.startsWith(matchPrefix);
}
function createAnchor({
href,
label,
matchPrefix,
tooltip,
isExternal,
isActive,
className,
}) {
const anchor = document.createElement("a");
anchor.href = href;
anchor.className = className + (isActive ? " is-active" : "");
anchor.dataset.matchPrefix = matchPrefix || href;
if (tooltip) {
anchor.title = tooltip;
}
if (isExternal) {
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
anchor.classList.add("is-external");
}
anchor.textContent = label;
return anchor;
}
function buildLinkBlock(link, pathname) {
if (!link || !link.href) {
return null;
}
const matchPrefix = link.match_prefix || link.matchPrefix || link.href;
const isActive = isActivePath(pathname, matchPrefix);
const block = document.createElement("div");
block.className = "sidebar-link-block";
if (typeof link.id === "number") {
block.dataset.linkId = String(link.id);
}
const anchor = createAnchor({
href: link.href,
label: link.label,
matchPrefix,
tooltip: link.tooltip,
isExternal: Boolean(link.is_external ?? link.isExternal),
isActive,
className: "sidebar-link",
});
block.appendChild(anchor);
const children = Array.isArray(link.children) ? link.children : [];
if (children.length > 0) {
const container = document.createElement("div");
container.className = "sidebar-sublinks";
for (const child of children) {
if (!child || !child.href) {
continue;
}
const childMatch =
child.match_prefix || child.matchPrefix || child.href;
const childActive = isActivePath(pathname, childMatch);
const childAnchor = createAnchor({
href: child.href,
label: child.label,
matchPrefix: childMatch,
tooltip: child.tooltip,
isExternal: Boolean(child.is_external ?? child.isExternal),
isActive: childActive,
className: "sidebar-sublink",
});
container.appendChild(childAnchor);
}
if (container.children.length > 0) {
block.appendChild(container);
}
}
return block;
}
function buildGroupSection(group, pathname) {
if (!group) {
return null;
}
const links = Array.isArray(group.links) ? group.links : [];
if (links.length === 0) {
return null;
}
const section = document.createElement("div");
section.className = "sidebar-section";
if (typeof group.id === "number") {
section.dataset.groupId = String(group.id);
}
const label = document.createElement("div");
label.className = "sidebar-section-label";
label.textContent = group.label;
section.appendChild(label);
const linksContainer = document.createElement("div");
linksContainer.className = "sidebar-section-links";
for (const link of links) {
const block = buildLinkBlock(link, pathname);
if (block) {
linksContainer.appendChild(block);
}
}
if (linksContainer.children.length === 0) {
return null;
}
section.appendChild(linksContainer);
return section;
}
function buildEmptyState() {
const section = document.createElement("div");
section.className = "sidebar-section sidebar-empty-state";
const label = document.createElement("div");
label.className = "sidebar-section-label";
label.textContent = "Navigation";
section.appendChild(label);
const copyWrapper = document.createElement("div");
copyWrapper.className = "sidebar-section-links";
const copy = document.createElement("p");
copy.className = "sidebar-empty-copy";
copy.textContent = "Navigation is unavailable.";
copyWrapper.appendChild(copy);
section.appendChild(copyWrapper);
return section;
}
function resolvePath(input) {
if (!input) {
return null;
}
try {
return new URL(input, window.location.origin).pathname;
} catch (error) {
if (input.startsWith("/")) {
return input;
}
return `/${input}`;
}
}
function flattenNavigation(groups) {
const sequence = [];
for (const group of groups) {
if (!group || !Array.isArray(group.links)) {
continue;
}
for (const link of group.links) {
if (!link || !link.href) {
continue;
}
const isExternal = Boolean(link.is_external ?? link.isExternal);
if (!isExternal) {
sequence.push({
href: link.href,
matchPrefix: link.match_prefix || link.matchPrefix || link.href,
});
}
const children = Array.isArray(link.children) ? link.children : [];
for (const child of children) {
if (!child || !child.href) {
continue;
}
const childExternal = Boolean(child.is_external ?? child.isExternal);
if (childExternal) {
continue;
}
sequence.push({
href: child.href,
matchPrefix: child.match_prefix || child.matchPrefix || child.href,
});
}
}
}
return sequence;
}
function configureChevronButtons(sequence) {
const prevButton = document.getElementById(NAV_PREV_ID);
const nextButton = document.getElementById(NAV_NEXT_ID);
if (!prevButton || !nextButton) {
return;
}
const pathname = window.location.pathname;
const normalised = sequence
.map((item) => ({
href: item.href,
matchPrefix: item.matchPrefix,
path: resolvePath(item.matchPrefix || item.href),
}))
.filter((item) => Boolean(item.path));
const currentIndex = normalised.findIndex((item) =>
isActivePath(pathname, item.matchPrefix || item.path)
);
prevButton.disabled = true;
prevButton.onclick = null;
nextButton.disabled = true;
nextButton.onclick = null;
if (currentIndex === -1) {
return;
}
if (currentIndex > 0) {
const target = normalised[currentIndex - 1].href;
prevButton.disabled = false;
prevButton.onclick = () => {
window.location.href = target;
};
}
if (currentIndex < normalised.length - 1) {
const target = normalised[currentIndex + 1].href;
nextButton.disabled = false;
nextButton.onclick = () => {
window.location.href = target;
};
}
}
function renderSidebar(navContainer, payload) {
const pathname = window.location.pathname;
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
navContainer.replaceChildren();
const rendered = [];
for (const group of groups) {
const section = buildGroupSection(group, pathname);
if (section) {
rendered.push(section);
}
}
if (rendered.length === 0) {
navContainer.appendChild(buildEmptyState());
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
delete navContainer.dataset[ROLE_ATTR];
configureChevronButtons([]);
return;
}
for (const section of rendered) {
navContainer.appendChild(section);
}
navContainer.dataset[DATA_SOURCE_ATTR] = "client";
const roles = Array.isArray(payload?.roles) ? payload.roles : [];
if (roles.length > 0) {
navContainer.dataset[ROLE_ATTR] = roles.join(",");
} else {
delete navContainer.dataset[ROLE_ATTR];
}
configureChevronButtons(flattenNavigation(groups));
}
async function hydrateSidebar(navContainer) {
const roles = getCurrentRoles(navContainer);
const rolesKey = roles ? serialiseRoles(roles) : null;
const cached = readCache(rolesKey);
if (cached && cached.payload) {
renderSidebar(navContainer, cached.payload);
if (!cached.expired) {
return;
}
}
try {
const response = await fetch(NAV_ENDPOINT, {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
if (!cached || !cached.payload) {
configureChevronButtons([]);
}
if (response.status !== 401 && response.status !== 403) {
console.warn(
"Navigation sidebar hydration failed with status",
response.status
);
}
return;
}
const payload = await response.json();
renderSidebar(navContainer, payload);
const payloadRoles = Array.isArray(payload?.roles)
? payload.roles
: roles || [];
saveCache(serialiseRoles(payloadRoles), payload);
} catch (error) {
console.warn("Navigation sidebar hydration failed", error);
if (!cached || !cached.payload) {
configureChevronButtons([]);
}
}
}
onReady(() => {
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
if (!navContainer) {
configureChevronButtons([]);
return;
}
hydrateSidebar(navContainer);
});
})();