Fix selection state: derive from DOM (single source of truth), clear browser-restored form state on load

This commit is contained in:
2026-08-08 10:47:26 +10:00
parent a15ee5728d
commit a6d0ced949
2 changed files with 38 additions and 24 deletions

View File

@@ -51,7 +51,7 @@
<a href="/review?status=approved">Approved</a>
</header>
<div class="filters">
<form method="get" action="/review" style="display:flex;gap:.5rem;">
<form method="get" action="/review" style="display:flex;gap:.5rem;" autocomplete="off">
<select name="status">
<option value="">all statuses</option>
{% for s in ["scanned", "review", "approved", "rejected"] %}
@@ -62,7 +62,7 @@
<button type="submit">Filter</button>
</form>
<button class="selall" id="sel-all" type="button">
<input type="checkbox" id="sel-all-box" style="pointer-events:none;"> Select all on page
<input type="checkbox" id="sel-all-box" style="pointer-events:none;" autocomplete="off"> Select all on page
</button>
</div>
<div class="bulk-bar" id="bulk-bar">
@@ -83,29 +83,26 @@
</div>
</main>
<script>
// selection state
let selected = new Set();
const bar = document.getElementById('bulk-bar');
const countEl = document.getElementById('sel-count');
// Selection is DERIVED from DOM checkboxes — single source of truth.
// No separate Set that can drift from the visible state.
function getSelected() {
return [...document.querySelectorAll('.item-check:checked')].map(cb => cb.value);
}
function refresh() {
countEl.textContent = selected.size + ' selected';
bar.classList.toggle('visible', selected.size > 0);
const n = getSelected().length;
document.getElementById('sel-count').textContent = n + ' selected';
document.getElementById('bulk-bar').classList.toggle('visible', n > 0);
document.querySelectorAll('.card').forEach(c => {
const cb = c.querySelector('.item-check');
if (cb) c.classList.toggle('selected', selected.has(cb.value));
if (cb) c.classList.toggle('selected', cb.checked);
});
}
// any checkbox change → refresh derived state
document.addEventListener('change', e => {
if (e.target.classList.contains('item-check')) {
if (e.target.checked) selected.add(e.target.value); else selected.delete(e.target.value);
refresh();
}
if (e.target.id === 'sel-all-box') {
document.querySelectorAll('.item-check').forEach(cb => {
if (e.target.checked) selected.add(cb.value); else selected.delete(cb.value);
});
if (e.target.classList.contains('item-check') || e.target.id === 'sel-all-box') {
refresh();
}
});
@@ -114,15 +111,21 @@
const box = document.getElementById('sel-all-box');
box.checked = !box.checked;
document.querySelectorAll('.item-check').forEach(cb => {
if (box.checked) selected.add(cb.value); else selected.delete(cb.value);
cb.checked = box.checked;
});
refresh();
});
// clear any browser-restored form state on load (defensive)
window.addEventListener('load', () => {
document.querySelectorAll('.item-check').forEach(cb => { cb.checked = false; });
document.getElementById('sel-all-box').checked = false;
refresh();
});
async function bulk(action) {
if (selected.size === 0) return;
const shas = [...selected];
// disable buttons during the operation (cross-filesystem moves can be slow)
const shas = getSelected();
if (shas.length === 0) return;
setBulkBusy(true);
try {
const resp = await fetch('/review/bulk', {
@@ -131,15 +134,13 @@
body: JSON.stringify({ action, shas })
});
const data = await resp.json();
// remove processed cards from DOM immediately (match by sha in value attr)
shas.forEach(s => {
const cb = document.querySelector(`.item-check[value="${s}"]`);
if (cb) cb.closest('.card')?.remove();
});
selected.clear();
refresh();
updateCount();
showToast(`Bulk ${action}: ${data.moved} done, ${data.missing} missing`);
showToast(`Bulk ${action}: ${data.updated ?? data.moved} done, ${data.missing ?? 0} missing`);
} catch (e) {
showToast('Bulk action failed: ' + e, true);
} finally {

View File

@@ -102,3 +102,16 @@ deployments:
name: photo-pool
work_queue_name: null
job_variables: {}
- name: staging
version: null
tags: [photo-pipeline]
description: "Materialize review decisions: move approved/rejected files to staging dirs"
schedule: null
flow_name: null
entrypoint: process_staging.py:process_staging
parameters: {}
work_pool:
name: photo-pool
work_queue_name: null
job_variables: {}