import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; const TREE_URL = "/api/gpx/tree"; const UPLOAD_URL = "/api/gpx"; const FOLDER_URL = "/api/gpx/folder"; const MOVE_URL = "/api/gpx/move"; const TRACK_COLOR = "#e63946"; // Custom drag payload MIME type for moving an already-uploaded file between // folders, as opposed to an OS file drag (type "Files") for uploading a new // one — both land on the same drop targets, told apart by this. const MOVE_MIME = "application/x-gpx-path"; // A sidebar icon that opens a side panel for uploading GPX files (via // drag & drop, stored server-side in a folder hierarchy the user can add to // and reorganize by dragging), and toggling their tracks/waypoints on and // off the map. export class GpxPanelControl { constructor(map) { this._map = map; this._visible = new Set(); // file paths currently shown on the map this._interactiveLayers = new Set(); // point layer ids with click/hover wired up // setStyle() (switching the Online/Offline base map) tears down all // custom sources and layers — put visible GPX layers back afterwards. map.on("style.load", () => { this._visible.forEach((path) => this._addLayers(path)); }); } mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; this._container.innerHTML = ``; this._icon = this._container.querySelector(".sidebar-icon"); this._icon.addEventListener("click", () => this._togglePanel()); this._panel = document.getElementById("gpx-panel"); this._treeContainer = this._panel.querySelector(".gpx-tree"); this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON; this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON; this._panel .querySelector(".gpx-panel__close") .addEventListener("click", () => this._togglePanel(false)); onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); this._setupToolbarActions(); this._setupRootDropTarget(); this._setupMapDropTarget(); this._loadTree(); sidebar.appendChild(this._container); } _togglePanel(force) { const open = force === undefined ? this._panel.hidden : force; this._panel.hidden = !open; this._icon.classList.toggle("active", open); if (open) notifySidePanelOpen(this._panel); } // Both toolbar actions are icon buttons now (matching OsmAnd's tracks // panel) rather than an always-visible dropzone/form: upload triggers the // native file picker, new folder prompts for a path. _setupToolbarActions() { const fileInput = this._panel.querySelector(".gpx-file-input"); fileInput.addEventListener("change", () => { this._uploadFiles(fileInput.files, ""); fileInput.value = ""; }); this._panel.querySelector('[data-action="upload"]').addEventListener("click", () => fileInput.click()); this._panel.querySelector('[data-action="new-folder"]').addEventListener("click", async () => { const path = window.prompt("New folder name (e.g. Hiking/2026):"); if (!path || !path.trim()) return; const response = await fetch(FOLDER_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `path=${encodeURIComponent(path.trim())}`, }); if (redirectIfUnauthorized(response)) return; if (response.ok) { const { tree } = await response.json(); this._renderTree(tree); } }); } // The tree itself is the drop target for both an OS file drag (upload to // the top level) and, for anywhere that isn't a folder (blank space, or // onto another file — folder drop targets call stopPropagation() so this // only fires then), an internal file drag (move back to the top level). _setupRootDropTarget() { this._treeContainer.addEventListener("dragover", (event) => { if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return; event.preventDefault(); this._treeContainer.classList.add("gpx-drop-hover"); }); this._treeContainer.addEventListener("dragleave", (event) => { if (event.target === this._treeContainer) this._treeContainer.classList.remove("gpx-drop-hover"); }); this._treeContainer.addEventListener("drop", (event) => { event.preventDefault(); this._treeContainer.classList.remove("gpx-drop-hover"); if (event.dataTransfer.files?.length > 0) { this._uploadFiles(event.dataTransfer.files, ""); return; } const from = event.dataTransfer.getData(MOVE_MIME); if (from) this._moveFile(from, ""); }); } // Dropping a GPX file straight onto the map (not just the panel) uploads // it to the top level too. _setupMapDropTarget() { const mapContainer = this._map.getContainer(); mapContainer.addEventListener("dragover", (event) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); mapContainer.classList.add("gpx-drop-hover"); }); mapContainer.addEventListener("dragleave", (event) => { if (event.target === mapContainer) mapContainer.classList.remove("gpx-drop-hover"); }); mapContainer.addEventListener("drop", (event) => { mapContainer.classList.remove("gpx-drop-hover"); if (event.dataTransfer.files?.length > 0) { event.preventDefault(); this._uploadFiles(event.dataTransfer.files, ""); } }); } async _loadTree() { const response = await fetch(TREE_URL); const tree = await response.json(); this._renderTree(tree); } _renderTree(node) { this._treeContainer.innerHTML = ""; this._treeContainer.appendChild(this._renderChildren(node.children)); } _renderChildren(children) { const ul = document.createElement("ul"); if (children.length === 0) { const li = document.createElement("li"); li.className = "gpx-tree__empty"; li.textContent = "No files yet"; ul.appendChild(li); return ul; } children.forEach((child) => { const li = document.createElement("li"); if (child.type === "folder") { li.appendChild(this._renderFolder(child)); } else { li.appendChild(this._renderFile(child)); } ul.appendChild(li); }); return ul; } _renderFolder(folder) { const details = document.createElement("details"); details.open = true; const summary = document.createElement("summary"); const row = document.createElement("span"); row.className = "gpx-tree__folder-row"; const name = document.createElement("span"); name.textContent = folder.name; row.appendChild(name); row.appendChild(this._deleteButton(() => this._deleteFolder(folder.path))); summary.appendChild(row); this._wireFolderDropTarget(summary, folder.path); details.appendChild(summary); details.appendChild(this._renderChildren(folder.children)); return details; } _renderFile(file) { const row = document.createElement("div"); row.className = "gpx-tree__file"; row.draggable = true; row.addEventListener("dragstart", (event) => { event.dataTransfer.setData(MOVE_MIME, file.path); event.dataTransfer.effectAllowed = "move"; }); const label = document.createElement("label"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = this._visible.has(file.path); checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked)); const name = document.createElement("span"); name.className = "gpx-tree__file-name"; name.textContent = file.name; name.title = file.name; label.appendChild(checkbox); (file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind))); label.appendChild(name); row.appendChild(label); row.appendChild(this._deleteButton(() => this._deleteFile(file.path))); return row; } _kindIcon(kind) { const icon = document.createElement("span"); icon.className = "gpx-tree__kind-icon"; if (kind === "waypoint") { icon.innerHTML = WAYPOINT_ICON; icon.title = "Has waypoints"; } else { icon.innerHTML = TRACK_ICON; icon.title = "Has a track"; } return icon; } _deleteButton(onClick) { const button = document.createElement("button"); button.type = "button"; button.className = "gpx-tree__delete"; button.title = "Delete"; button.textContent = "×"; button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onClick(); }); return button; } // A folder accepts both an OS file drag (upload straight into it) and an // internal file drag (move into it). _wireFolderDropTarget(summary, folderPath) { summary.addEventListener("dragover", (event) => { if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return; event.preventDefault(); summary.classList.add("gpx-drop-hover"); }); summary.addEventListener("dragleave", () => summary.classList.remove("gpx-drop-hover")); summary.addEventListener("drop", (event) => { event.preventDefault(); event.stopPropagation(); summary.classList.remove("gpx-drop-hover"); if (event.dataTransfer.files?.length > 0) { this._uploadFiles(event.dataTransfer.files, folderPath); return; } const from = event.dataTransfer.getData(MOVE_MIME); if (from) this._moveFile(from, folderPath); }); } _toggleFile(path, visible) { if (visible) { this._visible.add(path); this._addLayers(path); } else { this._visible.delete(path); this._removeLayers(path); } } _addLayers(path) { const id = sourceId(path); if (this._map.getSource(id)) return; this._map.addSource(id, { type: "geojson", data: gpxFileUrl(path) }); this._map.addLayer({ id: `${id}-line`, type: "line", source: id, filter: ["==", ["geometry-type"], "LineString"], layout: { "line-cap": "round", "line-join": "round" }, paint: { "line-color": TRACK_COLOR, "line-width": 3 }, }); this._map.addLayer({ id: `${id}-points`, type: "circle", source: id, filter: ["==", ["geometry-type"], "Point"], paint: { "circle-radius": 5, "circle-color": TRACK_COLOR, "circle-stroke-color": "#fff", "circle-stroke-width": 1.5, }, }); this._registerPointInteractivity(`${id}-points`); this._registerLineInteractivity(`${id}-line`); } // Registered once per layer id, ever — map.on(type, layerId, fn) is a // delegated listener that lives on the map itself and keeps matching // that layer id after setStyle() removes and (via _addLayers) re-adds // it, so re-registering on every re-add would stack duplicate popups. _registerPointInteractivity(layerId) { if (this._interactiveLayers.has(layerId)) return; this._interactiveLayers.add(layerId); this._map.on("click", layerId, (event) => { const feature = event.features[0]; if (!feature) return; new Popup({ closeButton: true, maxWidth: "260px" }) .setLngLat(event.lngLat) .setHTML(popupHtml(feature.properties)) .addTo(this._map); }); this._map.on("mouseenter", layerId, () => { this._map.getCanvas().style.cursor = "pointer"; }); this._map.on("mouseleave", layerId, () => { this._map.getCanvas().style.cursor = ""; }); } // Hover rather than click: unlike a waypoint, a track's properties // (distance, elevation gain, duration) describe the whole line, not // whatever point along it happens to get clicked. _registerLineInteractivity(layerId) { if (this._interactiveLayers.has(layerId)) return; this._interactiveLayers.add(layerId); let popup = null; this._map.on("mouseenter", layerId, (event) => { this._map.getCanvas().style.cursor = "pointer"; const feature = event.features[0]; if (!feature) return; popup = new Popup({ closeButton: false, closeOnClick: false, maxWidth: "260px" }) .setLngLat(event.lngLat) .setHTML(trackPopupHtml(feature.properties)) .addTo(this._map); }); this._map.on("mouseleave", layerId, () => { this._map.getCanvas().style.cursor = ""; popup?.remove(); popup = null; }); } _removeLayers(path) { const id = sourceId(path); [`${id}-line`, `${id}-points`].forEach((layerId) => { if (this._map.getLayer(layerId)) this._map.removeLayer(layerId); }); if (this._map.getSource(id)) this._map.removeSource(id); } async _uploadFiles(fileList, folder) { // .zip: Garmin's bulk/FIT export format — the server expands it into // its .gpx/.fit entries, so it's accepted here even though the tree // never shows a .zip itself. const files = [...fileList].filter((file) => /\.(gpx|fit|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) { 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()); } catch (err) { failed += 1; } } if (tree) this._renderTree(tree); this._setStatus(failed > 0 ? `${failed} of ${files.length} uploads failed.` : "Uploaded."); } async _deleteFile(path) { if (!window.confirm(`Delete "${path}"?`)) return; const response = await fetch(gpxFileUrl(path), { method: "DELETE" }); if (redirectIfUnauthorized(response)) return; if (!response.ok) return; if (this._visible.has(path)) { this._visible.delete(path); this._removeLayers(path); } const { tree } = await response.json(); this._renderTree(tree); } async _deleteFolder(path) { if (!window.confirm(`Delete "${path}" and everything in it?`)) return; const response = await fetch(`/api/gpx/folder/${path.split("/").map(encodeURIComponent).join("/")}`, { method: "DELETE", }); if (redirectIfUnauthorized(response)) return; if (!response.ok) return; const prefix = `${path}/`; [...this._visible].forEach((visiblePath) => { if (visiblePath === path || visiblePath.startsWith(prefix)) { this._visible.delete(visiblePath); this._removeLayers(visiblePath); } }); const { tree } = await response.json(); this._renderTree(tree); } async _moveFile(from, toFolder) { if (from === toFolder) return; const response = await fetch(MOVE_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `from=${encodeURIComponent(from)}&to_folder=${encodeURIComponent(toFolder)}`, }); if (redirectIfUnauthorized(response)) return; if (!response.ok) return; const { path: newPath, tree } = await response.json(); if (this._visible.has(from)) { this._visible.delete(from); this._removeLayers(from); this._visible.add(newPath); this._addLayers(newPath); } this._renderTree(tree); } _setStatus(text) { this._uploadStatus.hidden = false; this._uploadStatus.textContent = text; } } // Write endpoints require login (see app.rb); a 401 means the session // expired or was never established — send the user to log in rather than // silently failing. function redirectIfUnauthorized(response) { if (response.status !== 401) return false; window.location.href = "/login"; return true; } function sourceId(path) { return `gpx-${path}`; } function gpxFileUrl(path) { return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`; } // properties come straight from an uploaded GPX file — untrusted input — // so every value is escaped before landing in the popup's HTML. function popupHtml({ name, description, time }) { const parts = []; if (name) parts.push(`${escapeHtml(name)}`); if (description) parts.push(`
${linkify(description).replace(/\n/g, "
")}
${escapeHtml(parsed.toLocaleString())}
`); } } return parts.length > 0 ? parts.join("") : "No details"; } // properties come straight from an uploaded GPX/FIT file — untrusted input. function trackPopupHtml({ name, distance_meters, elevation_gain_meters, duration_secs }) { const parts = []; if (name) parts.push(`${escapeHtml(name)}`); const stats = []; if (typeof distance_meters === "number") stats.push(`${(distance_meters / 1000).toFixed(1)} km`); if (typeof elevation_gain_meters === "number") stats.push(`↑ ${Math.round(elevation_gain_meters)} m`); if (typeof duration_secs === "number") stats.push(formatDuration(duration_secs)); if (stats.length > 0) parts.push(`${stats.join(" · ")}
`); return parts.length > 0 ? parts.join("") : "No details"; } function formatDuration(totalSeconds) { const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // Turns bare URLs in untrusted text into safe links: the string is // split on the URL pattern and every piece is escaped — including the URL // itself, both as href and as link text — before any HTML is assembled, so // nothing bypasses escaping. The URL pattern excludes quote characters, so // a matched URL can never break out of the href="..." attribute. const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g; function linkify(text) { return text .split(URL_PATTERN) .map((part, i) => (i % 2 === 1 ? urlToLink(part) : escapeHtml(part))) .join(""); } function urlToLink(url) { // Keep trailing sentence punctuation (e.g. "...place/1234.") out of the // link itself. const [, core, trailing] = url.match(/^(.*?)([.,;:!?]*)$/); const href = escapeHtml(core); return `${href}${escapeHtml(trailing)}`; }