Photo upload gets a visible lifecycle

A phone photo is several MB — seconds of dead air in which the user
reasonably assumes it worked and moves on, losing the upload if they
navigate or lock the phone (exactly how a photo went missing today).
The dropzone now narrates: uploading with count and size ("keep this
page open"), a green ✓ naming each saved file, reverting after a few
seconds; failures keep their alert but also reset the zone. A
beforeunload guard makes the browser ask before abandoning an
in-flight upload, re-picking the same file re-fires, and the gallery
refresh is forced so the new photo appears immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 17:10:13 -04:00
co-authored by Claude Fable 5
parent 0401c3351b
commit cf6dd5e134
3 changed files with 47 additions and 3 deletions
+13
View File
@@ -1693,5 +1693,18 @@
"IMG_4573.jpeg" "IMG_4573.jpeg"
], ],
"title_normalized": "blackboa rd" "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"
} }
] ]
+2
View File
@@ -406,6 +406,8 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
box-shadow: none; box-shadow: none;
} }
#dropzone.hot, #dropzone:hover { border-style: solid; background: #fdeebb; } #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 { #joblog {
background: var(--navy); color: #eaf1fb; background: var(--navy); color: #eaf1fb;
font-family: var(--font-mono); font-size: .78rem; font-family: var(--font-mono); font-size: .78rem;
+32 -3
View File
@@ -83,25 +83,54 @@ zone.addEventListener("drop", e => {
}); });
pick.addEventListener("change", () => sendPhotos(pick.files)); pick.addEventListener("change", () => sendPhotos(pick.files));
const ZONE_IDLE = "drop shelf photos here, or click to choose";
let UPLOADING = false;
async function sendPhotos(files) { async function sendPhotos(files) {
if (!files.length) return; if (!files.length || UPLOADING) return;
const form = new FormData(); const form = new FormData();
for (const f of files) form.append("files", f); 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 { try {
res = await fetch("/api/photos", {method: "POST", body: form}); res = await fetch("/api/photos", {method: "POST", body: form});
} catch (err) { } catch (err) {
alert("Upload failed (no response from the server): " + 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) { if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null); const detail = await res.json().then(d => d.detail).catch(() => null);
zone.textContent = ZONE_IDLE;
alert("Upload failed: " + (detail ?? res.statusText)); alert("Upload failed: " + (detail ?? res.statusText));
return; 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 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)); refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner("")); pollLoop(refresh, 5000, () => showBanner(""));
</script> </script>