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.
This commit is contained in:
randy committed 2026-09-08 00:10:02 +02:00
1 parent 45567fc520
commit 1b4463a3c1
3 files changed
+149 -62

No files matched your search

+41
View File
@@ -379,11 +379,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 +404,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;
+81 -38
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();
@@ -292,32 +301,15 @@ export class GpxPanelControl {
// 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.
// alone. Plain text, not a link — downloading is the download button's
// job now (see below), one obvious place instead of two differently-
// shaped ones (the name used to double as a GeoJSON-only download
// link, easy to miss and only half the story).
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
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);
});
}
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (!file.cached) label.appendChild(this._pendingIcon());
@@ -357,24 +349,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 —