diff --git a/data/titles.json b/data/titles.json index 1e9c63a..731c066 100644 --- a/data/titles.json +++ b/data/titles.json @@ -1693,5 +1693,18 @@ "IMG_4573.jpeg" ], "title_normalized": "blackboa rd" + }, + { + "title_raw": "...and then, we held hands.", + "confidence": "high", + "publisher_hint": "LudiCreations", + "edition_hint": "", + "year_hint": null, + "language_hint": "English", + "art_notes": "Blue sky background with white stylized doves/hands motif, cursive white title text", + "source_photos": [ + "image.jpg" + ], + "title_normalized": "and then we held hands" } ] diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 4fc92f1..ac8830d 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -406,6 +406,8 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # box-shadow: none; } #dropzone.hot, #dropzone:hover { border-style: solid; background: #fdeebb; } +#dropzone[aria-busy="true"] { cursor: progress; opacity: .8; } +#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: #e2f2e4; } #joblog { background: var(--navy); color: #eaf1fb; font-family: var(--font-mono); font-size: .78rem; diff --git a/src/bggpipe/templates/pages/photos.html b/src/bggpipe/templates/pages/photos.html index d80352f..ad1c1a4 100644 --- a/src/bggpipe/templates/pages/photos.html +++ b/src/bggpipe/templates/pages/photos.html @@ -83,25 +83,54 @@ zone.addEventListener("drop", e => { }); pick.addEventListener("change", () => sendPhotos(pick.files)); +const ZONE_IDLE = "drop shelf photos here, or click to choose"; +let UPLOADING = false; + async function sendPhotos(files) { - if (!files.length) return; + if (!files.length || UPLOADING) return; const form = new FormData(); for (const f of files) form.append("files", f); - let res; + const mb = ([...files].reduce((total, f) => total + f.size, 0) / 1048576).toFixed(1); + UPLOADING = true; + zone.disabled = true; + zone.setAttribute("aria-busy", "true"); + zone.classList.remove("ok"); + zone.textContent = `uploading ${files.length} photo(s) — ${mb} MB… keep this page open`; + let res = null; try { res = await fetch("/api/photos", {method: "POST", body: form}); } catch (err) { alert("Upload failed (no response from the server): " + err); - return; } + UPLOADING = false; + zone.disabled = false; + zone.removeAttribute("aria-busy"); + pick.value = ""; // re-picking the same photo must fire change again + if (!res) { zone.textContent = ZONE_IDLE; return; } if (!res.ok) { const detail = await res.json().then(d => d.detail).catch(() => null); + zone.textContent = ZONE_IDLE; alert("Upload failed: " + (detail ?? res.statusText)); return; } + const saved = await res.json().then(d => d.saved || []).catch(() => []); + zone.classList.add("ok"); + zone.textContent = `✓ saved ${saved.join(", ")} — add another?`; + setTimeout(() => { + if (!UPLOADING && zone.classList.contains("ok")) { + zone.classList.remove("ok"); + zone.textContent = ZONE_IDLE; + } + }, 6000); + LAST = null; // the new photo must appear even if nothing else changed refresh().catch(() => {}); // the next poll self-heals a refresh hiccup } +// leaving mid-upload silently loses the photo — make the browser ask +window.addEventListener("beforeunload", e => { + if (UPLOADING) e.preventDefault(); +}); + refresh().catch(err => errorBanner(err.message || err)); pollLoop(refresh, 5000, () => showBanner(""));