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:
parent
92e585deba
commit
e57d42ddf6
@ -135,22 +135,32 @@ class AppTabbedManager {
|
|||||||
return appName;
|
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() {
|
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 urlParams = new URLSearchParams(window.location.search);
|
||||||
const tab = urlParams.get('tab') || 'config';
|
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;
|
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
|
// Check if we're on an app page before doing anything
|
||||||
isAppPage() {
|
isAppPage() {
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
@ -163,34 +173,38 @@ class AppTabbedManager {
|
|||||||
search.includes('?=')); // Old format app pages
|
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) {
|
updateURL(app = null, tab = null) {
|
||||||
// Only update URLs on app pages - prevent interference with other pages.
|
// Only update URLs on app pages - prevent interference with other pages.
|
||||||
if (!this.isAppPage()) return;
|
if (!this.isAppPage()) return;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const currentParts = window.appPartsFromPath
|
||||||
const fromPath = window.location.pathname.replace(/^\/app\/?/, '').split('/')[0];
|
? window.appPartsFromPath(window.location.pathname)
|
||||||
const currentApp = app || (fromPath ? decodeURIComponent(fromPath) : '') || this.currentApp;
|
: { app: '', tab: 'config', sub: null };
|
||||||
|
const currentApp = app || currentParts.app || this.currentApp;
|
||||||
if (!currentApp) return;
|
if (!currentApp) return;
|
||||||
|
|
||||||
const q = new URLSearchParams();
|
const finalTab = tab || currentParts.tab || 'config';
|
||||||
const finalTab = tab || params.get('tab');
|
// Keep the config sub-tab only if we're STAYING on config (and on the same
|
||||||
if (finalTab) q.set('tab', finalTab);
|
// 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).
|
// Keep a deep-linked task only when staying on the same app (tab-only update).
|
||||||
if (!app) {
|
const params = new URLSearchParams(window.location.search);
|
||||||
const task = params.get('task');
|
const taskId = !app ? params.get('task') : null;
|
||||||
if (task) q.set('task', task);
|
|
||||||
}
|
|
||||||
|
|
||||||
const qs = q.toString();
|
const url = window.appPath(currentApp, finalTab, finalSub, taskId);
|
||||||
window.history.replaceState({}, '', `/app/${encodeURIComponent(currentApp)}${qs ? '?' + qs : ''}`);
|
window.history.replaceState({}, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current app and refresh content
|
// Update current app and refresh content
|
||||||
updateApp(newAppName) {
|
updateApp(newAppName) {
|
||||||
this.setCurrentApp(newAppName);
|
this.setCurrentApp(newAppName);
|
||||||
// Reset to the config tab on the path-based app URL.
|
// 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');
|
this.switchTab('config');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -538,10 +552,7 @@ class AppTabbedManager {
|
|||||||
// Get current app from AppTabbedManager
|
// Get current app from AppTabbedManager
|
||||||
const currentApp = this.currentApp || '';
|
const currentApp = this.currentApp || '';
|
||||||
|
|
||||||
// Construct proper URL with correct parameter order
|
history.pushState({}, '', window.appPath(currentApp, 'tasks', null, taskId));
|
||||||
const newUrl = `/app/${currentApp}?tab=tasks&task=${taskId}`;
|
|
||||||
// console.log('🔍 Updating URL with task parameter:', newUrl);
|
|
||||||
history.pushState({}, '', newUrl);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ App task details not found for:', taskId);
|
console.warn('⚠️ App task details not found for:', taskId);
|
||||||
|
|||||||
@ -311,8 +311,7 @@ class AppsManager {
|
|||||||
// console.log('🔍 Preserving existing tab:', targetTab);
|
// console.log('🔍 Preserving existing tab:', targetTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUrl = `/app/${appName}?tab=${targetTab}`;
|
const newUrl = window.appPath(appName, targetTab);
|
||||||
// console.log('🔍 Setting URL to:', newUrl);
|
|
||||||
history.pushState({}, '', newUrl);
|
history.pushState({}, '', newUrl);
|
||||||
|
|
||||||
// Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or
|
// Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or
|
||||||
@ -369,7 +368,7 @@ class AppsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set URL to target tab (config or tasks)
|
// Set URL to target tab (config or tasks)
|
||||||
const newUrl = `/app/${appName}?tab=${targetTab}`;
|
const newUrl = window.appPath(appName, targetTab);
|
||||||
history.pushState({}, '', newUrl);
|
history.pushState({}, '', newUrl);
|
||||||
|
|
||||||
// Update app-tabbed-manager. setCurrentApp clears stale disable state from
|
// Update app-tabbed-manager. setCurrentApp clears stale disable state from
|
||||||
@ -577,7 +576,12 @@ class AppsManager {
|
|||||||
// Use global preferred category if not provided
|
// Use global preferred category if not provided
|
||||||
if (!preferredCategory && window.preferredConfigCategory) {
|
if (!preferredCategory && window.preferredConfigCategory) {
|
||||||
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');
|
const container = document.getElementById('app-detail-view');
|
||||||
@ -1884,6 +1888,17 @@ class AppsManager {
|
|||||||
if (targetButton) {
|
if (targetButton) {
|
||||||
targetButton.classList.add('active');
|
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)
|
// Initialize simple tabs (working method from app-config-original.js)
|
||||||
@ -3227,10 +3242,10 @@ class AppsManager {
|
|||||||
}, 500);
|
}, 500);
|
||||||
} else if (window.librePortalSPA) {
|
} else if (window.librePortalSPA) {
|
||||||
// Fallback: navigate to app with tasks tab
|
// 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);
|
window.librePortalSPA.navigateTo(taskUrl);
|
||||||
} else if (window.navigateToRoute) {
|
} 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);
|
}, 1000);
|
||||||
|
|
||||||
@ -3472,11 +3487,11 @@ class AppsManager {
|
|||||||
}, 500);
|
}, 500);
|
||||||
} else if (window.librePortalSPA) {
|
} else if (window.librePortalSPA) {
|
||||||
// Fallback: navigate to app with tasks tab
|
// 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}`);
|
// console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`);
|
||||||
window.librePortalSPA.navigateTo(taskUrl);
|
window.librePortalSPA.navigateTo(taskUrl);
|
||||||
} else if (window.navigateToRoute) {
|
} 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);
|
}, 1000);
|
||||||
|
|
||||||
|
|||||||
@ -346,7 +346,7 @@ window.handleNotificationNavigation = (url) => {
|
|||||||
// We're on an app page - navigate to the specified app and tab
|
// We're on an app page - navigate to the specified app and tab
|
||||||
if (window.appTabbedManager) {
|
if (window.appTabbedManager) {
|
||||||
// Update the URL to the target app/tab/task
|
// 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);
|
console.log('🔗 Pushing state to URL:', newUrl);
|
||||||
window.history.pushState({}, '', newUrl);
|
window.history.pushState({}, '', newUrl);
|
||||||
|
|
||||||
@ -414,7 +414,7 @@ window.handleNotificationNavigation = (url) => {
|
|||||||
} else {
|
} else {
|
||||||
// Not on app or tasks page - navigate to the app's tasks tab
|
// Not on app or tasks page - navigate to the app's tasks tab
|
||||||
if (appName && 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
|
// Let the SPA handle the navigation
|
||||||
if (window.appTabbedManager) {
|
if (window.appTabbedManager) {
|
||||||
window.appTabbedManager.showAppDetail(appName);
|
window.appTabbedManager.showAppDetail(appName);
|
||||||
|
|||||||
@ -296,7 +296,7 @@ async configUpdate(changes) {
|
|||||||
const currentUrl = window.location.href;
|
const currentUrl = window.location.href;
|
||||||
|
|
||||||
if (currentUrl.includes('/app/') && appName) {
|
if (currentUrl.includes('/app/') && appName) {
|
||||||
taskUrl = `/app/${appName}?tab=tasks&task=${task.id}`;
|
taskUrl = window.appPath(appName, 'tasks', null, task.id);
|
||||||
} else {
|
} else {
|
||||||
taskUrl = `/tasks/all?task=${task.id}`;
|
taskUrl = `/tasks/all?task=${task.id}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1421,7 +1421,7 @@ class TasksManager {
|
|||||||
: (appName || (task.command || `Task ${taskId}`)));
|
: (appName || (task.command || `Task ${taskId}`)));
|
||||||
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
|
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
|
||||||
const url = (onAppPage && appName)
|
const url = (onAppPage && appName)
|
||||||
? `/app/${appName}?tab=tasks&task=${taskId}`
|
? window.appPath(appName, 'tasks', null, taskId)
|
||||||
: `/tasks/all?task=${taskId}`;
|
: `/tasks/all?task=${taskId}`;
|
||||||
const icon = isSystemTask
|
const icon = isSystemTask
|
||||||
? '/icons/libreportal.svg'
|
? '/icons/libreportal.svg'
|
||||||
|
|||||||
@ -361,6 +361,22 @@ class LibrePortalSPAClean {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const html = await this.fetchContent('/html/apps-unified-layout.html');
|
const html = await this.fetchContent('/html/apps-unified-layout.html');
|
||||||
this.loadContent(html, appName); // Will be updated after app data loads
|
this.loadContent(html, appName); // Will be updated after app data loads
|
||||||
@ -539,6 +555,35 @@ window.adminCategoryFromPath = function (pathname) {
|
|||||||
return segs[0];
|
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
|
// Global navigation function for click handlers
|
||||||
window.navigateToRoute = function(href) {
|
window.navigateToRoute = function(href) {
|
||||||
if (window.spaClean) {
|
if (window.spaClean) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user