librelad 8a9ae28b6f feat(webui): developer mode + Android-style 10-click easter egg
What this delivers (Stage 1+2 of the dev-mode feature):

1. New `**DEV**` marker for config fields. Mirrors the existing
   `**ADVANCED**` pattern: stays in the description string, frontend
   strips it for display, presence flips a 'hide unless dev mode is on'
   behaviour. Implemented in ConfigUtils.cleanDescription /
   isDevField / isDevModeOn and in ConfigShared._filterDevKeys, which
   the two generateFieldsForCategory* helpers now call before rendering.

2. New CFG_DEV_MODE field in configs/general/general_install. Visible
   under Advanced; defaults to false. The canonical place to toggle
   dev mode (the WebUI easter egg writes to it, the auto-detector
   writes to it, and users can flip it directly here too).

3. Marked CFG_INSTALL_MODE and CFG_RELEASE_CHANNEL with `**DEV**`.
   Normal users no longer see either field — they install Release-
   Stable and that's the whole story. Devs see both with the
   user-facing labels you asked for:
     CFG_INSTALL_MODE        Release - Stable | Git clone | Local folder
     CFG_RELEASE_CHANNEL     Release - Stable | Release - Bleeding Edge
   (CFG_INSTALL_MODE label for the release option also renamed to match.)

4. 10-click LibrePortal-logo easter egg in topbar.js:
   - Counter on any .libreportal-logo click; idle-reset after 3 s
   - Toast countdown from click 6 ('4 clicks away from being a developer…')
   - At 10: toggles CFG_DEV_MODE via the standard config_update task
     (same path the Config form uses); shows '🛠️ Developer mode
     unlocked. Reload to see the extra options.'
   - Re-using the same logo when dev mode is on toggles it back off
     ('… away from disabling developer mode') — symmetric, no separate UI

5. Auto-detect: on every WebUI load, if CFG_INSTALL_MODE is git or
   local AND CFG_DEV_MODE is off, auto-flip to on with a one-time
   toast 'Developer mode auto-enabled — you're on a git install.
   Click the LibrePortal logo 10× to disable.' Stops dev-install
   users getting locked out of the very options they need to manage
   their install. Idempotent — runs once per page load; no-op if
   already on or on release.

Disable surfaces: (a) CFG_DEV_MODE in Advanced on the Config form is
the canonical toggle; (b) 10 more logo clicks. A 3rd surface (a System
page banner) is deferred — those two cover the practical cases.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 23:49:09 +01:00

504 lines
18 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.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. Click the LibrePortal logo 10× to disable.`, 'success');
await this._setDevMode(true, /*silent*/ true);
}
} catch { /* missing configs.json, network blip — leave it; easter egg still works */ }
}
// 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 (!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');
}
}