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

View File

@@ -102,3 +102,16 @@ deployments:
name: photo-pool name: photo-pool
work_queue_name: null work_queue_name: null
job_variables: {} 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: {}