Compare commits

...
4 Commits
Author SHA1 Message Date
randy 4aafe332a7 Fix upload/new-folder buttons not actually hiding when logged out
CI / deploy (push) Successful in 25s
CI / check (push) Successful in 47s
.gpx-panel__action's own "display: flex" outranks the browser's
default "[hidden] { display: none }" -- same specificity, but an
author stylesheet rule always beats a user-agent one -- so setting
.hidden = true on these (GpxPanelControl#mount) never had any visual
effect. The per-file row icons (delete/download) were unaffected by
this since they're conditionally never added to the DOM at all rather
than hidden after the fact, a different mechanism that doesn't hit
this bug.

Same class of bug as the earlier Distance/Area overlap fix. Swept the
rest of the app's [hidden]-manipulated elements for the same pattern
(a display override with no [hidden] counterpart) -- this was the
only other one.

Verified in a real browser: computed display is now "none", not just
the hidden attribute being set with no visual effect.
2026-09-08 00:16:10 +02:00
randy ca224e95c1 Show the tree's file names without the .geojson suffix
The suffix ("ride.gpx" -> "ride.gpx.geojson" once cached) explained
what the name would download back when it doubled as a GeoJSON-only
download link. Now that downloading is the download button's job
(previous commit), it was just confusing -- the file itself is still
named "ride.gpx".
2026-09-08 00:11:41 +02:00
randy 1b4463a3c1 Consolidate original/GeoJSON download into one menu button
The file name doubling as a GeoJSON-only download link was easy to
miss as a link at all, and only ever covered half of what's actually
downloadable. One button next to delete now opens a small menu
(Original file / GeoJSON) instead -- costs a click when downloading
several files in a row, but both formats are reachable from one,
discoverable place. Only one menu open at a time, closed on any
outside click.

Verified in a real browser: opens with both items, closes on outside
click, no console errors.
2026-09-08 00:10:02 +02:00
randy 45567fc520 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.
2026-09-08 00:05:40 +02:00
3 changed files with 198 additions and 79 deletions

No files matched your search

+31 -28
View File
@@ -338,34 +338,37 @@ app, and not yet opened. Badging the common case would just be noise,
so the file tree's icon (`PENDING_ICON`, `public/js/icons.js`) marks
that exception instead of the (near-universal) ready state.
+
Once a `.gpx`/`.fit` is cached, the tree shows it under its converted
name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching
the real cache file sitting next to it on disk — since the GeoJSON,
not the original, is what's actually rendered on the map from that
point on. The original is never deleted or re-parsed once cached, just
not re-shown as itself, so both forms need a way out of the app as an
actual file: the file name itself (`GET /api/gpx/file/<path>?download=geojson`)
is a real link, not a `<span>` — clicking it downloads the GeoJSON
rather than also toggling the row's checkbox, since a native
`<a>`/`<button>` nested inside a `<label>` claims its own click
instead of the label forwarding it to the associated control — and a
second button next to delete (`?download=original`) downloads the
untouched, as-uploaded file, since the name alone only covers half of
what's now downloadable and isn't an obvious click target regardless.
A directly-uploaded `.geojson` keeps its own name and both buttons
download the same (identical) content — redundant there, not wrong.
Both kinds of download require login, same as upload/move/delete/
create-folder — `GET /api/gpx/file` itself stays open to anyone
(viewing a file on the map isn't a write action), just not
`?download=...` on it. Since both links are real `<a href>`s (so
right-click/middle-click still do something sensible), a logged-out
click can't just be a plain navigation the way delete/upload already
aren't — `_download` (`gpx.js`) fetches instead, checks for a 401 the
same way `redirectIfUnauthorized` already does for every other write
action, and only then turns the response into a blob and clicks a
throwaway `<a download>` to trigger the actual save — a plain
navigation would otherwise land on a raw JSON error page instead of
being sent to log in. `Gpx` parses the GPX with REXML: `<wpt>`
The tree always shows a `.gpx`/`.fit` file under its real, as-uploaded
name, even once cached — it briefly grew a `.geojson` suffix there
(matching the real cache file's name on disk) back when the name
doubled as a GeoJSON-only download link and the suffix explained what
clicking it would get you; now that downloading is the download
button's job (below), the suffix was just confusing; the file itself
is still named `"ride.gpx"`. The original is never deleted or
re-parsed once cached, just not re-shown as itself once the GeoJSON
becomes what's actually rendered on the map, so both forms need a way
out of the app as an actual file: one download button, next to
delete, opens a small menu ("Original file" / "GeoJSON",
`_downloadButton`/`_downloadMenuItem`). Only one menu open at a time
(`_toggleDownloadMenu`/`_closeDownloadMenu`), closed by a
document-level click listener the same way an outside click closes a
`sidebar-flyout` dropdown, just scoped to this one control rather than
threaded through `onlyOneSidePanelOpen`. A directly-uploaded `.geojson`
keeps its own name and both menu items download the same (identical)
content — redundant there, not wrong. Both downloads (`GET
/api/gpx/file/<path>?download=original`/`?download=geojson`) require
login, same as upload/move/delete/create-folder, so the whole button —
not just its menu items — is left out of the DOM when logged out
rather than shown and failing on click (see "Authentication" below);
`GET /api/gpx/file` itself stays open to anyone otherwise (viewing a
file on the map isn't a write action), just not `?download=...` on it.
Each menu item is a real `<a href>` (so right-click/middle-click still
do something sensible), but a plain navigation would land a logged-out
click on a raw JSON error page instead of being sent to log in —
`_download` (`gpx.js`) fetches instead, checks for a 401 the same way
`redirectIfUnauthorized` already does for every other write action,
and only then turns the response into a blob and clicks a throwaway
`<a download>` to trigger the actual save. `Gpx` parses the GPX with REXML: `<wpt>`
waypoints become `Point` features (with `name`, `desc` and `time`
carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
+49
View File
@@ -277,6 +277,14 @@ html, body {
cursor: pointer;
}
/* This class's own "display: flex" above otherwise wins over the browser
default "[hidden] { display: none }" — same specificity, but an author
rule always outranks a user-agent one — so GpxPanelControl#mount hiding
these (logged out: no upload/new-folder) had no visual effect at all. */
.gpx-panel__action[hidden] {
display: none;
}
.gpx-panel__action svg {
width: 18px;
height: 18px;
@@ -379,11 +387,20 @@ html, body {
height: 14px;
}
.gpx-tree__download-wrapper {
position: relative;
flex-shrink: 0;
display: flex;
}
.gpx-tree__download {
flex-shrink: 0;
display: flex;
border: none;
background: none;
color: #999;
padding: 0 4px;
cursor: pointer;
}
.gpx-tree__download svg {
@@ -395,6 +412,38 @@ html, body {
color: #333;
}
.gpx-tree__download-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 20;
min-width: max-content;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px;
font-size: 13px;
}
.gpx-tree__download-menu[hidden] {
display: none;
}
.gpx-tree__download-menu-item {
display: block;
padding: 6px 10px;
border-radius: 4px;
color: inherit;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.gpx-tree__download-menu-item:hover {
background: #f0f0f0;
}
.gpx-tree__delete {
flex-shrink: 0;
border: none;
+118 -51
View File
@@ -53,6 +53,7 @@ export class GpxPanelControl {
this._visible = new Set(); // file paths currently shown on the map
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
// setStyle() (switching the Online/Offline base map) tears down all
// custom sources/layers/images — re-register the category glyphs before
@@ -130,6 +131,14 @@ export class GpxPanelControl {
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
// Closes whichever per-file download menu is open on any click that
// isn't the toggle button that opened it (that one calls
// stopPropagation so this never fights it) — same "outside click
// closes it" behavior sidebar-flyout dropdowns already have, just not
// wired through onlyOneSidePanelOpen since these aren't full side
// panels.
document.addEventListener("click", () => this._closeDownloadMenu());
this._setupToolbarActions();
this._setupRootDropTarget();
this._setupMapDropTarget();
@@ -287,37 +296,16 @@ export class GpxPanelControl {
checkbox.type = "checkbox";
checkbox.checked = this._visible.has(file.path);
checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox));
// Once a .gpx/.fit is cached, its converted GeoJSON is the thing that's
// actually shown on the map from here on (see GpxStore#geojson) — the
// name reflects that (matching the real "<path>.geojson" cache file on
// disk) rather than still reading as a plain, un-converted .gpx/.fit.
// A direct .geojson upload already ends in .geojson, so it's left
// alone.
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
// Just the real, as-uploaded name — it used to grow a ".geojson" suffix
// once cached (matching the real "<path>.geojson" cache file on disk),
// back when the name doubled as a GeoJSON-only download link and the
// suffix explained what clicking it would get you. Now that downloading
// is the download button's job (see below), that suffix was just
// confusing: the file is still named "ride.gpx", not "ride.gpx.geojson".
const loggedIn = isLoggedIn();
// Logged in: an <a>, not a <span> — clicking it downloads that GeoJSON,
// the only way to get it out of the app, since the original .gpx/.fit
// is never deleted but also never re-shown once converted. An <a>
// nested in a <label> doesn't also toggle the checkbox on click (unlike
// a plain element), so this doesn't fight the row's own
// toggle-on-map behavior. href kept pointing at the real endpoint (so
// hover/right-click/middle-click still do something sensible) but the
// actual click goes through _download, which redirects to /login on a
// 401 rather than navigating to a raw JSON error page (this endpoint
// requires login the same as upload/move/delete, see app.rb).
// Logged out: plain text — nothing here would actually work, so it
// isn't shown as if it would (same reasoning as the buttons below).
const name = document.createElement(loggedIn ? "a" : "span");
const name = document.createElement("span");
name.className = "gpx-tree__file-name";
name.textContent = displayName;
if (loggedIn) {
name.href = `${gpxFileUrl(file.path)}?download=geojson`;
name.title = `Download ${displayName}`;
name.addEventListener("click", (event) => {
event.preventDefault();
this._download(name.href, displayName);
});
}
name.textContent = file.name;
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (!file.cached) label.appendChild(this._pendingIcon());
@@ -357,24 +345,75 @@ export class GpxPanelControl {
return icon;
}
// The file's name already downloads its GeoJSON (?download=geojson,
// matching what's displayed) — this covers the other half: the
// untouched, as-uploaded file, which otherwise has no way out of the
// app once it stops being shown as itself. For a native .geojson
// upload the two downloads are identical (nothing to convert), so this
// isn't hidden for those — just redundant, not wrong.
// One button, one obvious place for both download kinds — clicking it
// opens a small menu (Original / GeoJSON) instead of splitting them
// across two differently-shaped click targets (the file name used to be
// a GeoJSON-only download link; easy to miss it was a link at all, and
// it only ever covered half the story). Costs a click for the common
// case of downloading several files in a row, in exchange for both
// formats actually being reachable from one, discoverable place.
_downloadButton(file) {
const link = document.createElement("a");
link.className = "gpx-tree__download";
link.href = `${gpxFileUrl(file.path)}?download=original`;
link.title = "Download original file";
link.setAttribute("aria-label", "Download original file");
link.innerHTML = DOWNLOAD_ICON;
link.addEventListener("click", (event) => {
event.preventDefault();
this._download(link.href, file.name);
const wrapper = document.createElement("span");
wrapper.className = "gpx-tree__download-wrapper";
const button = document.createElement("button");
button.type = "button";
button.className = "gpx-tree__download";
button.title = "Download";
button.setAttribute("aria-label", "Download");
button.innerHTML = DOWNLOAD_ICON;
const menu = document.createElement("div");
menu.className = "gpx-tree__download-menu";
menu.hidden = true;
const geojsonName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
menu.appendChild(this._downloadMenuItem("Original file", `${gpxFileUrl(file.path)}?download=original`, file.name));
menu.appendChild(this._downloadMenuItem("GeoJSON", `${gpxFileUrl(file.path)}?download=geojson`, geojsonName));
// stopPropagation so this click doesn't immediately reach the
// document-level listener (mount()) that closes whichever menu is
// open on an outside click — otherwise it would close right back up
// the instant it opened.
button.addEventListener("click", (event) => {
event.stopPropagation();
this._toggleDownloadMenu(menu);
});
return link;
wrapper.appendChild(button);
wrapper.appendChild(menu);
return wrapper;
}
_downloadMenuItem(label, url, filename) {
const item = document.createElement("a");
item.className = "gpx-tree__download-menu-item";
item.href = url;
item.textContent = label;
item.addEventListener("click", (event) => {
event.preventDefault();
this._closeDownloadMenu();
this._download(url, filename);
});
return item;
}
// Only one menu open at a time — opening a second one closes whichever
// was already open, same as onlyOneSidePanelOpen does for the sidebar's
// own panels (panels.js), just scoped to this one control instead of
// shared across the page.
_toggleDownloadMenu(menu) {
if (this._openDownloadMenu === menu) {
this._closeDownloadMenu();
return;
}
this._closeDownloadMenu();
menu.hidden = false;
this._openDownloadMenu = menu;
}
_closeDownloadMenu() {
if (this._openDownloadMenu) this._openDownloadMenu.hidden = true;
this._openDownloadMenu = null;
}
// fetch-based rather than a plain navigation so a 401 (not logged in —
@@ -720,19 +759,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 +863,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 <img> falls back to the CSS