Show "Converting..." once an upload's bytes are fully sent

fetch() has no cross-browser upload progress event, so the upload
path now uses XMLHttpRequest instead -- its upload.progress firing
with loaded === total is the only client-observable signal that the
server has moved from receiving bytes to converting them, worth
surfacing now that a large POI export's conversion (GpxStore#save)
can itself take several seconds on top of the transfer. Multi-file
uploads now also show which file (i of N) is in flight, previously
just a static "Uploading N files..." for the whole batch.

Verified the 401 path still redirects to /login through the new XHR
flow the same way fetch/redirectIfUnauthorized did.
This commit is contained in:
randy committed 2026-09-08 00:05:40 +02:00
1 parent 37bc14f3ba
commit 45567fc520
1 file changed
+34 -6
+34 -6
View File
@@ -720,19 +720,29 @@ export class GpxPanelControl {
const files = [...fileList].filter((file) => /\.(gpx|fit|geojson|zip)$/i.test(file.name)); const files = [...fileList].filter((file) => /\.(gpx|fit|geojson|zip)$/i.test(file.name));
if (files.length === 0) return; if (files.length === 0) return;
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`);
let tree = null; let tree = null;
let failed = 0; let failed = 0;
for (const file of files) { for (const [index, file] of files.entries()) {
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${index + 1} of ${files.length}…`);
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("folder", folder); formData.append("folder", folder);
try { try {
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData }); // fetch() has no cross-browser upload progress event, so this one
if (redirectIfUnauthorized(response)) return; // upload uses XMLHttpRequest instead — its upload.progress firing
if (!response.ok) throw new Error("upload failed"); // with loaded === total is the only client-observable signal that
({ tree } = await response.json()); // the server has (or is about to start) converting rather than
// still receiving bytes, worth surfacing now that a several-
// thousand-waypoint POI export's conversion can itself take
// several more seconds on top of the transfer (GpxStore#save).
const xhr = await uploadFile(formData, () => this._setStatus("Converting…"));
if (xhr.status === 401) {
window.location.href = "/login";
return;
}
if (xhr.status < 200 || xhr.status >= 300) throw new Error("upload failed");
({ tree } = JSON.parse(xhr.responseText));
} catch (err) { } catch (err) {
failed += 1; failed += 1;
} }
@@ -814,6 +824,24 @@ function redirectIfUnauthorized(response) {
return true; return true;
} }
// XMLHttpRequest rather than fetch() — only so onUploadComplete (the
// upload.progress event) is available at all; every other write action in
// this file stays on fetch()/redirectIfUnauthorized. Resolves with the xhr
// itself (not just responseText) so the caller can still read .status the
// same way a fetch Response would let it.
function uploadFile(formData, onUploadComplete) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", UPLOAD_URL);
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.loaded === event.total) onUploadComplete();
});
xhr.addEventListener("load", () => resolve(xhr));
xhr.addEventListener("error", () => reject(new Error("network error")));
xhr.send(formData);
});
}
// Rasterizes a hand-authored SVG icon to ImageData for map.addImage(), at a // Rasterizes a hand-authored SVG icon to ImageData for map.addImage(), at a
// fixed square size (the source SVGs have no explicit width/height, just a // fixed square size (the source SVGs have no explicit width/height, just a
// viewBox, so one is injected — otherwise an <img> falls back to the CSS // viewBox, so one is injected — otherwise an <img> falls back to the CSS