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>
504 lines
18 KiB
JavaScript
Executable File
504 lines
18 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.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');
|
||
}
|
||
}
|