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>
585 lines
20 KiB
JavaScript
Executable File
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
|