Clicking the LibrePortal logo 6→9 times spawned four separate "X clicks away from being a developer" notifications stacked on top of each other — visual noise for a delightful-bonus interaction. Now the easter egg keeps a single reference to its current toast and mutates the `.notification-message` text in place on each subsequent click. When the toast's 10s auto-remove timer expires mid-sequence (slow clicker) the next click opens a fresh one — same fallback for the idle-reset path that clears the count after 3s. `_devToast` now returns the notification element so the easter-egg handler can grab it; previously it returned undefined, fine for the one-shot toasts but no longer enough for the rolling-update pattern. Signed-off-by: librelad <librelad@digitalangels.vip>
556 lines
20 KiB
JavaScript
Executable File
556 lines
20 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;
|
|
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');
|
|
}
|
|
}
|