librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

400 lines
13 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('/config') || path === '/config') {
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();
}
// 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('/config')) {
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 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('/config')) {
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');
}
}