The app-detail page was the last corner of the SPA still using query
parameters for navigation state. Two related complaints surfaced it:
- `/app/adguard?tab=tasks` should mirror admin (`/admin/tools/peers`,
`/admin/config/network`) and be `/app/adguard/tasks`.
- The config sub-tab (general / advanced / features / network / …)
had no URL representation at all — `showTab` was a pure visual
swap with no history push, so refreshing a deep config sub-tab
sent the user back to the default first category.
New URL shape:
/app/<name> → config tab, default sub-tab
/app/<name>/<tab> → non-config main tab (tasks, backups, …)
/app/<name>/config/<category> → config tab + specific sub-tab
…?task=<id> → optional deep-link to a single task
Mirrors `adminPath` / `adminCategoryFromPath`. Two new helpers in
spa.js carry the convention:
window.appPath(name, tab, sub, taskId) → URL
window.appPartsFromPath(pathname) → { app, tab, sub }
Every URL constructor in the WebUI was replaced with `window.appPath`:
spa.js — handleAppDetail back-compat redirect
app-tabbed-manager.js — getTabFromURL + new getConfigSubFromURL
(path first, ?tab= fallback for legacy)
updateURL + updateApp use appPath
the inline task-deep-link constructor
apps-manager.js — showAppDetail + showAppDetailWithConfig
showTab now pushes /app/<n>/config/<sub>
renderAppDetail picks the sub-tab out of
the URL on first load
4 fallback task-URL constructors
tasks-manager.js — completion-notification URL
task-actions.js — start-notification URL
notifications.js — 2 task deep-link URLs
Back-compat: handleAppDetail detects legacy `?tab=` / `?config=` /
`?task=` queries and replaceState()s the URL to the canonical path
shape BEFORE anything else reads URL state — old bookmarks land on
the right page and end up with a clean URL.
Verified by running every appPath / appPartsFromPath case (including
the `logs` → `tasks` legacy alias) and confirming the round-trip is
identity. JS syntax checks clean on all six files. No remaining
hardcoded `/app/<x>?tab=` strings outside the back-compat comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
636 lines
22 KiB
JavaScript
Executable File
636 lines
22 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 {
|
|
// 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({}, '', 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';
|
|
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];
|
|
};
|
|
|
|
// 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
|
|
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
|