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:
co-authored by
Claude Fable 5
parent
0401c3351b
commit
cf6dd5e134
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user