From 45567fc5203cf2ee71bf975afa81904895fe3dc0 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Tue, 8 Sep 2026 00:05:40 +0200 Subject: [PATCH] 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. --- public/js/gpx.js | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/public/js/gpx.js b/public/js/gpx.js index eaf8744..7405cee 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -720,19 +720,29 @@ export class GpxPanelControl { const files = [...fileList].filter((file) => /\.(gpx|fit|geojson|zip)$/i.test(file.name)); if (files.length === 0) return; - this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`); let tree = null; 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(); formData.append("file", file); formData.append("folder", folder); try { - const response = await fetch(UPLOAD_URL, { method: "POST", body: formData }); - if (redirectIfUnauthorized(response)) return; - if (!response.ok) throw new Error("upload failed"); - ({ tree } = await response.json()); + // fetch() has no cross-browser upload progress event, so this one + // upload uses XMLHttpRequest instead — its upload.progress firing + // with loaded === total is the only client-observable signal that + // 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) { failed += 1; } @@ -814,6 +824,24 @@ function redirectIfUnauthorized(response) { 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 // fixed square size (the source SVGs have no explicit width/height, just a // viewBox, so one is injected — otherwise an falls back to the CSS