librelad 5655835398 ui(devmode): persistent banner under topbar + shorter auto-enable toast
Two small dev-mode UX changes.

1. Banner. When CFG_DEV_MODE is on, a 36px amber-tinted strip sits flush
   under the topbar with "You are currently running in Developer mode"
   and a dismiss X. Dismissal is remembered in localStorage and cleared
   whenever dev mode is toggled back on, so re-enabling the mode brings
   the banner back. Body picks up `.has-dev-banner` while visible to
   bump padding-top by the strip's height (also adjusts the mobile
   drawer's top/height).

2. Toast. The auto-enable message dropped the trailing
   "Click the LibrePortal logo 10× to disable." — too noisy on every
   git/local page load; the easter egg is still discoverable. New
   message is just "Developer mode auto-enabled — you're on a <mode>
   install."

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 14:13:56 +01:00

537 lines
19 KiB
JavaScript
Executable File

// 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;
document.addEventListener('click', (e) => {
const logo = e.target.closest('.libreportal-logo');
if (!logo) return;
count++;
if (resetTimer) clearTimeout(resetTimer);
resetTimer = setTimeout(() => { count = 0; }, 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) {
this._devToast(`You are ${remaining} click${remaining === 1 ? '' : 's'} away from ${verb} ${noun}.`, 'info');
} else if (remaining === 0) {
count = 0;
clearTimeout(resetTimer);
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.
_devToast(message, kind = 'info') {
if (window.notificationSystem?.show) {
window.notificationSystem.show(message, kind);
} else if (typeof window.showNotification === 'function') {
window.showNotification(message, kind);
} else {
console.log(`[dev-mode] ${message}`);
}
}
// 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');
}
}