diff --git a/static/js/navigation.js b/static/js/navigation.js deleted file mode 100644 index f1113fc..0000000 --- a/static/js/navigation.js +++ /dev/null @@ -1,53 +0,0 @@ -// Navigation chevron buttons logic -document.addEventListener("DOMContentLoaded", function () { - const navPrev = document.getElementById("nav-prev"); - const navNext = document.getElementById("nav-next"); - - if (!navPrev || !navNext) return; - - // Define the navigation order (main pages) - const navPages = [ - window.NAVIGATION_URLS.dashboard, - window.NAVIGATION_URLS.projects, - window.NAVIGATION_URLS.imports, - window.NAVIGATION_URLS.simulations, - window.NAVIGATION_URLS.reporting, - window.NAVIGATION_URLS.settings, - ]; - - const currentPath = window.location.pathname; - - // Find current index - let currentIndex = -1; - for (let i = 0; i < navPages.length; i++) { - if (currentPath.startsWith(navPages[i])) { - currentIndex = i; - break; - } - } - - // If not found, disable both - if (currentIndex === -1) { - navPrev.disabled = true; - navNext.disabled = true; - return; - } - - // Set up prev button - if (currentIndex > 0) { - navPrev.addEventListener("click", function () { - window.location.href = navPages[currentIndex - 1]; - }); - } else { - navPrev.disabled = true; - } - - // Set up next button - if (currentIndex < navPages.length - 1) { - navNext.addEventListener("click", function () { - window.location.href = navPages[currentIndex + 1]; - }); - } else { - navNext.disabled = true; - } -}); diff --git a/static/js/navigation_sidebar.js b/static/js/navigation_sidebar.js index 8be73a2..406930e 100644 --- a/static/js/navigation_sidebar.js +++ b/static/js/navigation_sidebar.js @@ -3,6 +3,148 @@ 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") { @@ -160,6 +302,102 @@ 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 : []; @@ -177,6 +415,7 @@ navContainer.appendChild(buildEmptyState()); navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty"; delete navContainer.dataset[ROLE_ATTR]; + configureChevronButtons([]); return; } @@ -191,9 +430,22 @@ } 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", @@ -204,6 +456,9 @@ }); if (!response.ok) { + if (!cached || !cached.payload) { + configureChevronButtons([]); + } if (response.status !== 401 && response.status !== 403) { console.warn( "Navigation sidebar hydration failed with status", @@ -215,14 +470,22 @@ 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); diff --git a/templates/base.html b/templates/base.html index 15e313d..51d164b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -44,7 +44,6 @@ - diff --git a/templates/dashboard.html b/templates/dashboard.html index 9eea868..14b4059 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -1,5 +1,4 @@ -{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {% -block head_extra %} +{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {% block head_extra %} {% endblock %} {% block content %}