librelad fab6997cd7 refactor(webui): path-based Admin routing (/admin/config/<x>, /admin/tools/ssh-access)
Replace the Admin area's ?= query URLs with clean, hierarchical paths that
mirror the breadcrumb:
  /admin                  -> Overview
  /admin/config/<category>-> Config / <category>
  /admin/tools/ssh-access -> Tools / SSH Access

New /admin (+ /admin*) SPA route -> handleAdmin, which parses the path via the
shared window.adminPath / window.adminCategoryFromPath helpers and renders
through the existing ConfigManager. Legacy /config, /config?=<x> and /ssh now
redirect into the matching /admin path, so old links/bookmarks keep working
(server already serves index.html for any depth). Sidebar, Admin Overview,
dashboard link and top-nav now build /admin paths; active-nav + config data
loading recognise /admin across spa.js, topbar.js, router.js, data-loader.js.

Scope: Admin area only — /app, /apps, /tasks, /backup keep their existing ?=
URLs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 18:36:06 +01:00

288 lines
7.7 KiB
JavaScript
Executable File

// SPA Router for LibrePortal - Handles navigation without page reloads
class Router {
constructor() {
this.routes = new Map();
this.currentRoute = null;
this.loadingBar = null;
this.contentContainer = null;
this.init();
}
init() {
// Create loading bar
this.createLoadingBar();
// Get content container
this.contentContainer = document.getElementById('main-content') || document.querySelector('.main');
//console.log('Router: Content container found:', !!this.contentContainer, this.contentContainer);
// Handle browser back/forward
window.addEventListener('popstate', (e) => {
if (e.state && e.state.route) {
this.navigate(e.state.route, false);
}
});
// Don't handle initial route here - let SPA handle it after routes are registered
//console.log('Router: Initialized, waiting for SPA to handle initial navigation');
}
createLoadingBar() {
// Create neon loading bar
this.loadingBar = document.createElement('div');
this.loadingBar.className = 'neon-loading-bar';
this.loadingBar.innerHTML = `
<div class="neon-loading-bar-inner">
<div class="neon-loading-bar-progress"></div>
<div class="neon-loading-bar-glow"></div>
</div>
`;
// Insert after header
const header = document.getElementById('topbar-container');
if (header) {
header.insertAdjacentElement('afterend', this.loadingBar);
}
// Add CSS
this.addLoadingBarStyles();
}
addLoadingBarStyles() {
const style = document.createElement('style');
style.textContent = `
.neon-loading-bar {
position: fixed;
top: 60px;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
}
.neon-loading-bar.active {
opacity: 1;
}
.neon-loading-bar-inner {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.neon-loading-bar-progress {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #00d4ff, #0099ff, #00d4ff);
transition: width 0.3s ease;
box-shadow: 0 0 10px #00d4ff;
animation: neonPulse 1.5s ease-in-out infinite;
}
.neon-loading-bar-glow {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
opacity: 0.6;
animation: neonGlow 2s ease-in-out infinite;
}
@keyframes neonPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes neonGlow {
0%, 100% {
opacity: 0.3;
transform: scaleX(1);
}
50% {
opacity: 0.8;
transform: scaleX(1.1);
}
}
.neon-loading-bar.complete .neon-loading-bar-progress {
width: 100%;
animation: none;
}
.neon-loading-bar.complete .neon-loading-bar-glow {
animation: none;
opacity: 0;
}
`;
document.head.appendChild(style);
}
// Register a route
register(path, handler) {
this.routes.set(path, handler);
//console.log('Router: Registered route:', path);
}
// Navigate to a route
async navigate(path, addToHistory = true) {
if (this.currentRoute === path) return;
// Show loading bar
this.showLoadingBar();
// Update browser history
if (addToHistory) {
history.pushState({ route: path }, '', path);
}
this.currentRoute = path;
try {
//console.log('Router: Navigating to:', path);
//console.log('Router: Available routes:', Array.from(this.routes.keys()));
// Find matching route - simple wildcard matching
let handler = null;
if (this.routes.has(path)) {
// Exact match
handler = this.routes.get(path);
} else {
// Wildcard matching
for (const [route, routeHandler] of this.routes) {
if (route.includes('*')) {
const routePattern = route.replace('*', '');
if (path.startsWith(routePattern)) {
handler = routeHandler;
break;
}
}
}
}
if (handler) {
//console.log('Router: Found handler for:', path);
await handler();
} else {
console.warn('Router: No handler found for:', path);
console.warn('Router: Available routes were:', Array.from(this.routes.keys()));
this.hideLoadingBar();
}
} catch (error) {
console.error('Navigation error:', error);
this.hideLoadingBar();
}
}
showLoadingBar() {
if (this.loadingBar) {
this.loadingBar.classList.add('active');
// Reset progress
const progress = this.loadingBar.querySelector('.neon-loading-bar-progress');
if (progress) {
progress.style.width = '0%';
}
}
}
updateProgress(percent) {
if (this.loadingBar) {
const progress = this.loadingBar.querySelector('.neon-loading-bar-progress');
if (progress) {
progress.style.width = `${percent}%`;
}
}
}
hideLoadingBar() {
if (this.loadingBar) {
// Complete animation
this.updateProgress(100);
setTimeout(() => {
this.loadingBar.classList.add('complete');
setTimeout(() => {
this.loadingBar.classList.remove('active', 'complete');
const progress = this.loadingBar.querySelector('.neon-loading-bar-progress');
if (progress) {
progress.style.width = '0%';
}
}, 300);
}, 200);
}
}
// Load content dynamically
async loadContent(content, title = null) {
//console.log('Router: Loading content with title:', title);
if (!this.contentContainer) {
console.error('Router: No content container found');
return;
}
// Clear current content
this.contentContainer.innerHTML = '';
// Update page title
if (title) {
document.title = `${title} - LibrePortal`;
}
// Add new content
this.contentContainer.innerHTML = content;
//console.log('Router: Content loaded successfully, container innerHTML length:', this.contentContainer.innerHTML.length);
// Update active nav state
this.updateActiveNav();
}
updateActiveNav() {
// Always clear all navigation first to prevent sticky highlighting
if (typeof window.topbarNavigationHighlighting === 'function') {
window.topbarNavigationHighlighting();
return; // Exit early - let topbar handle it
}
// Fallback to original logic if helper not available
// 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 pathname = window.location.pathname;
let activeNavId;
// PRIMARY: Use path-based detection only (most reliable)
if (pathname.startsWith('/app') || pathname.startsWith('/apps')) {
activeNavId = 'nav-app-center';
} else if (pathname.startsWith('/admin') || pathname.startsWith('/config') || pathname.startsWith('/ssh')) {
activeNavId = 'nav-config';
} else if (pathname.startsWith('/tasks')) {
activeNavId = 'nav-tasks';
} else if (pathname === '/' || pathname === '/dashboard') {
activeNavId = 'nav-dashboard';
} else {
activeNavId = 'nav-dashboard'; // default
}
if (activeNavId) {
const activeNav = document.getElementById(activeNavId);
if (activeNav) {
activeNav.classList.add('nav-active');
}
}
}
}
// Global router instance
const router = new Router();