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>
400 lines
13 KiB
JavaScript
Executable File
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');
|
|
}
|
|
}
|