librelad cfdd39386c feat(admin): move Peers into Admin/Tools; lift System next to Overview
Two related UI tidies — both removing surface area from the topbar / Tools
group rather than adding new pages.

Peers → /admin/tools/peers
  Was a top-level /peers route with its own topbar nav item, which doubled
  the navigation surface for what's really an admin tool (same shape as
  SSH Access). Now lives under the Admin sidebar's Tools group alongside
  SSH Access. /peers is kept as a legacy redirect → /admin/tools/peers.

  Plumbing:
  - config-sidebar.js gains a Peers entry under the Tools label.
  - config-manager.js gains a 'peers' branch that fetches
    peers-content.html into config-section, then inits PeersPage.
  - window.adminPath() learns 'peers' → /admin/tools/peers.
  - spa.js handlePeers() is now a redirect (mirrors handleSsh).
  - topbar.html drops the Peers nav item.
  - peers-content.html slimmed to a config-section template (no
    standalone page wrapper) so it embeds cleanly under the admin shell.
  - PeersPage gains a rootId constructor arg for symmetry with SshPage
    (queries still work globally — IDs are unique).

System lifted out of the Tools group
  User feedback: 'overview/system are kinda like, the same thing'. Moved
  System to sit right under Overview at the top of the sidebar, before
  the 'Config' label. Both surfaces are admin-landing pages (Overview =
  ops/health summary, System = live host + per-app stats) — distinct from
  config form pages or the Tools utilities.

  config-sidebar.js: System block moved to the top section (right after
  Overview's click handler). Original Tools-group instance removed.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 20:16:45 +01:00

585 lines
20 KiB
JavaScript
Executable File

// Clean SPA Router - Unified routing system for LibrePortal
class LibrePortalSPAClean {
constructor() {
this.routes = new Map();
this.currentRoute = null;
this.isLoading = false;
this.dataLoaded = false;
this.apps = [];
this.categories = [];
this.init();
}
async init() {
//console.log('🚀 Clean SPA: Initializing...');
// Setup routes immediately
this.setupRoutes();
// Wait for DOM to be ready
if (document.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve);
});
}
// Wait for topbar to load first
await this.waitForTopbar();
// Load data first
await this.loadCoreData();
// Handle initial route
this.handleInitialRoute();
//console.log('✅ Clean SPA: Initialization complete');
}
async waitForTopbar() {
//console.log('⏳ Waiting for topbar to load...');
// Wait for topbar component to be available and loaded
let attempts = 0;
const maxAttempts = 20; // 2 seconds max (reduced from 5 seconds)
while (attempts < maxAttempts) {
if (typeof TopbarComponent !== 'undefined' && TopbarComponent.loadTopbar) {
try {
await TopbarComponent.loadTopbar();
//console.log('✅ Topbar loaded successfully');
return;
} catch (error) {
console.warn('⚠️ Topbar loading failed, retrying...', error);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
console.warn('⚠️ Topbar failed to load after 2 seconds, continuing without topbar');
// Don't block the entire app if topbar fails
}
setupRoutes() {
// Clean route definitions with explicit handlers
this.routes.set('/', () => this.handleDashboard());
this.routes.set('/dashboard', () => this.handleDashboard());
this.routes.set('/apps', () => this.handleApps());
this.routes.set('/apps*', () => this.handleApps()); // /apps/<category> — MUST precede /app* (/apps startsWith /app)
this.routes.set('/app', () => this.handleAppDetail()); // /app/<name>
this.routes.set('/app*', () => this.handleAppDetail());
this.routes.set('/admin', () => this.handleAdmin()); // Admin area (path-based: /admin/config/<x>, /admin/tools/<x>)
this.routes.set('/admin*', () => this.handleAdmin());
this.routes.set('/config', () => this.handleConfigRedirect()); // legacy → /admin
this.routes.set('/config*', () => this.handleConfigRedirect());
this.routes.set('/tasks', () => this.handleTasks()); // Handle /tasks without query
this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query
this.routes.set('/backup', () => this.handleBackup());
this.routes.set('/backup*', () => this.handleBackup());
this.routes.set('/peers', () => this.handlePeers()); // legacy → /admin/tools/peers
this.routes.set('/peers*', () => this.handlePeers());
this.routes.set('/ssh', () => this.handleSsh()); // legacy → /admin/tools/ssh-access
this.routes.set('/ssh*', () => this.handleSsh());
//console.log('📍 Routes registered:', Array.from(this.routes.keys()));
}
async loadCoreData() {
//console.log('📊 Loading core data...');
try {
// Load apps
if (typeof DataLoader !== 'undefined' && DataLoader.loadApps) {
this.apps = await DataLoader.loadApps();
window.apps = this.apps;
//console.log(`📱 Loaded ${this.apps.length} apps`);
}
// Load categories
if (typeof DataLoader !== 'undefined' && DataLoader.loadCategories) {
this.categories = await DataLoader.loadCategories();
window.categories = this.categories;
window.sidebarCategories = this.categories; // Ensure this is always available
//console.log(`📂 Loaded ${Object.keys(this.categories).length} categories`);
}
this.dataLoaded = true;
//console.log('✅ Core data loaded successfully');
} catch (error) {
console.error('❌ Failed to load core data:', error);
this.showError('Failed to load application data');
}
}
handleInitialRoute() {
const path = window.location.pathname + window.location.search;
// console.log('🎯 SPA: Handling initial route:', path);
// Handle root path - redirect to dashboard
if (path === '/' || path === '') {
// console.log('🏠 SPA: Redirecting to dashboard');
this.navigate('/dashboard', false);
} else {
// console.log('🔀 SPA: Navigating to:', path);
this.navigate(path, false); // Don't add to history for initial load
}
}
async navigate(path, addToHistory = true) {
// console.log('🚀 SPA: navigate called with:', path, 'addToHistory:', addToHistory);
if (this.isLoading) {
// console.log('⏳ Navigation already in progress, ignoring:', path);
return;
}
if (this.currentRoute === path && addToHistory) {
//console.log('🔄 Same route, skipping navigation:', path);
return;
}
// Unsaved-config guard — an app's config panel registers
// window.__appConfigNavGuard while it has unsaved changes, so it can
// intercept navigation away (Apply / Discard / Stay).
if (typeof window.__appConfigNavGuard === 'function') {
try {
const decision = await window.__appConfigNavGuard(path);
if (decision === 'stay') return;
} catch (e) {
console.error('Nav guard error:', e);
}
}
// Force data reload when navigating to dashboard, even if same route
if (path === '/dashboard' || path === '/') {
// console.log('🔄 Dashboard navigation detected, forcing data reload');
// Trigger dashboard data reload after a short delay to ensure DOM is ready
setTimeout(() => {
if (typeof loadDashboardData === 'function') {
loadDashboardData();
}
}, 100);
}
this.isLoading = true;
//console.log('🧭 Navigating to:', path);
try {
// Update browser history
if (addToHistory) {
history.pushState({ route: path }, '', path);
}
this.currentRoute = path;
// Find and execute route handler
const handler = this.findRouteHandler(path);
if (handler) {
await handler();
} else {
console.warn('❌ No handler found for:', path);
this.showError('Page not found');
}
// Update navigation highlighting after content loads
if (typeof window.topbarNavigationHighlighting === 'function') {
//console.log('🔗 SPA: Updating navigation highlighting after navigation');
window.topbarNavigationHighlighting();
}
} catch (error) {
console.error('❌ Navigation error:', error);
this.showError('Navigation failed');
} finally {
this.isLoading = false;
}
}
findRouteHandler(path) {
//console.log('🔍 Finding handler for path:', path);
// Exact match first
if (this.routes.has(path)) {
//console.log('✅ Exact match found:', path);
return this.routes.get(path);
}
// Handle query parameters - strip them for matching
let basePath = path;
if (path.includes('?')) {
basePath = path.split('?')[0];
}
//console.log('🔍 Checking base path:', basePath, 'for full path:', path);
// Check base path against routes
if (this.routes.has(basePath)) {
//console.log('✅ Base path match found:', basePath);
return this.routes.get(basePath);
}
// Wildcard matching for remaining cases
for (const [route, handler] of this.routes) {
if (route.includes('*')) {
const pattern = route.replace('*', '');
if (basePath.startsWith(pattern)) {
//console.log('✅ Wildcard match:', pattern, 'for base path:', basePath);
return handler;
}
}
}
//console.log('❌ No handler found for:', path);
//console.log('❌ Base path:', basePath);
//console.log('❌ Available routes:', Array.from(this.routes.keys()));
return null;
}
async handleDashboard() {
// console.log('🏠 SPA: Loading dashboard...');
try {
// console.log('📄 SPA: Fetching dashboard content...');
const html = await this.fetchContent('/html/dashboard-content.html');
// console.log('📄 SPA: Dashboard content fetched, loading...');
this.loadContent(html, 'Dashboard');
// console.log('📄 SPA: Dashboard content loaded');
// Dashboard should already be initialized by SystemLoader
if (typeof loadInstalledApps === 'function') {
// console.log('📱 SPA: Loading installed apps...');
loadInstalledApps();
}
} catch (error) {
console.error('❌ Dashboard load error:', error);
this.showError('Failed to load dashboard');
}
}
async handleBackup() {
try {
const html = await this.fetchContent('/html/backup-content.html');
this.loadContent(html, 'Backups');
if (typeof BackupPage !== 'undefined') {
window.backupPage = new BackupPage();
await window.backupPage.init();
} else {
console.error('BackupPage class not loaded');
}
} catch (error) {
console.error('❌ Backup page load error:', error);
this.showError('Failed to load backup page');
}
}
async handlePeers() {
// Legacy /peers → Peers under the Admin area.
this.navigate('/admin/tools/peers', true);
}
async handleSsh() {
// Legacy /ssh → SSH Access under the Admin area.
this.navigate('/admin/tools/ssh-access', true);
}
async handleApps() {
//console.log('📱 Loading apps...');
// Category from the path (/apps/<category>), else legacy ?=<cat> / ?apps=.
const seg = window.location.pathname.replace(/^\/apps\/?/, '').split('/')[0];
if (seg) {
window.appsCategory = decodeURIComponent(seg);
} else {
const search = window.location.search || '';
if (search.includes('?=')) {
window.appsCategory = (window.location.pathname + search).split('?=')[1] || 'all';
} else {
window.appsCategory = new URLSearchParams(search).get('apps') || 'all';
}
}
try {
// Ensure unified layout is loaded (like the old SPA)
if (!document.querySelector('.apps-layout')) {
//console.log('📄 Loading apps layout HTML...');
const html = await this.fetchContent('/html/apps-unified-layout.html');
this.loadContent(html, 'Applications');
} else {
//console.log('📄 Apps layout already exists, skipping HTML load');
}
// Apps manager should already be initialized by SystemLoader
if (window.appsManager) {
//console.log('✅ AppsManager already initialized by SystemLoader');
await window.appsManager.initialize();
//console.log('✅ Apps loaded successfully');
} else {
console.error('AppsManager not available - SystemLoader should have initialized it');
throw new Error('AppsManager not initialized by SystemLoader');
}
} catch (error) {
console.error('❌ Apps load error:', error);
this.showError('Failed to load applications: ' + error.message);
}
}
async handleAppDetail() {
//console.log('🔍 Loading app detail...');
// Extract app name. Path-based /app/<name> first, then legacy ?app= / ?=name.
const url = new URL(window.location);
let appName = url.pathname.replace(/^\/app\/?/, '').split('/')[0];
appName = appName ? decodeURIComponent(appName) : '';
if (!appName) appName = url.searchParams.get('app');
// Handle old format ?=appname&tab=tabname
if (!appName && url.search.includes('?=')) {
const queryPart = url.search.replace('?', '');
const parts = queryPart.split('&');
for (const part of parts) {
if (part.startsWith('=')) {
appName = part.substring(1); // Remove the '='
break;
}
}
}
//console.log('🔍 Parsed app name:', appName, 'from URL:', url.search);
if (!appName) {
this.navigate('/apps', false);
return;
}
try {
const html = await this.fetchContent('/html/apps-unified-layout.html');
this.loadContent(html, appName); // Will be updated after app data loads
// AppTabbedManager should already be initialized by SystemLoader
if (window.appTabbedManager) {
// console.log('✅ AppTabbedManager already initialized by SystemLoader');
await window.appTabbedManager.initialize();
} else {
console.error('AppTabbedManager not available - SystemLoader should have initialized it');
throw new Error('AppTabbedManager not initialized by SystemLoader');
}
//console.log('✅ App detail loaded:', appName);
} catch (error) {
console.error('❌ App detail load error:', error);
this.showError('Failed to load application details');
}
}
// Admin area. Path-based: /admin (overview), /admin/config/<category>,
// /admin/tools/ssh-access. Reuses the config page shell + ConfigManager.
async handleAdmin() {
window.configCategory = window.adminCategoryFromPath(window.location.pathname);
try {
const html = await this.fetchContent('/html/config-content.html');
this.loadContent(html, 'Admin');
if (window.configManager) {
if (typeof window.configManager.renderConfig === 'function') {
await window.configManager.renderConfig(window.configCategory || 'overview');
}
} else {
console.error('ConfigManager not available - SystemLoader should have initialized it');
throw new Error('ConfigManager not initialized by SystemLoader');
}
} catch (error) {
console.error('❌ Admin load error:', error);
this.showError('Failed to load the Admin area');
}
}
// Legacy /config and /config?=<x> → the path-based /admin equivalent.
async handleConfigRedirect() {
const search = window.location.search || '';
let cat = 'overview';
if (search.includes('?=')) {
cat = (window.location.pathname + search).split('?=')[1] || 'overview';
} else {
cat = new URLSearchParams(search).get('config') || 'overview';
}
this.navigate(window.adminPath(cat), true);
}
async handleTasks() {
//console.log('📋 Loading tasks...');
try {
const html = await this.fetchContent('/html/tasks-content.html');
this.loadContent(html, 'Tasks');
// Tasks manager should already be initialized by SystemLoader
if (window.tasksManager) {
//console.log('✅ TasksManager already initialized by SystemLoader');
await window.tasksManager.init();
} else {
console.warn('⚠️ TasksManager not available yet, task functionality will be limited');
// Don't throw error - just show warning and continue
// The task system will be available when the user actually interacts with tasks
}
} catch (error) {
console.error('❌ Tasks load error:', error);
this.showError('Failed to load tasks');
}
}
async fetchContent(url) {
//console.log('📥 Fetching:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.text();
}
async loadScript(src) {
const scriptId = src.replace(/[^a-zA-Z0-9]/g, '_');
if (document.getElementById(scriptId)) {
return; // Already loaded
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.id = scriptId;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
loadContent(html, title) {
const container = document.getElementById('main-content') || document.querySelector('.main');
if (!container) {
throw new Error('Content container not found');
}
container.innerHTML = html;
document.title = `${title} - LibrePortal`;
// Update navigation highlighting
this.updateNavigation();
}
updateNavigation() {
//console.log('🔗 SPA: Using fallback navigation logic');
// Remove ALL active classes
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
item.classList.remove('nav-active');
});
// Use path-based detection only for consistency
const path = window.location.pathname;
let activeId = 'nav-dashboard'; // default
if (path.startsWith('/app') || path.startsWith('/apps')) {
activeId = 'nav-app-center';
} else if (path.startsWith('/admin') || path.startsWith('/config') || path.startsWith('/ssh')) {
activeId = 'nav-config';
} else if (path.startsWith('/tasks')) {
activeId = 'nav-tasks';
} else if (path.startsWith('/backup')) {
activeId = 'nav-backup';
} else if (path === '/' || path === '/dashboard') {
activeId = 'nav-dashboard';
}
//console.log('🔗 SPA: Setting active nav:', activeId);
const activeElement = document.getElementById(activeId);
if (activeElement) {
activeElement.classList.add('nav-active');
}
}
showError(message) {
const container = document.getElementById('main-content') || document.querySelector('.main');
if (container) {
container.innerHTML = `
<div class="error-page">
<h2>Error</h2>
<p>${message}</p>
<button onclick="location.reload()">Reload Page</button>
</div>
`;
}
}
}
// Admin area path helpers (shared by the SPA, sidebar, overview, ssh page).
// Map a category to its path-based URL, and parse a path back to a category.
window.adminPath = function (category) {
if (!category || category === 'overview') return '/admin';
if (category === 'ssh-access') return '/admin/tools/ssh-access';
if (category === 'peers') return '/admin/tools/peers';
return '/admin/config/' + category;
};
window.adminCategoryFromPath = function (pathname) {
const segs = String(pathname || '').replace(/^\/admin\/?/, '').split('/').filter(Boolean);
if (!segs.length || segs[0] === 'overview') return 'overview';
if (segs[0] === 'config') return segs[1] || 'general';
if (segs[0] === 'tools') return segs[1] || 'overview';
return segs[0];
};
// Global navigation function for click handlers
window.navigateToRoute = function(href) {
if (window.spaClean) {
//console.log('🔗 Converting href:', href);
// Convert href to clean path - handle various formats
let route = href.replace('.html', '').replace('./', '').replace(/^\//, '');
// Handle special cases
if (route === '' || route === 'index') {
route = '/apps'; // index goes to apps (main app center)
} else if (route === 'dashboard') {
route = '/dashboard';
} else if (route === 'apps') {
route = '/apps';
} else if (route === 'config' || route === 'admin') {
route = '/admin';
} else if (route === 'tasks') {
route = '/tasks';
} else if (!route.startsWith('/')) {
route = '/' + route;
}
//console.log('🎯 Final route:', route);
window.spaClean.navigate(route);
}
};
// Handle browser back/forward
window.addEventListener('popstate', (e) => {
if (e.state && e.state.route && window.spaClean) {
window.spaClean.navigate(e.state.route, false);
}
});
// Handle internal link clicks
document.addEventListener('click', (e) => {
const target = e.target.closest('a');
if (target && target.href) {
const href = target.getAttribute('href');
if (href && (href.startsWith('/') || href.startsWith('./'))) {
e.preventDefault();
window.navigateToRoute(href);
}
}
});
// SPA initialization is now handled by SystemLoader
// LibrePortalSPAClean instance will be created centrally