feat(tasks): path-based single-task permalink (/tasks/<cat>/<id>)

The global tasks page still deep-linked a single task with a ?task=<id>
query while the rest of the SPA moved to path-based permalinks
(/app/<name>, /admin/<…>). Bring it in line: the task is now a path
segment, /tasks/<category>/<id>.

Task ids are guaranteed `task_<digits>_<base36>` (isValidTaskId), so the
redundant `task_` prefix is dropped in the URL and restored on read via a
new window.taskPath / window.taskPartsFromPath helper pair (mirrors
appPath/appPartsFromPath). The parser still accepts the legacy ?task=
query and the full-prefixed id, so old links, bookmarks and notifications
keep resolving.

Updated every builder (tasks-manager updateURL + notification url,
task-id link, task-actions, admin config-form, setup-wizard handoff with
its &from=setup flag) and the notification navigation handler / button
text to recognise the path form.

Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-06-12 18:31:18 +01:00
parent 12a37cc734
commit 9582671072
7 changed files with 54 additions and 34 deletions

View File

@ -82,9 +82,9 @@ class ConfigForm {
); );
if (task && window.librePortalSPA && typeof window.librePortalSPA.navigate === 'function') { 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) { } 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) { } catch (error) {
console.error('ConfigForm: Error saving configuration:', error); console.error('ConfigForm: Error saving configuration:', error);

View File

@ -213,7 +213,7 @@ Object.assign(TasksManager.prototype, {
<!-- Task metadata --> <!-- Task metadata -->
<div class="task-meta"> <div class="task-meta">
<div class="meta-item"> <div class="meta-item">
<strong>Task ID:</strong> <a href="/tasks/all?task=${task.id}" class="task-id-link" data-task-id="${task.id}">${task.id}</a> <strong>Task ID:</strong> <a href="${window.taskPath ? window.taskPath('all', task.id) : '/tasks/all'}" class="task-id-link" data-task-id="${task.id}">${task.id}</a>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<strong>Type:</strong> ${task.type || 'unknown'} <strong>Type:</strong> ${task.type || 'unknown'}

View File

@ -142,18 +142,13 @@ class TasksManager {
const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html'; const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html';
if (isMainTasksPage) { if (isMainTasksPage) {
// Category from the path (/tasks/<category>), else legacy ?=<category>. // Category + single-task deep link from the path (/tasks/<category>/<id>),
const seg = currentUrl.pathname.replace(/^\/tasks\/?/, '').split('/')[0]; // with legacy ?=<category> and ?task=<id> queries still honoured.
this.currentCategory = seg || searchParams.get('') || 'all'; const parts = (typeof window.taskPartsFromPath === 'function')
? window.taskPartsFromPath(currentUrl.pathname, currentUrl.search)
// Only check for specific task parameter if we're not coming from an app page : { category: '', taskId: searchParams.get('task') || '' };
const taskParam = searchParams.get('task'); this.currentCategory = parts.category || searchParams.get('') || 'all';
if (taskParam) { this.highlightedTaskId = parts.taskId || null;
this.highlightedTaskId = taskParam;
} else {
// Clear any existing highlighted task when on main tasks page without task param
this.highlightedTaskId = null;
}
} else { } else {
// Not on main tasks page, get default filter from localStorage // Not on main tasks page, get default filter from localStorage
this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all'; this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all';
@ -163,11 +158,11 @@ class TasksManager {
} }
updateURL(category, taskId = null) { updateURL(category, taskId = null) {
// Update URL without page reload and without hash // Update URL without page reload and without hash. The task deep link is a
let newURL = `/tasks/${category || 'all'}`; // path segment (/tasks/<category>/<id>) — see window.taskPath.
if (taskId) { const newURL = (typeof window.taskPath === 'function')
newURL += `?task=${taskId}`; ? window.taskPath(category, taskId)
} : `/tasks/${category || 'all'}${taskId ? '/' + String(taskId).replace(/^task_/, '') : ''}`;
// Prevent the SPA from interfering // Prevent the SPA from interfering
if (window.librePortalSPA) { if (window.librePortalSPA) {
@ -184,7 +179,7 @@ class TasksManager {
//// // console.log('🔧 Initializing TasksManager...'); //// // console.log('🔧 Initializing TasksManager...');
// Re-read the URL on every (re)mount. The SPA reuses this singleton, so a // Re-read the URL on every (re)mount. The SPA reuses this singleton, so a
// navigation to /tasks/<cat>?task=X must refresh the category + deep-link // navigation to /tasks/<cat>/<id> must refresh the category + deep-link
// state — a constructor-only read goes stale after the first visit. // state — a constructor-only read goes stale after the first visit.
this.initializeFromURL(); this.initializeFromURL();
@ -415,7 +410,7 @@ class TasksManager {
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)
? window.appPath(appName, 'tasks', null, taskId) ? window.appPath(appName, 'tasks', null, taskId)
: `/tasks/all?task=${taskId}`; : window.taskPath('all', taskId);
// Per-action emoji (install ✅, backup 💾, restore 📦, …) in the // Per-action emoji (install ✅, backup 💾, restore 📦, …) in the
// notification's leftmost icon slot, mirroring task-list rows. // notification's leftmost icon slot, mirroring task-list rows.
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null; const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;

View File

@ -657,6 +657,27 @@ window.appPartsFromPath = function (pathname) {
return { app, tab, sub }; return { app, tab, sub };
}; };
// Global tasks page path helpers — bring the single-task deep link in line with
// /app/<name> and /admin/<…>: it's a path segment (/tasks/<category>/<id>), not
// a ?task= query. Task ids are guaranteed `task_<digits>_<base36>`, so the
// `task_` prefix is dropped in the URL for brevity and restored on read. A
// legacy ?task=<id> 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 // Global navigation function for click handlers
window.navigateToRoute = function(href) { window.navigateToRoute = function(href) {
if (window.spaClean) { if (window.spaClean) {

View File

@ -114,7 +114,7 @@ class NotificationSystem {
// Action button (context-aware) // Action button (context-aware)
if (appName && appUrl) { if (appName && appUrl) {
let buttonText = 'Manage'; let buttonText = 'Manage';
if (appUrl.includes('task=')) { if (appUrl.includes('task=') || /\/tasks\/[^/]+\/[^/?]+/.test(appUrl)) {
buttonText = 'View Task'; buttonText = 'View Task';
} else if (type === 'success' && message.includes('install')) { } else if (type === 'success' && message.includes('install')) {
buttonText = 'Configure'; buttonText = 'Configure';
@ -399,14 +399,17 @@ window.handleNotificationNavigation = (url) => {
console.warn('⚠️ appTabbedManager not available'); console.warn('⚠️ appTabbedManager not available');
} }
} else if (currentPath.includes('/tasks')) { } else if (currentPath.includes('/tasks')) {
// We're on the tasks page, navigate to the specified task // We're on the tasks page, navigate to the specified task. The target may
if (taskId) { // carry the id as a /tasks/<cat>/<id> path segment or a legacy ?task=.
console.log('🔗 On tasks page, opening task:', taskId); const tasksTaskId = taskId
window.history.pushState({}, '', `/tasks/all?task=${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(() => { setTimeout(() => {
if (typeof window.toggleTaskDetails === 'function') { if (typeof window.toggleTaskDetails === 'function') {
console.log('🔗 Opening task details for:', taskId); console.log('🔗 Opening task details for:', tasksTaskId);
window.toggleTaskDetails(taskId); window.toggleTaskDetails(tasksTaskId);
} }
}, 300); }, 300);
return true; return true;

View File

@ -784,10 +784,11 @@ class SetupWizard {
this.container.classList.add('setup-launched'); this.container.classList.add('setup-launched');
setTimeout(() => { setTimeout(() => {
// Path-based route (the app uses /… URLs); the specific task is still // Path-based route (the app uses /… URLs); the specific task is a path
// selected via ?task=. Navigate via the SPA helper, with an absolute-path // segment (/tasks/all/<id>) and ?from=setup flags the install handoff so
// full-load fallback. // the page follows the running queue. Navigate via the SPA helper, with
const target = `/tasks/all?task=${encodeURIComponent(firstTaskId)}&from=setup`; // an absolute-path full-load fallback.
const target = `${window.taskPath('all', firstTaskId)}?from=setup`;
if (typeof window.navigateToRoute === 'function' && window.spaClean) { if (typeof window.navigateToRoute === 'function' && window.spaClean) {
window.navigateToRoute(target); window.navigateToRoute(target);
this.hide(); this.hide();

View File

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