diff --git a/containers/libreportal/frontend/components/admin/config/js/config-form.js b/containers/libreportal/frontend/components/admin/config/js/config-form.js
index cc3a9d8..12d1c95 100755
--- a/containers/libreportal/frontend/components/admin/config/js/config-form.js
+++ b/containers/libreportal/frontend/components/admin/config/js/config-form.js
@@ -82,9 +82,9 @@ class ConfigForm {
);
if (task && window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') {
- setTimeout(() => window.librePortalSPA.navigate(`/tasks/all?task=${task.id}`), 400);
+ setTimeout(() => window.librePortalSPA.navigate(window.taskPath('all', task.id)), 400);
} else if (task && window.navigateToRoute) {
- setTimeout(() => window.navigateToRoute(`tasks/all?task=${task.id}`), 400);
+ setTimeout(() => window.navigateToRoute(window.taskPath('all', task.id)), 400);
}
} catch (error) {
console.error('ConfigForm: Error saving configuration:', error);
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
index a0438cc..3194195 100644
--- a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js
@@ -213,7 +213,7 @@ Object.assign(TasksManager.prototype, {
Type: ${task.type || 'unknown'}
diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js
index f2886a2..4ccbbf8 100755
--- a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js
+++ b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js
@@ -142,18 +142,13 @@ class TasksManager {
const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html';
if (isMainTasksPage) {
- // Category from the path (/tasks/), else legacy ?=.
- const seg = currentUrl.pathname.replace(/^\/tasks\/?/, '').split('/')[0];
- this.currentCategory = seg || searchParams.get('') || 'all';
-
- // Only check for specific task parameter if we're not coming from an app page
- const taskParam = searchParams.get('task');
- if (taskParam) {
- this.highlightedTaskId = taskParam;
- } else {
- // Clear any existing highlighted task when on main tasks page without task param
- this.highlightedTaskId = null;
- }
+ // Category + single-task deep link from the path (/tasks//),
+ // with legacy ?= and ?task= queries still honoured.
+ const parts = (typeof window.taskPartsFromPath === 'function')
+ ? window.taskPartsFromPath(currentUrl.pathname, currentUrl.search)
+ : { category: '', taskId: searchParams.get('task') || '' };
+ this.currentCategory = parts.category || searchParams.get('') || 'all';
+ this.highlightedTaskId = parts.taskId || null;
} else {
// Not on main tasks page, get default filter from localStorage
this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all';
@@ -163,11 +158,11 @@ class TasksManager {
}
updateURL(category, taskId = null) {
- // Update URL without page reload and without hash
- let newURL = `/tasks/${category || 'all'}`;
- if (taskId) {
- newURL += `?task=${taskId}`;
- }
+ // Update URL without page reload and without hash. The task deep link is a
+ // path segment (/tasks//) — see window.taskPath.
+ const newURL = (typeof window.taskPath === 'function')
+ ? window.taskPath(category, taskId)
+ : `/tasks/${category || 'all'}${taskId ? '/' + String(taskId).replace(/^task_/, '') : ''}`;
// Prevent the SPA from interfering
if (window.librePortalSPA) {
@@ -184,7 +179,7 @@ class TasksManager {
//// // console.log('🔧 Initializing TasksManager...');
// Re-read the URL on every (re)mount. The SPA reuses this singleton, so a
- // navigation to /tasks/?task=X must refresh the category + deep-link
+ // navigation to /tasks// must refresh the category + deep-link
// state — a constructor-only read goes stale after the first visit.
this.initializeFromURL();
@@ -415,7 +410,7 @@ class TasksManager {
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
const url = (onAppPage && appName)
? window.appPath(appName, 'tasks', null, taskId)
- : `/tasks/all?task=${taskId}`;
+ : window.taskPath('all', taskId);
// Per-action emoji (install ✅, backup 💾, restore 📦, …) in the
// notification's leftmost icon slot, mirroring task-list rows.
const customIcon = typeIcon ? `${typeIcon}` : null;
diff --git a/containers/libreportal/frontend/core/kernel/js/spa.js b/containers/libreportal/frontend/core/kernel/js/spa.js
index 096c0dd..f074258 100755
--- a/containers/libreportal/frontend/core/kernel/js/spa.js
+++ b/containers/libreportal/frontend/core/kernel/js/spa.js
@@ -657,6 +657,27 @@ window.appPartsFromPath = function (pathname) {
return { app, tab, sub };
};
+// Global tasks page path helpers — bring the single-task deep link in line with
+// /app/ and /admin/<…>: it's a path segment (/tasks//), not
+// a ?task= query. Task ids are guaranteed `task__`, so the
+// `task_` prefix is dropped in the URL for brevity and restored on read. A
+// legacy ?task= query is still parsed so older links and bookmarks resolve.
+window.taskPath = function (category, taskId) {
+ let p = '/tasks/' + encodeURIComponent(category || 'all');
+ if (taskId) p += '/' + encodeURIComponent(String(taskId).replace(/^task_/, ''));
+ return p;
+};
+window.taskPartsFromPath = function (pathname, search) {
+ const segs = String(pathname || '').replace(/^\/tasks\/?/, '').split('/').filter(Boolean);
+ const category = segs[0] || '';
+ let taskId = segs[1] ? decodeURIComponent(segs[1]) : '';
+ if (!taskId && search) {
+ try { taskId = new URLSearchParams(search).get('task') || ''; } catch (_) {}
+ }
+ if (taskId && !/^task_/.test(taskId)) taskId = 'task_' + taskId;
+ return { category, taskId };
+};
+
// Global navigation function for click handlers
window.navigateToRoute = function(href) {
if (window.spaClean) {
diff --git a/containers/libreportal/frontend/core/notifications/js/notifications.js b/containers/libreportal/frontend/core/notifications/js/notifications.js
index ae6d3cf..3e2fa42 100755
--- a/containers/libreportal/frontend/core/notifications/js/notifications.js
+++ b/containers/libreportal/frontend/core/notifications/js/notifications.js
@@ -114,7 +114,7 @@ class NotificationSystem {
// Action button (context-aware)
if (appName && appUrl) {
let buttonText = 'Manage';
- if (appUrl.includes('task=')) {
+ if (appUrl.includes('task=') || /\/tasks\/[^/]+\/[^/?]+/.test(appUrl)) {
buttonText = 'View Task';
} else if (type === 'success' && message.includes('install')) {
buttonText = 'Configure';
@@ -399,14 +399,17 @@ window.handleNotificationNavigation = (url) => {
console.warn('⚠️ appTabbedManager not available');
}
} else if (currentPath.includes('/tasks')) {
- // We're on the tasks page, navigate to the specified task
- if (taskId) {
- console.log('🔗 On tasks page, opening task:', taskId);
- window.history.pushState({}, '', `/tasks/all?task=${taskId}`);
+ // We're on the tasks page, navigate to the specified task. The target may
+ // carry the id as a /tasks// path segment or a legacy ?task=.
+ const tasksTaskId = taskId
+ || (window.taskPartsFromPath ? window.taskPartsFromPath(urlObj.pathname, urlObj.search).taskId : '');
+ if (tasksTaskId) {
+ console.log('🔗 On tasks page, opening task:', tasksTaskId);
+ window.history.pushState({}, '', window.taskPath ? window.taskPath('all', tasksTaskId) : url);
setTimeout(() => {
if (typeof window.toggleTaskDetails === 'function') {
- console.log('🔗 Opening task details for:', taskId);
- window.toggleTaskDetails(taskId);
+ console.log('🔗 Opening task details for:', tasksTaskId);
+ window.toggleTaskDetails(tasksTaskId);
}
}, 300);
return true;
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index 1d57e1c..6e1e75d 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -784,10 +784,11 @@ class SetupWizard {
this.container.classList.add('setup-launched');
setTimeout(() => {
- // Path-based route (the app uses /… URLs); the specific task is still
- // selected via ?task=. Navigate via the SPA helper, with an absolute-path
- // full-load fallback.
- const target = `/tasks/all?task=${encodeURIComponent(firstTaskId)}&from=setup`;
+ // Path-based route (the app uses /… URLs); the specific task is a path
+ // segment (/tasks/all/) and ?from=setup flags the install handoff so
+ // the page follows the running queue. Navigate via the SPA helper, with
+ // an absolute-path full-load fallback.
+ const target = `${window.taskPath('all', firstTaskId)}?from=setup`;
if (typeof window.navigateToRoute === 'function' && window.spaClean) {
window.navigateToRoute(target);
this.hide();
diff --git a/containers/libreportal/frontend/core/tasks/js/task-actions.js b/containers/libreportal/frontend/core/tasks/js/task-actions.js
index 8742670..465bcee 100755
--- a/containers/libreportal/frontend/core/tasks/js/task-actions.js
+++ b/containers/libreportal/frontend/core/tasks/js/task-actions.js
@@ -406,7 +406,7 @@ async configUpdate(changes) {
if (currentUrl.includes('/app/') && appName) {
taskUrl = window.appPath(appName, 'tasks', null, task.id);
} else {
- taskUrl = `/tasks/all?task=${task.id}`;
+ taskUrl = window.taskPath('all', task.id);
}
if (window.notificationSystem) {