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 @@
-