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
-
+
@@ -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,