Badge the exception (not yet cached) instead of the near-universal case

GpxStore#save converts eagerly, so "cached" is true for nearly every
file in the tree already — badging that was just noise. Flipped to
PENDING_ICON, shown only for the rare case: a .gpx/.fit dropped
straight into data/gpx/ on the filesystem, bypassing the app, and not
yet opened.

Also add an explicit download button next to delete (DOWNLOAD_ICON) —
the file name itself has been a download link since the last commit,
but isn't obviously clickable at a glance.
This commit is contained in:
randy committed 2026-09-07 22:59:02 +02:00
1 parent cd92dadf6a
commit 12635a48c3
4 files changed
+73 -26

No files matched your search

+19 -14
View File
@@ -282,26 +282,31 @@ multi-second hang on a checkbox click reads as broken in a way the
same wait during an upload doesn't. A file that fails to parse still same wait during an upload doesn't. A file that fails to parse still
saves fine either way; the error just surfaces on first view instead. saves fine either way; the error just surfaces on first view instead.
A `.geojson` upload skips this path entirely — `geojson` reads and A `.geojson` upload skips this path entirely — `geojson` reads and
returns it directly, no cache file, no `Gpx`/`Fit` involved; the file returns it directly, no cache file, no `Gpx`/`Fit` involved.
tree's cached-file icon (`GEOJSON_ICON`, `public/js/icons.js`) always `GpxStore#save` converting eagerly means `cached` is `true` for
shows for one, since there's no conversion step it could ever be almost everything in the tree — the one exception is a `.gpx`/`.fit`
waiting on. dropped straight into `data/gpx/` on the filesystem, bypassing the
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 Once a `.gpx`/`.fit` is cached, the tree shows it under its converted
name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching
the real cache file sitting next to it on disk — since the GeoJSON, 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 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 point on. The original is never deleted or re-parsed once cached, just
not re-shown as itself; the tree's file name is a real link not re-shown as itself. Two ways to get that converted form out of the
(`GET /api/gpx/file/<path>?download=1`), the only way to get that app as an actual file (`GET /api/gpx/file/<path>?download=1`) — the
converted form out of the app as an actual file, since the cache is cache is otherwise an implementation detail with no name of its own to
otherwise an implementation detail with no name of its own to expose. expose: the file name itself is a real link (an `<a>`, not a `<span>`,
It's an `<a>`, not a `<span>`, specifically so clicking it downloads specifically so clicking it downloads instead of also toggling the
instead of also toggling the row's checkbox — a native `<a>`/`<button>` row's checkbox — a native `<a>`/`<button>` nested inside a `<label>`
nested inside a `<label>` claims its own click instead of the label claims its own click instead of the label forwarding it to the
forwarding it to the associated control, so the two behaviors don't associated control, so the two behaviors don't fight over the same
fight over the same click. A directly-uploaded `.geojson` keeps its click), and a dedicated download button sits next to delete for anyone
own name (nothing to convert) and downloads unchanged. `Gpx` parses the GPX with REXML: `<wpt>` who wouldn't guess the name itself is clickable. A directly-uploaded
`.geojson` keeps its own name (nothing to convert) and downloads
unchanged either way. `Gpx` parses the GPX with REXML: `<wpt>`
waypoints become `Point` features (with `name`, `desc` and `time` waypoints become `Point` features (with `name`, `desc` and `time`
carried through as properties), each `<trk>` becomes one carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>` `MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
+16
View File
@@ -379,6 +379,22 @@ html, body {
height: 14px; height: 14px;
} }
.gpx-tree__download {
flex-shrink: 0;
display: flex;
color: #999;
padding: 0 4px;
}
.gpx-tree__download svg {
width: 14px;
height: 14px;
}
.gpx-tree__download:hover {
color: #333;
}
.gpx-tree__delete { .gpx-tree__delete {
flex-shrink: 0; flex-shrink: 0;
border: none; border: none;
+19 -5
View File
@@ -6,7 +6,8 @@ import {
WAYPOINT_ICON, WAYPOINT_ICON,
TRACK_ICON, TRACK_ICON,
ROUTE_ICON, ROUTE_ICON,
GEOJSON_ICON, PENDING_ICON,
DOWNLOAD_ICON,
MAP_MARKER_ICON, MAP_MARKER_ICON,
POI_ICONS, POI_ICONS,
PARK4NIGHT_TAGS, PARK4NIGHT_TAGS,
@@ -290,9 +291,10 @@ export class GpxPanelControl {
name.title = `Download ${displayName}`; name.title = `Download ${displayName}`;
label.appendChild(checkbox); label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind))); (file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (file.cached) label.appendChild(this._cachedIcon()); if (!file.cached) label.appendChild(this._pendingIcon());
label.appendChild(name); label.appendChild(name);
row.appendChild(label); row.appendChild(label);
row.appendChild(this._downloadButton(file));
row.appendChild(this._deleteButton(() => this._deleteFile(file.path))); row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
return row; return row;
@@ -314,14 +316,26 @@ export class GpxPanelControl {
return icon; return icon;
} }
_cachedIcon() { _pendingIcon() {
const icon = document.createElement("span"); const icon = document.createElement("span");
icon.className = "gpx-tree__kind-icon"; icon.className = "gpx-tree__kind-icon";
icon.innerHTML = GEOJSON_ICON; icon.innerHTML = PENDING_ICON;
icon.title = "Ready to view instantly (GeoJSON cached)"; icon.title = "Not yet converted — dropped straight into data/gpx/, not uploaded through the app";
return icon; return icon;
} }
// Same destination as clicking the file's name (gpxFileUrl + ?download=1)
// — a second, more obviously clickable entry point for the same action.
_downloadButton(file) {
const link = document.createElement("a");
link.className = "gpx-tree__download";
link.href = `${gpxFileUrl(file.path)}?download=1`;
link.title = "Download GeoJSON";
link.setAttribute("aria-label", "Download GeoJSON");
link.innerHTML = DOWNLOAD_ICON;
return link;
}
_deleteButton(onClick) { _deleteButton(onClick) {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
+19 -7
View File
@@ -54,14 +54,26 @@ export const WAYPOINT_ICON = `
export const TRACK_ICON = GPX_ICON; export const TRACK_ICON = GPX_ICON;
// Shown next to a file whose GeoJSON conversion is already cached on disk // Shown next to a file whose GeoJSON conversion is NOT yet cached on disk
// (GpxStore#geojson) — ready to view instantly instead of paying REXML's // (GpxStore#geojson) — the uncommon case, since GpxStore#save converts
// parse cost on click. Curly braces read as "structured data ready", the // eagerly at upload time; this only happens for a .gpx/.fit dropped
// same shorthand most editors/tools use for JSON. // straight into data/gpx/ on the filesystem, bypassing the app, and not
export const GEOJSON_ICON = ` // yet opened. Everything uploaded normally is cached almost immediately,
// so badging the common case would just be noise — this badges the
// exception instead.
export const PENDING_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 4c-1.5 0-2 .8-2 2v3c0 1-.5 1.5-1.5 1.5 1 0 1.5.5 1.5 1.5v3c0 1.2.5 2 2 2"/> <circle cx="12" cy="12" r="8"/>
<path d="M15 4c1.5 0 2 .8 2 2v3c0 1 .5 1.5 1.5 1.5-1 0-1.5.5-1.5 1.5v3c0 1.2-.5 2-2 2"/> <path d="M12 8v4l3 2"/>
</svg>`;
// A small download button next to each tree file's delete button — the
// name link (see gpx.js's _renderFile) does the same thing, but isn't
// obviously clickable at a glance, so this is the discoverable version.
export const DOWNLOAD_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v11M8 11l4 4 4-4"/>
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
</svg>`; </svg>`;
// Same shape as TRACK_ICON, dashed — a route is planned rather than // Same shape as TRACK_ICON, dashed — a route is planned rather than