refactor(webui): path-based URLs for app tabs + config sub-tabs

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>
This commit is contained in:
librelad 2026-05-27 01:40:05 +01:00
parent 92e585deba
commit e57d42ddf6
6 changed files with 116 additions and 45 deletions

View File

@ -135,22 +135,32 @@ class AppTabbedManager {
return appName;
}
// Get tab from URL parameter
// Get the main tab from the URL. Prefers the path-based shape
// /app/<name>/<tab> (where <tab> defaults to "config" when absent)
// and falls back to the legacy `?tab=<tab>` query for older bookmarks /
// links that haven't been migrated yet. Legacy `logs` is aliased to `tasks`.
getTabFromURL() {
const currentUrl = window.location.href;
if (window.appPartsFromPath) {
const parts = window.appPartsFromPath(window.location.pathname);
if (parts.app) return parts.tab; // path wins on app pages
}
const urlParams = new URLSearchParams(window.location.search);
const tab = urlParams.get('tab') || 'config';
// console.log('🔍 getTabFromURL debug:', {
//currentUrl: currentUrl,
//search: window.location.search,
//tabParam: urlParams.get('tab'),
//defaultTab: 'config',
//finalTab: tab === 'logs' ? 'tasks' : tab
//});
// Convert "logs" to "tasks" for backward compatibility
return tab === 'logs' ? 'tasks' : tab;
}
// Get the config sub-tab category from the URL. Only meaningful when the
// main tab is "config" — returns null otherwise (and when no sub-tab is set).
getConfigSubFromURL() {
if (window.appPartsFromPath) {
const parts = window.appPartsFromPath(window.location.pathname);
if (parts.tab === 'config' && parts.sub) return parts.sub;
}
// Legacy `?config=<category>` query support.
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('config') || null;
}
// Check if we're on an app page before doing anything
isAppPage() {
const pathname = window.location.pathname;
@ -163,34 +173,38 @@ class AppTabbedManager {
search.includes('?=')); // Old format app pages
}
// Update URL with app and tab — path-based: /app/<app>?tab=<tab>&task=<task>.
// Update URL with app and tab — path-based:
// /app/<name> — config tab, no sub
// /app/<name>/<tab> — non-config main tab
// /app/<name>/config/<sub> — config tab + sub-category
// …?task=<id> — optional deep-link
updateURL(app = null, tab = null) {
// Only update URLs on app pages - prevent interference with other pages.
if (!this.isAppPage()) return;
const params = new URLSearchParams(window.location.search);
const fromPath = window.location.pathname.replace(/^\/app\/?/, '').split('/')[0];
const currentApp = app || (fromPath ? decodeURIComponent(fromPath) : '') || this.currentApp;
const currentParts = window.appPartsFromPath
? window.appPartsFromPath(window.location.pathname)
: { app: '', tab: 'config', sub: null };
const currentApp = app || currentParts.app || this.currentApp;
if (!currentApp) return;
const q = new URLSearchParams();
const finalTab = tab || params.get('tab');
if (finalTab) q.set('tab', finalTab);
const finalTab = tab || currentParts.tab || 'config';
// Keep the config sub-tab only if we're STAYING on config (and on the same
// app). Switching main tab or app drops it; staying on config preserves it.
const finalSub = (!app && finalTab === 'config') ? currentParts.sub : null;
// Keep a deep-linked task only when staying on the same app (tab-only update).
if (!app) {
const task = params.get('task');
if (task) q.set('task', task);
}
const params = new URLSearchParams(window.location.search);
const taskId = !app ? params.get('task') : null;
const qs = q.toString();
window.history.replaceState({}, '', `/app/${encodeURIComponent(currentApp)}${qs ? '?' + qs : ''}`);
const url = window.appPath(currentApp, finalTab, finalSub, taskId);
window.history.replaceState({}, '', url);
}
// Update current app and refresh content
updateApp(newAppName) {
this.setCurrentApp(newAppName);
// Reset to the config tab on the path-based app URL.
history.replaceState({}, '', `/app/${encodeURIComponent(newAppName)}?tab=config`);
history.replaceState({}, '', window.appPath(newAppName, 'config'));
this.switchTab('config');
}
@ -538,10 +552,7 @@ class AppTabbedManager {
// Get current app from AppTabbedManager
const currentApp = this.currentApp || '';
// Construct proper URL with correct parameter order
const newUrl = `/app/${currentApp}?tab=tasks&task=${taskId}`;
// console.log('🔍 Updating URL with task parameter:', newUrl);
history.pushState({}, '', newUrl);
history.pushState({}, '', window.appPath(currentApp, 'tasks', null, taskId));
}
} else {
console.warn('⚠️ App task details not found for:', taskId);

View File

@ -311,10 +311,9 @@ class AppsManager {
// console.log('🔍 Preserving existing tab:', targetTab);
}
const newUrl = `/app/${appName}?tab=${targetTab}`;
// console.log('🔍 Setting URL to:', newUrl);
const newUrl = window.appPath(appName, targetTab);
history.pushState({}, '', newUrl);
// Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or
// any code it triggers calls switchTab → loadTabContent → restoreButtonState,
// we need this.currentApp to already be updated so restoreButtonState checks
@ -369,7 +368,7 @@ class AppsManager {
}
// Set URL to target tab (config or tasks)
const newUrl = `/app/${appName}?tab=${targetTab}`;
const newUrl = window.appPath(appName, targetTab);
history.pushState({}, '', newUrl);
// Update app-tabbed-manager. setCurrentApp clears stale disable state from
@ -577,7 +576,12 @@ class AppsManager {
// Use global preferred category if not provided
if (!preferredCategory && window.preferredConfigCategory) {
preferredCategory = window.preferredConfigCategory;
// console.log('🎯 Using global preferred category:', preferredCategory);
}
// …or pick it out of the path (/app/<name>/config/<sub>) so a refresh /
// deep-link lands on the sub-tab encoded in the URL.
if (!preferredCategory && window.appPartsFromPath) {
const parts = window.appPartsFromPath(window.location.pathname);
if (parts.tab === 'config' && parts.sub) preferredCategory = parts.sub;
}
const container = document.getElementById('app-detail-view');
@ -1868,22 +1872,33 @@ class AppsManager {
// Hide all panels
const allPanels = document.querySelectorAll('.tab-panel');
allPanels.forEach(panel => panel.classList.remove('active'));
// Remove active from all config category tabs (not main navigation tabs)
const allButtons = document.querySelectorAll('.tab-panel:has(.config-section) .tab-button, .config-section .tab-button');
allButtons.forEach(button => button.classList.remove('active'));
// Show selected panel
const targetPanel = document.getElementById(`panel-${tabKey}`);
if (targetPanel) {
targetPanel.classList.add('active');
}
// Add active to clicked config category button
const targetButton = document.querySelector(`.config-section [data-tab="${tabKey}"], .tab-panel:has(.config-section) [data-tab="${tabKey}"]`);
if (targetButton) {
targetButton.classList.add('active');
}
// Push the path-based URL so this sub-tab is shareable + back-buttonable —
// /app/<name>/config/<sub>. Skipped when there's no current app (e.g. when
// the form is rendered outside of the per-app context).
const currentApp = window.appTabbedManager?.currentApp;
if (currentApp && window.appPath) {
const newUrl = window.appPath(currentApp, 'config', tabKey);
if (window.location.pathname + window.location.search !== newUrl) {
history.pushState({}, '', newUrl);
}
}
}
// Initialize simple tabs (working method from app-config-original.js)
@ -3227,10 +3242,10 @@ class AppsManager {
}, 500);
} else if (window.librePortalSPA) {
// Fallback: navigate to app with tasks tab
const taskUrl = task ? `/app/${appName}?tab=tasks&task=${task.id}` : `/app/${appName}?tab=tasks`;
const taskUrl = window.appPath(appName, 'tasks', null, task ? task.id : null);
window.librePortalSPA.navigateTo(taskUrl);
} else if (window.navigateToRoute) {
window.navigateToRoute(`app/${appName}?tab=tasks${task ? `&task=${task.id}` : ''}`);
window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, ''));
}
}, 1000);
@ -3472,11 +3487,11 @@ class AppsManager {
}, 500);
} else if (window.librePortalSPA) {
// Fallback: navigate to app with tasks tab
const taskUrl = task ? `/app/${appName}?tab=tasks&task=${task.id}` : `/app/${appName}?tab=tasks`;
const taskUrl = window.appPath(appName, 'tasks', null, task ? task.id : null);
// console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`);
window.librePortalSPA.navigateTo(taskUrl);
} else if (window.navigateToRoute) {
window.navigateToRoute(`app/${appName}?tab=tasks${task ? `&task=${task.id}` : ''}`);
window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, ''));
}
}, 1000);

View File

@ -346,7 +346,7 @@ window.handleNotificationNavigation = (url) => {
// We're on an app page - navigate to the specified app and tab
if (window.appTabbedManager) {
// Update the URL to the target app/tab/task
const newUrl = `/app/${appName}?tab=${tab}&task=${taskId}`;
const newUrl = window.appPath(appName, tab, null, taskId);
console.log('🔗 Pushing state to URL:', newUrl);
window.history.pushState({}, '', newUrl);
@ -414,7 +414,7 @@ window.handleNotificationNavigation = (url) => {
} else {
// Not on app or tasks page - navigate to the app's tasks tab
if (appName && tab) {
window.history.pushState({}, '', `/app/${appName}?tab=${tab}&task=${taskId}`);
window.history.pushState({}, '', window.appPath(appName, tab, null, taskId));
// Let the SPA handle the navigation
if (window.appTabbedManager) {
window.appTabbedManager.showAppDetail(appName);

View File

@ -296,7 +296,7 @@ async configUpdate(changes) {
const currentUrl = window.location.href;
if (currentUrl.includes('/app/') && appName) {
taskUrl = `/app/${appName}?tab=tasks&task=${task.id}`;
taskUrl = window.appPath(appName, 'tasks', null, task.id);
} else {
taskUrl = `/tasks/all?task=${task.id}`;
}

View File

@ -1421,7 +1421,7 @@ class TasksManager {
: (appName || (task.command || `Task ${taskId}`)));
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
const url = (onAppPage && appName)
? `/app/${appName}?tab=tasks&task=${taskId}`
? window.appPath(appName, 'tasks', null, taskId)
: `/tasks/all?task=${taskId}`;
const icon = isSystemTask
? '/icons/libreportal.svg'

View File

@ -360,7 +360,23 @@ class LibrePortalSPAClean {
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
@ -539,6 +555,35 @@ window.adminCategoryFromPath = function (pathname) {
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) {