diff --git a/containers/libreportal/frontend/css/tasks.css b/containers/libreportal/frontend/css/tasks.css index 010a2f7..1b650de 100644 --- a/containers/libreportal/frontend/css/tasks.css +++ b/containers/libreportal/frontend/css/tasks.css @@ -382,6 +382,108 @@ border-color: var(--status-danger); } +/* Multi-select checkbox sat to the right of the row's Delete button. + Sized + framed to match the surrounding .task-btn buttons so the row + reads as a single action group. Hides the native input and renders + a custom box; `cursor: pointer` on the wrapping label keeps the + whole 24×24 hit target clickable. */ +.task-select { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid rgba(var(--text-rgb), 0.12); + background: rgba(var(--text-rgb), 0.04); + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease; +} +.task-select:hover { + background: var(--surface-hover); + border-color: var(--status-success); +} +.task-select input[type="checkbox"] { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; height: 0; +} +.task-select-box { + width: 12px; + height: 12px; + border-radius: 3px; + border: 1.5px solid rgba(var(--text-rgb), 0.45); + background: transparent; + position: relative; + transition: background 0.12s ease, border-color 0.12s ease; +} +.task-select input[type="checkbox"]:checked + .task-select-box { + background: var(--status-success); + border-color: var(--status-success); +} +.task-select input[type="checkbox"]:checked + .task-select-box::after { + content: ""; + position: absolute; + top: 1px; + left: 4px; + width: 3px; + height: 7px; + border: solid var(--text-on-accent, #fff); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.task-select input[type="checkbox"]:focus-visible + .task-select-box { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Master "Select all" toggle in the status/action bar (left of Clear All). + Uses the same custom-box visual as the row checkbox so the two reinforce + each other; the label sits inline with the button text style. */ +.task-select-all { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + font-size: 11px; + color: var(--text-secondary); + user-select: none; + transition: background 0.18s ease, color 0.18s ease; +} +.task-select-all:hover { + background: var(--surface-hover); + color: var(--text-primary); +} +.task-select-all input[type="checkbox"] { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; height: 0; +} +/* The indeterminate state (some-but-not-all visible rows ticked) shows + a horizontal dash instead of a check. */ +.task-select-all input[type="checkbox"]:indeterminate + .task-select-box { + background: var(--status-success); + border-color: var(--status-success); +} +.task-select-all input[type="checkbox"]:indeterminate + .task-select-box::after { + content: ""; + position: absolute; + top: 4px; + left: 1px; + width: 8px; + height: 2px; + background: var(--text-on-accent, #fff); + border: none; + transform: none; +} +.task-select-all-label { + font-weight: 500; +} + .task-details { border-top: 1px solid rgba(var(--text-rgb), 0.10); background: transparent; diff --git a/containers/libreportal/frontend/html/tasks-content.html b/containers/libreportal/frontend/html/tasks-content.html index ba364c5..89f841b 100755 --- a/containers/libreportal/frontend/html/tasks-content.html +++ b/containers/libreportal/frontend/html/tasks-content.html @@ -151,12 +151,17 @@ Refresh - diff --git a/containers/libreportal/frontend/js/components/tasks/tasks-manager.js b/containers/libreportal/frontend/js/components/tasks/tasks-manager.js index 36d17b7..3628c2d 100755 --- a/containers/libreportal/frontend/js/components/tasks/tasks-manager.js +++ b/containers/libreportal/frontend/js/components/tasks/tasks-manager.js @@ -8,6 +8,10 @@ class TasksManager { this.tasks = []; this.currentCategory = 'all'; this.highlightedTaskId = null; + // Multi-select: ids of tasks the user has ticked. When non-empty the + // "Clear All" button morphs into "Delete Selected (N)". Both paths + // share _showClearAllModal — same UX, different filter. + this.selectedTaskIds = new Set(); this.taskManager = new TaskManager(); this.init(); @@ -556,6 +560,11 @@ class TasksManager { if (scrollParent) { requestAnimationFrame(() => { scrollParent.scrollTop = savedScrollTop; }); } + + // Resync the multi-select UI after the render. The Clear All label + // + master-checkbox tri-state need to reflect the just-rendered row + // set (selections can become stale across category switches). + if (typeof this._updateSelectionUI === 'function') this._updateSelectionUI(); } filterTasksByCategory(tasks, category) { @@ -677,6 +686,11 @@ class TasksManager { Delete + @@ -2328,19 +2342,86 @@ class TasksManager { } async clearAllTasks() { - const result = await this._showClearAllModal(this.tasks); + // Routes to one of two modes depending on whether any rows are ticked. + // Both paths share _showClearAllModal — same UX, same modal, different + // input list + title. + const hasSelection = this.selectedTaskIds.size > 0; + const targetTasks = hasSelection + ? this.tasks.filter(t => this.selectedTaskIds.has(t.id)) + : this.tasks; + const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all'); if (!result || !result.confirmed) return false; - await this.performClearAll({ cancelRunning: result.cancelRunning }); + await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks }); + if (hasSelection) { + // Drop any ids we just deleted from the selection set, then refresh + // the button label + master-checkbox state. + this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id))); + this._updateSelectionUI(); + } return true; } + // Selection helpers wired by the row + master checkboxes. + toggleTaskSelection(taskId, checked) { + if (checked) this.selectedTaskIds.add(taskId); + else this.selectedTaskIds.delete(taskId); + this._updateSelectionUI(); + } + + toggleSelectAll(checked) { + if (checked) { + // Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes + // rather than this.tasks so category-filtered views only select + // what the user can see. + const boxes = document.querySelectorAll('#tasks-list [data-task-select]'); + boxes.forEach((cb) => { + this.selectedTaskIds.add(cb.dataset.taskSelect); + cb.checked = true; + }); + } else { + this.selectedTaskIds.clear(); + document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; }); + } + this._updateSelectionUI(); + } + + // Rewrites the Clear All button label + the master-checkbox indeterminate + // state to reflect the current selection. Cheap — only touches a few + // DOM nodes, safe to call from any selection-change path. + _updateSelectionUI() { + const n = this.selectedTaskIds.size; + const btn = document.getElementById('tasks-clear-btn'); + const btnLabel = btn && btn.querySelector('.clear-btn-label'); + if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All'; + if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks'; + + // Master checkbox: checked when ALL visible are picked, indeterminate + // when SOME are, unchecked when none. + const master = document.getElementById('tasks-select-all'); + const visible = document.querySelectorAll('#tasks-list [data-task-select]'); + if (master) { + if (n === 0 || visible.length === 0) { + master.checked = false; + master.indeterminate = false; + } else if (n >= visible.length) { + master.checked = true; + master.indeterminate = false; + } else { + master.checked = false; + master.indeterminate = true; + } + } + } + // Confirmation modal for clearAllTasks. Same openEoModal shape as // _showDeleteTaskModal so visual identity matches every other destructive // confirmation (Uninstall, Delete Task, …). Adds a "Cancel running tasks // too" toggle (off by default) — when off, running/queued tasks are // skipped; when on, they're cancelled first then deleted. // Resolves {confirmed, cancelRunning}. Cancel/backdrop/close → confirmed=false. - _showClearAllModal(tasks) { + // mode: 'all' (everything) or 'selected' (user-picked subset) — just + // shapes the title/copy. + _showClearAllModal(tasks, mode) { return new Promise((resolve) => { const escHtml = (s) => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); @@ -2389,12 +2470,21 @@ class TasksManager { resolve(val); }; + // Title shape depends on which path got us here: + // selected → "Delete N selected task(s)?" (multi-select chip) + // all → "Delete all N tasks?" (legacy Clear All) + const isSelectedMode = mode === 'selected'; + const titleText = isSelectedMode + ? (total === 1 ? 'Delete 1 selected task?' : `Delete ${total} selected tasks?`) + : (total === 1 ? 'Delete 1 task?' : `Delete all ${total} tasks?`); const m = window.openEoModal({ id: 'clear-all-tasks-modal', size: 'sm', - eyebrow: 'Delete Tasks', - title: total === 1 ? 'Delete 1 task?' : `Delete all ${total} tasks?`, - desc: 'Confirm to delete the selected tasks.', + eyebrow: isSelectedMode ? 'Delete Selected' : 'Delete Tasks', + title: titleText, + desc: isSelectedMode + ? 'Confirm to delete the ticked tasks.' + : 'Confirm to delete every task on the list.', body: bodyHtml, actions: [ { @@ -2432,6 +2522,10 @@ class TasksManager { async performClearAll(opts) { opts = opts || {}; const cancelRunning = !!opts.cancelRunning; + // Caller (clearAllTasks) passes the subset to operate on. Falls back + // to this.tasks for backward compatibility — old direct callers + // continue to mean "everything". + const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []); // Show progress notification with the trash type icon in the left slot // (this is conceptually a delete-all action, so 🗑️ matches the row icon). @@ -2447,8 +2541,8 @@ class TasksManager { const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending'); // Partition: which tasks we attempt now, which we skip silently. - const targets = (this.tasks || []).filter(t => cancelRunning || !isActive(t)); - const skipped = (this.tasks || []).filter(t => !targets.includes(t)); + const targets = universe.filter(t => cancelRunning || !isActive(t)); + const skipped = universe.filter(t => !targets.includes(t)); try { // For active tasks (only reached when the toggle is on): cancel first,