librelad e6e796311a feat(webui): migrate Dashboard + Tasks to feature modules
Two more pages on the feature-module contract (specs produced + adversarially
verified by workflow):
- features/dashboard/index.js: landing page; mount() folds in the data-reload
  that used to be a navigate() special-case (now deleted from spa.js, so it
  fires exactly once). No controller class — uses the eager dashboard.js globals.
- features/tasks/index.js: re-inits the system-loader tasksManager singleton;
  unmount() clears the 30s auto-refresh interval + open log streams WITHOUT
  stopping the shared SSE bus or nulling the singleton.

Verifier fixes applied: deleted the duplicate dashboard reload in navigate();
dropped a dead detachDashboardLive() call; fixed an invalid try{}while(false)
in the tasks unmount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-29 23:29:31 +01:00

747 lines
28 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 from the feature manifest (falls back to the built-in table)
await this.setupRoutesFromManifest();
// 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()));
}
// Build the route table from the feature manifest (window.LP.features) so
// "what pages exist" lives in one declarative place (features/manifest.dev.json)
// instead of being hardcoded here. Route insertion order is preserved from the
// manifest, which keeps the wildcard precedence findRouteHandler() relies on
// (e.g. '/apps*' must be inserted before '/app*'). Falls back to the built-in
// setupRoutes() table if the manifest is missing, empty, or names a handler
// this class doesn't define — routing must never be left half-wired.
async setupRoutesFromManifest() {
try {
const manifest = (window.LP && window.LP.features)
? await window.LP.features.loadManifest()
: null;
const entries = manifest && manifest.features;
if (!entries || !entries.length) {
this.setupRoutes();
return;
}
// All-or-nothing: a single missing handler means we don't trust the
// manifest enough to route from it — use the known-good built-in table.
for (const f of entries) {
if (typeof this[f.handler] !== 'function') {
console.warn(`[spa] manifest handler "${f.handler}" (feature "${f.id}") not found — using built-in routes`);
this.setupRoutes();
return;
}
}
this.routes.clear();
for (const f of entries) {
// A feature that has registered a module with a mount() is driven
// through the kernel lifecycle; everything else still runs its legacy
// handleX() method (strangler migration — both coexist).
const mod = (window.LP && window.LP.features) ? window.LP.features.get(f.id) : null;
const handler = (mod && typeof mod.mount === 'function')
? () => this._mountFeature(f)
: () => this[f.handler]();
for (const route of (f.routes || [])) {
this.routes.set(route, handler);
}
}
this._routesFromManifest = true;
//console.log('📍 Routes registered from manifest:', Array.from(this.routes.keys()));
} catch (err) {
console.error('[spa] manifest routing failed, using built-in routes:', err);
this.setupRoutes();
}
}
// Drive a feature module's mount() through the kernel lifecycle. Falls back
// to the feature's legacy handler if no module is registered or mount throws,
// so a broken/absent module can never leave the route dead.
async _mountFeature(f) {
const mod = (window.LP && window.LP.features) ? window.LP.features.get(f.id) : null;
if (!mod || typeof mod.mount !== 'function') {
return (typeof this[f.handler] === 'function') ? this[f.handler]() : this.showError('Page not found');
}
try {
const ctx = new window.LP.kernel.MountContext(this, { path: window.location.pathname + window.location.search });
await mod.mount(ctx);
this._mountedFeature = { id: f.id, mod, ctx, handler: f.handler };
} catch (err) {
console.error(`[kernel] mount("${f.id}") failed, falling back to handler:`, err);
this._mountedFeature = null;
if (typeof this[f.handler] === 'function') return this[f.handler]();
this.showError('Failed to load ' + f.id);
}
}
// Unmount the currently-mounted feature (if any) before navigating away, so
// its listeners/streams are released. Legacy handlers don't go through here,
// so this only fires when leaving a migrated feature.
async _unmountCurrentFeature() {
const cur = this._mountedFeature;
if (!cur) return;
this._mountedFeature = null;
try { if (typeof cur.mod.unmount === 'function') await cur.mod.unmount(cur.ctx); }
catch (e) { console.error(`[kernel] unmount("${cur.id}") error:`, e); }
try { cur.ctx && cur.ctx.teardown(); } catch (_) {}
}
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);
}
}
// (The dashboard data-reload that used to live here is now folded into the
// dashboard feature module's mount(), so it fires exactly once per
// navigation. See features/dashboard/index.js.)
this.isLoading = true;
//console.log('🧭 Navigating to:', path);
try {
// Update browser history
if (addToHistory) {
history.pushState({ route: path }, '', path);
} else {
// Even for "don't add to history" navigations (initial route, back-compat
// rewrites) we stamp the current entry's state with the resolved route.
// Without this the very first history entry has state: null, so popstate
// back to it from a later pushState'd entry hits the null guard and
// does nothing — exactly the "back button doesn't work" symptom.
const here = window.location.pathname + window.location.search;
history.replaceState({ route: path }, '', here);
}
this.currentRoute = path;
// Release the previously-mounted feature module (if any) before rendering
// the next route, so live streams/listeners don't outlive their page.
await this._unmountCurrentFeature();
// 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 {
// backup-page.js + backup-app-card.js are loaded on first navigation.
// loadScript is idempotent — subsequent /backup navigations are no-ops.
await Promise.all([
this.loadScript('/js/components/backup/backup-page.js'),
this.loadScript('/js/components/backup/backup-app-card.js')
]);
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;
}
// Back-compat: rewrite legacy /app/<name>?tab=<tab>[&config=<sub>][&task=<id>]
// URLs to the path-based shape (/app/<name>/<tab> | /<name>/config/<sub>)
// before the rest of the page reads URL state. The replaceState avoids
// leaving a stale query in the address bar for shareable links.
const legacyTab = url.searchParams.get('tab');
const legacyConfig = url.searchParams.get('config');
if (legacyTab || legacyConfig) {
const tab = legacyTab === 'logs' ? 'tasks' : (legacyTab || 'config');
const sub = (tab === 'config') ? legacyConfig : null;
const taskId = url.searchParams.get('task');
const canonical = window.appPath(appName, tab, sub, taskId);
if (canonical !== url.pathname + url.search) {
window.history.replaceState({ route: canonical }, '', canonical);
}
}
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/dashboard'; // the admin board
if (category === 'system') return '/admin/system'; // stats, not config
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);
// Bare /admin and /admin/dashboard both resolve to the overview board.
if (!segs.length || segs[0] === 'overview' || segs[0] === 'dashboard') return 'overview';
if (segs[0] === 'config') return segs[1] || 'general';
if (segs[0] === 'tools') return segs[1] || 'overview';
return segs[0];
};
// App-detail path helpers — mirror adminPath but with one extra optional
// segment for the config sub-tab category. The shape is:
// /app/<name> — config tab, default sub-tab
// /app/<name>/<tab> — non-config main tab (tasks, backups, …)
// /app/<name>/config/<category> — config tab with a specific sub-tab
// plus an optional ?task=<id> query for deep-linking a single task on the
// tasks tab (a transient deep link, not part of the navigation hierarchy).
window.appPath = function (appName, tab, sub, taskId) {
if (!appName) return '/apps';
let p = '/app/' + encodeURIComponent(appName);
if (tab && tab !== 'config') {
p += '/' + encodeURIComponent(tab);
} else if (sub) {
p += '/config/' + encodeURIComponent(sub);
}
if (taskId) p += '?task=' + encodeURIComponent(taskId);
return p;
};
window.appPartsFromPath = function (pathname) {
const segs = String(pathname || '').replace(/^\/app\/?/, '').split('/').filter(Boolean);
const app = segs[0] ? decodeURIComponent(segs[0]) : '';
let tab = segs[1] || 'config';
let sub = null;
if (tab === 'config') sub = segs[2] ? decodeURIComponent(segs[2]) : null;
// `logs` is the legacy alias for the `tasks` main tab.
if (tab === 'logs') tab = 'tasks';
return { app, tab, sub };
};
// 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. Prefer the route stamped on the history
// entry's state; fall back to the URL bar so entries that somehow lack
// state (third-party history manipulation, very-early popstate before
// init() finished) still re-render the right page instead of no-op'ing.
window.addEventListener('popstate', (e) => {
if (!window.spaClean) return;
const route = (e.state && e.state.route) || (window.location.pathname + window.location.search);
window.spaClean.navigate(route, false);
});
// Back-forward cache (BFCache) cooperation. Open SSE/WebSocket connections
// block the browser from snapshotting the page, so the back button ends up
// doing a full reload instead of restoring instantly. Closing every live
// bus we own on pagehide lets the snapshot happen; on pageshow with
// event.persisted === true the page just came back from BFCache and we
// reopen them. Fresh loads also fire pageshow but with persisted=false —
// we ignore those because the normal init paths already started everything.
window.addEventListener('pagehide', () => {
try { window.taskEventBus && window.taskEventBus.stop(); } catch (_) {}
try { window.LiveSystem && window.LiveSystem.pause && window.LiveSystem.pause(); } catch (_) {}
try { window.servicesManager && window.servicesManager.pauseStreams && window.servicesManager.pauseStreams(); } catch (_) {}
});
window.addEventListener('pageshow', (e) => {
if (!e.persisted) return;
try { window.taskEventBus && window.taskEventBus.start(); } catch (_) {}
try { window.LiveSystem && window.LiveSystem.resume && window.LiveSystem.resume(); } catch (_) {}
// Service log tail streams aren't auto-resumed — the user can click
// Resume on the overlay if they were tailing a log. Most BFCache restores
// are "back to dashboard", so silently dropping the tail is fine.
});
// 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