diff --git a/containers/libreportal/frontend/components/apps/core/css/apps.css b/containers/libreportal/frontend/components/apps/core/css/apps.css index a63fb57..4fdc49b 100644 --- a/containers/libreportal/frontend/components/apps/core/css/apps.css +++ b/containers/libreportal/frontend/components/apps/core/css/apps.css @@ -832,3 +832,22 @@ color: var(--text-primary); outline: none; } + +/* Updates tab multi-select. The checkbox column is always present so rows stay + aligned whether or not an app has something to apply — a column that appears + and disappears per row makes the list jitter as scans land. */ +.ov-pick { display: inline-flex; align-items: center; justify-content: center; width: 18px; margin-right: 6px; flex: 0 0 auto; } +.ov-pick-empty { visibility: hidden; } +.ov-pick-box { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent, #29b6f6); } + +/* The bar holds its space even at zero selected: appearing on first tick would + shift the whole list down under the pointer mid-click. */ +.ov-selbar { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; margin: 0 0 10px; + border: 1px dashed rgba(var(--text-rgb), 0.18); border-radius: 10px; + opacity: 0.55; transition: opacity 120ms ease, border-color 120ms ease; +} +.ov-selbar.is-on { opacity: 1; border-style: solid; border-color: rgba(var(--text-rgb), 0.3); } +.ov-selbar-count { font-size: 13px; color: var(--text-secondary); } +.ov-selbar .updater-btn[disabled] { opacity: 0.45; cursor: not-allowed; } diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index a0b1e2d..be58c85 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -38,6 +38,7 @@ class OverviewManager { this.tabs = null; // TabController (show/hide only) this.filter = 'all'; // Updates-tab chip: all | updates | security this.board = null; // Overview-tab chip: null = auto | all | action + this.selected = new Set(); // Updates-tab multi-select, app slugs this.current = null; } @@ -315,6 +316,25 @@ class OverviewManager { const oa = e.target.closest('[data-overview-action]'); if (oa) { switch (oa.dataset.overviewAction) { + // The checkbox lives inside the row head, which is itself the expander + // target — so this has to claim the click, or picking an app would also + // open its detail panel every time. + case 'select': { + e.stopPropagation(); + const slug = oa.dataset.app; + if (!slug) break; + if (oa.checked) this.selected.add(slug); else this.selected.delete(slug); + this._syncSelectionBar(); + return; + } + case 'select-clear': + this.selected.clear(); + this._applyTab('updates'); + return; + case 'update-selected': + if (this.updater) this.updater.applySelected([...this.selected]); + this.selected.clear(); + return; case 'goto': // Whole-row navigation is a convenience over the row's own button — // don't fire it when the click was really the end of a text drag @@ -576,6 +596,38 @@ class OverviewManager { // ---- Updates tab (expander table + filter chips) ------------------------- + // Prune selections whose app no longer has an update — a rescan can land + // while a selection is open, and a stale pick would silently do nothing. + _liveSelection() { + const up = this.updater; + if (!up) return []; + const avail = new Set(up.apps.filter((a) => a.update_available).map((a) => a.name)); + for (const s of [...this.selected]) if (!avail.has(s)) this.selected.delete(s); + return [...this.selected]; + } + + // Update the bar in place. Re-rendering the whole tab on every tick would + // rebuild the checkboxes underneath the pointer and lose focus mid-selection. + _syncSelectionBar() { + const bar = document.querySelector('.ov-selbar'); + if (!bar) return; + const n = this._liveSelection().length; + bar.classList.toggle('is-on', n > 0); + const label = bar.querySelector('.ov-selbar-count'); + if (label) label.textContent = n === 1 ? '1 app selected' : `${n} apps selected`; + const btn = bar.querySelector('[data-overview-action="update-selected"]'); + if (btn) btn.disabled = n === 0; + } + + renderSelectionBar() { + const n = this._liveSelection().length; + return `
`; + } + renderUpdates() { const up = this.updater; if (!up || !up.apps.length) return `