Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aafe332a7 | ||
|
|
ca224e95c1 | ||
|
|
1b4463a3c1 | ||
|
|
45567fc520 |
No files matched your search
+31
-28
@@ -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>`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user