// Topbar component functionality class TopbarComponent { constructor() { this.init(); } getCurrentPage() { const path = window.location.pathname; //// // console.log('๐Ÿ” Topbar: Detecting page from path:', path); // PRIMARY: Use path-based detection only (most reliable) // This avoids confusion from query parameters like tab=, app=, etc. if (path.startsWith('/app') || path === '/app') { return 'app'; } if (path.startsWith('/apps') || path === '/apps') { return 'apps'; } if (path.startsWith('/admin') || path.startsWith('/config') || path.startsWith('/ssh')) { return 'config'; } if (path.startsWith('/tasks') || path === '/tasks') { return 'tasks'; } if (path.startsWith('/backup') || path === '/backup') { return 'backup'; } if (path === '/' || path === '/dashboard') { return 'dashboard'; } // Fallback to filename extraction for backward compatibility const filename = path.split('/').pop() || 'dashboard.html'; const pageType = filename.replace('.html', ''); return pageType; } // Static method to load topbar (only once in SPA) static async loadTopbar() { const container = document.getElementById('topbar-container'); if (!container) { console.error('Topbar container not found'); return; } // Load fresh topbar HTML try { //// // console.log('Loading topbar HTML (SPA mode)'); const response = await fetch('/html/topbar.html'); if (!response.ok) { throw new Error(`Failed to load topbar: ${response.status}`); } const html = await response.text(); container.innerHTML = html; // Initialize component new TopbarComponent(); } catch (error) { console.error('Error loading topbar:', error); } } init() { this.setupNavigation(); this.setActiveNav(); this.setupThemeManager(); this.setupLogout(); this.setupConfigUpdateLockout(); this.setupSetupGate(); this.setupDevModeEasterEgg(); this.setupDevBanner(); this.autoEnableDevModeIfNeeded(); // Mount the "out of date" badge now that .topbar-controls exists. window.updateNotifier?.onTopbarReady(); } // 10-click LibrePortal-logo easter egg โ†’ toggles CFG_DEV_MODE. Inspired by // Android's "tap Build Number 7 times to become a developer". Same idea: // dev-mode shows hidden CFG_* fields marked **DEV** (Installation Mode, // Release Channel, etc.) that normal users never need to see. Disabling // also via this path โ€” 10 more clicks turns it back off. setupDevModeEasterEgg() { const TARGET_CLICKS = 10; // matches Android's pattern (well, 7 there) const TOAST_FROM = 6; // start showing the countdown at click 6 const RESET_AFTER_MS = 3000; // idle reset โ€” don't accumulate across sessions let count = 0; let resetTimer = null; let currentToast = null; // single rolling toast โ€” updated in place document.addEventListener('click', (e) => { const logo = e.target.closest('.libreportal-logo'); if (!logo) return; count++; if (resetTimer) clearTimeout(resetTimer); resetTimer = setTimeout(() => { count = 0; currentToast = null; // next sequence starts a fresh toast }, RESET_AFTER_MS); const remaining = TARGET_CLICKS - count; const devOn = (window.systemConfigs?.CFG_DEV_MODE === 'true'); const verb = devOn ? 'disabling' : 'being'; const noun = devOn ? 'developer mode' : 'a developer'; if (remaining > 0 && count >= TOAST_FROM) { const msg = `You are ${remaining} click${remaining === 1 ? '' : 's'} away from ${verb} ${noun}.`; // If the rolling toast is still in the DOM, mutate its message // text in place instead of stacking another notification. When // it's been auto-removed (10s lifetime) we open a fresh one. const msgEl = currentToast && currentToast.parentElement ? currentToast.querySelector('.notification-message') : null; if (msgEl) { msgEl.innerHTML = msg; } else { currentToast = this._devToast(msg, 'info'); } } else if (remaining === 0) { count = 0; clearTimeout(resetTimer); currentToast = null; this._setDevMode(!devOn); } }); } // On WebUI load, if CFG_INSTALL_MODE is git/local but CFG_DEV_MODE is off, // auto-enable it. Otherwise dev-install users get locked out of the very // fields they need to manage their install. Idempotent โ€” runs once per // load; the no-op case (already on, or on release) is silent. async autoEnableDevModeIfNeeded() { try { const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`); if (!r.ok) return; const data = await r.json(); const installMode = data?.config?.CFG_INSTALL_MODE?.value; const devMode = data?.config?.CFG_DEV_MODE?.value; const onDevInstall = (installMode === 'git' || installMode === 'local'); const devModeOff = (devMode !== 'true' && devMode !== true); if (onDevInstall && devModeOff) { this._devToast(`Developer mode auto-enabled โ€” you're on a ${installMode} install.`, 'success'); await this._setDevMode(true, /*silent*/ true); } else if (devMode === 'true' || devMode === true) { this._updateDevBanner(true); } } catch { /* missing configs.json, network blip โ€” leave it; easter egg still works */ } } // Wires the dismiss button on the developer-mode strip. Dismissal is // remembered in localStorage and cleared whenever dev mode is re-enabled // (in _setDevMode), so toggling off + back on brings the banner back. setupDevBanner() { const btn = document.getElementById('dev-banner-close'); if (!btn) return; btn.addEventListener('click', () => { try { localStorage.setItem('lp.devBannerDismissed', 'true'); } catch {} this._updateDevBanner(false); }); } // Show/hide the banner + body padding offset. visible=true only paints // it when the user hasn't dismissed for this install. _updateDevBanner(visible) { const el = document.getElementById('dev-banner'); if (!el) return; let dismissed = false; try { dismissed = localStorage.getItem('lp.devBannerDismissed') === 'true'; } catch {} const shouldShow = visible && !dismissed; el.hidden = !shouldShow; document.body.classList.toggle('has-dev-banner', shouldShow); } // Toast helper โ€” uses the project's notification system if loaded, else // a quiet console echo so dev-mode UX never blocks page render. Returns // the notification DOM element when the real system is available, so // callers (the easter-egg countdown) can mutate the message in place // instead of stacking new toasts on each click. _devToast(message, kind = 'info') { if (window.notificationSystem?.show) { return window.notificationSystem.show(message, kind); } else if (typeof window.showNotification === 'function') { return window.showNotification(message, kind); } console.log(`[dev-mode] ${message}`); return null; } // Flip CFG_DEV_MODE via the standard config-update task (same path the // Config form uses). silent=true suppresses the success toast for the // auto-detect path โ€” the auto-detect path emits its own message first. async _setDevMode(enabled, silent = false) { const value = enabled ? 'true' : 'false'; try { if (!window.tasksManager?.router?.routeAction) { this._devToast('Task system not ready โ€” cannot toggle Developer Mode right now.', 'error'); return; } const encoded = `CFG_DEV_MODE=${value}`; await window.tasksManager.router.routeAction('config_update', { changes: `'${encoded.replace(/'/g, "'\\''")}'` }); // Optimistically update the in-process cache so other components // (e.g. config-shared.js's _filterDevKeys) see the new value without // a full page refresh. if (!window.systemConfigs) window.systemConfigs = {}; window.systemConfigs.CFG_DEV_MODE = value; if (enabled) { try { localStorage.removeItem('lp.devBannerDismissed'); } catch {} this._updateDevBanner(true); } else { this._updateDevBanner(false); } if (!silent) { this._devToast( enabled ? '๐Ÿ› ๏ธ Developer mode unlocked. Reload to see the extra options.' : 'Developer mode disabled.', 'success' ); } } catch (err) { this._devToast(`Failed to toggle Developer Mode: ${err.message || err}`, 'error'); } } // Disable nav items entirely until the Setup Wizard has been completed. // The wizard itself runs as a full-screen overlay that blocks interaction; // this is a belt-and-braces guard for the brief window before the wizard // mounts, and for any nav rendering that happens while it's open. async setupSetupGate() { try { const res = await fetch('/api/setup/status'); if (!res.ok) return; const { complete } = await res.json(); const nav = document.querySelector('.topbar-nav'); if (!nav) return; if (!complete) { nav.classList.add('setup-needed'); } else { nav.classList.remove('setup-needed'); } } catch { /* leave nav enabled if status check fails */ } } // Disable App Center / Config nav while a config_update task runs. setupConfigUpdateLockout() { const setLocked = (locked) => { ['nav-app-center', 'nav-config'].forEach((id) => { const el = document.getElementById(id); if (!el) return; if (locked) { el.classList.add('nav-item-disabled'); el.setAttribute('aria-disabled', 'true'); el.title = 'Disabled while configuration is being applied'; } else { el.classList.remove('nav-item-disabled'); el.removeAttribute('aria-disabled'); el.title = ''; } }); }; window.addEventListener('taskCreated', (event) => { if (event.detail?.action === 'config_update') setLocked(true); }); window.addEventListener('taskCompleted', (event) => { if (event.detail?.action === 'config_update') setLocked(false); }); } setupLogout() { const btn = document.getElementById('logout-btn'); if (btn) { btn.addEventListener('click', () => window.authManager?.logout()); } } setupNavigation() { // Add click handlers to navigation items const navItems = document.querySelectorAll('.nav-item'); navItems.forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); if (item.classList.contains('nav-item-disabled')) return; const href = item.getAttribute('href'); if (href) { // Special handling for tasks navigation if (item.id === 'nav-tasks' && window.tasksManager) { // console.log('๐Ÿ”„ Tasks button clicked - forcing clean state and reloading...'); // Force clean state window.tasksManager.highlightedTaskId = null; window.tasksManager.currentCategory = 'all'; window.tasksManager.tasks = []; // Clear the task list // Clear URL parameters window.history.pushState({ category: 'all', taskId: null }, '', '/tasks/all'); // Clear any localStorage filters localStorage.removeItem('tasksDefaultFilter'); // Force reload all tasks window.tasksManager.loadTasks().catch(error => { console.warn('โš ๏ธ Error refreshing tasks:', error); }); } // Use shared navigation utility navigateToRoute(href); } }); }); // Setup mobile menu this.setupMobileMenu(); } setupMobileMenu() { const mobileMenuBtn = document.getElementById('mobile-menu-btn'); const mobileOverlay = document.getElementById('mobile-overlay'); const sidebar = document.getElementById('sidebar'); if (mobileMenuBtn && mobileOverlay) { mobileMenuBtn.addEventListener('click', () => { sidebar.classList.toggle('mobile-open'); mobileOverlay.classList.toggle('active'); document.body.style.overflow = sidebar.classList.contains('mobile-open') ? 'hidden' : ''; }); mobileOverlay.addEventListener('click', () => { sidebar.classList.remove('mobile-open'); mobileOverlay.classList.remove('active'); document.body.style.overflow = ''; }); } } setupThemeManager() { const themeSelector = document.getElementById('theme-selector'); if (!themeSelector) return; const savedTheme = TopbarComponent.resolveSavedTheme(); this.setTheme(savedTheme); // Populate the dropdown from ThemeRegistry. Called twice โ€” once // synchronously with the built-in fallback list, then again after // the API discovery resolves with any custom themes. const renderOptions = (themes) => { const current = localStorage.getItem('theme') || savedTheme; themeSelector.innerHTML = ''; themes.forEach((t) => { const opt = document.createElement('option'); opt.value = t.name; opt.textContent = t.displayName || t.name; themeSelector.appendChild(opt); }); // If the saved theme is now in the list, select it; otherwise // leave whatever the browser chose as the default selection. if (themes.some((t) => t.name === current)) { themeSelector.value = current; } }; if (window.ThemeRegistry && typeof window.ThemeRegistry.onChange === 'function') { window.ThemeRegistry.onChange(renderOptions); } else { // ThemeRegistry didn't load โ€” fall back to the built-in three. renderOptions([ { name: 'nebula', displayName: 'Nebula' }, { name: 'dark-blue', displayName: 'Dark Blue' }, { name: 'light', displayName: 'Light' }, ]); } themeSelector.addEventListener('change', (e) => { this.setTheme(e.target.value); }); } // Reads localStorage, migrates legacy values, and returns the canonical // theme name. Old "dark" / "blue" both map to the new "dark-blue". // An earlier dead initializer wrote to "selectedTheme" โ€” fold that in // and drop it. We don't validate against a fixed list anymore because // custom themes are discovered at runtime โ€” any string is accepted. static resolveSavedTheme() { const legacy = localStorage.getItem('selectedTheme'); if (legacy && !localStorage.getItem('theme')) { localStorage.setItem('theme', legacy); } if (legacy) localStorage.removeItem('selectedTheme'); let theme = localStorage.getItem('theme'); if (theme === 'dark' || theme === 'blue') { theme = 'dark-blue'; localStorage.setItem('theme', theme); } if (!theme) { theme = 'nebula'; localStorage.setItem('theme', theme); } return theme; } setActiveNav() { // Remove active class from all nav items document.querySelectorAll('.nav-item').forEach(item => { item.classList.remove('active'); }); // Use path-based detection only for consistency const path = window.location.pathname; let activeNavId; // PRIMARY: Use path-based detection only (most reliable) if (path.startsWith('/app') || path.startsWith('/apps')) { activeNavId = 'nav-app-center'; } else if (path.startsWith('/admin') || path.startsWith('/config') || path.startsWith('/ssh')) { activeNavId = 'nav-config'; // Admin area (config + SSH live here) } else if (path.startsWith('/tasks')) { activeNavId = 'nav-tasks'; } else if (path.startsWith('/backup')) { activeNavId = 'nav-backup'; } else if (path === '/' || path === '/dashboard') { activeNavId = 'nav-dashboard'; } else { // Fallback to page detection if (this.currentPage === 'dashboard') { activeNavId = 'nav-dashboard'; } else if (this.currentPage === 'index' || this.currentPage === 'app' || this.currentPage === 'apps') { activeNavId = 'nav-app-center'; } else if (this.currentPage === 'config') { activeNavId = 'nav-config'; } else if (this.currentPage === 'tasks') { activeNavId = 'nav-tasks'; } else if (this.currentPage === 'backup') { activeNavId = 'nav-backup'; } else { activeNavId = 'nav-dashboard'; // default } } // Add active class to current page nav if (activeNavId) { const activeNav = document.getElementById(activeNavId); if (activeNav) { activeNav.classList.add('nav-active'); } else { console.warn(`โŒ Nav element not found: ${activeNavId}`); } } else { console.warn(`โŒ Could not determine active nav for path: ${path}`); } } setTheme(theme) { localStorage.setItem('theme', theme); document.documentElement.setAttribute('data-theme', theme); document.body.setAttribute('data-theme', theme); } static clearAllNavigationHighlighting() { //// // console.log('๐Ÿงน Aggressively clearing all navigation highlighting'); // Remove ALL active classes from ALL navigation items document.querySelectorAll('.nav-item').forEach(item => { //// // console.log('๐Ÿงน Clearing nav item:', item.id, item.textContent.trim()); item.classList.remove('active'); item.classList.remove('nav-active'); // Also remove any other possible active states item.removeAttribute('aria-current'); item.blur(); // Remove focus }); // Also clear other active classes that might interfere document.querySelectorAll('.category.active').forEach(item => { item.classList.remove('active'); }); document.querySelectorAll('.tab-button.active').forEach(item => { item.classList.remove('active'); }); //// // console.log('๐Ÿงน Cleared all navigation highlighting'); } static createNavigationHighlighting() { // Define the single function that handles navigation highlighting window.topbarNavigationHighlighting = function() { // Always clear all existing navigation first to prevent sticky highlighting TopbarComponent.clearAllNavigationHighlighting(); // PRIMARY: Use path-based detection only (most reliable) // This avoids confusion from query parameters like tab=, app=, etc. const path = window.location.pathname; let activeNavId; if (path.startsWith('/app') || path.startsWith('/apps')) { activeNavId = 'nav-app-center'; } else if (path.startsWith('/admin') || path.startsWith('/config') || path.startsWith('/ssh')) { activeNavId = 'nav-config'; } else if (path.startsWith('/tasks')) { activeNavId = 'nav-tasks'; } else if (path.startsWith('/backup')) { activeNavId = 'nav-backup'; } else if (path === '/' || path === '/dashboard') { activeNavId = 'nav-dashboard'; } else { // Fallback: use currentPage detection const currentPage = new TopbarComponent().getCurrentPage(); switch (currentPage) { case 'index.html': case '': case 'index': case 'app': case 'apps': activeNavId = 'nav-app-center'; break; case 'config': activeNavId = 'nav-config'; break; case 'tasks': activeNavId = 'nav-tasks'; break; case 'backup': activeNavId = 'nav-backup'; break; case 'dashboard': activeNavId = 'nav-dashboard'; break; default: activeNavId = 'nav-dashboard'; } } // Add active class to current page nav if (activeNavId) { const activeNav = document.getElementById(activeNavId); if (activeNav) { activeNav.classList.add('nav-active'); } else { console.warn(`โŒ Topbar: Nav element not found: ${activeNavId}`); } } else { console.warn(`โŒ Topbar: Could not determine active nav for path: ${path}`); } }; //// // console.log('๐ŸŒ Topbar navigation highlighting function created'); } }