Uploading is now drag & drop (or click to browse) instead of a form; folders can be created any time, not just during upload; files can be dragged onto a folder to move them, or dropped elsewhere in the tree to move back to the top level; files and folders can be deleted. Both drag sources (an OS file drag and dragging an existing tree entry) land on the same drop targets, told apart by a custom drag payload type versus the browser's own "Files" type. Also sort folders before files in the tree (previously interleaved alphabetically).
447 lines
15 KiB
JavaScript
447 lines
15 KiB
JavaScript
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||
|
||
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 MapLibre IControl: a "GPX" button 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));
|
||
});
|
||
}
|
||
|
||
onAdd() {
|
||
this._button = document.createElement("div");
|
||
this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle";
|
||
this._button.innerHTML = `<button type="button">GPX</button>`;
|
||
this._button
|
||
.querySelector("button")
|
||
.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(".gpx-panel__close")
|
||
.addEventListener("click", () => this._togglePanel(false));
|
||
|
||
this._setupDropzone();
|
||
this._setupNewFolderForm();
|
||
this._setupRootDropTarget();
|
||
|
||
this._loadTree();
|
||
|
||
return this._button;
|
||
}
|
||
|
||
onRemove() {
|
||
this._button.remove();
|
||
}
|
||
|
||
_togglePanel(force) {
|
||
this._panel.hidden = force === undefined ? !this._panel.hidden : !force;
|
||
}
|
||
|
||
_setupDropzone() {
|
||
const dropzone = this._panel.querySelector(".gpx-dropzone");
|
||
const fileInput = this._panel.querySelector(".gpx-dropzone__input");
|
||
|
||
dropzone.addEventListener("click", () => fileInput.click());
|
||
fileInput.addEventListener("change", () => {
|
||
this._uploadFiles(fileInput.files, "");
|
||
fileInput.value = "";
|
||
});
|
||
|
||
dropzone.addEventListener("dragover", (event) => {
|
||
if (!event.dataTransfer.types.includes("Files")) return;
|
||
event.preventDefault();
|
||
dropzone.classList.add("gpx-drop-hover");
|
||
});
|
||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("gpx-drop-hover"));
|
||
dropzone.addEventListener("drop", (event) => {
|
||
event.preventDefault();
|
||
dropzone.classList.remove("gpx-drop-hover");
|
||
if (event.dataTransfer.files?.length > 0) this._uploadFiles(event.dataTransfer.files, "");
|
||
});
|
||
}
|
||
|
||
_setupNewFolderForm() {
|
||
const form = this._panel.querySelector(".gpx-new-folder");
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const input = form.querySelector("input");
|
||
const path = input.value.trim();
|
||
if (!path) return;
|
||
|
||
const response = await fetch(FOLDER_URL, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: `path=${encodeURIComponent(path)}`,
|
||
});
|
||
if (response.ok) {
|
||
const { tree } = await response.json();
|
||
this._renderTree(tree);
|
||
input.value = "";
|
||
}
|
||
});
|
||
}
|
||
|
||
// Dropping a dragged file anywhere in the tree that isn't a folder (e.g.
|
||
// blank space, or onto another file) moves it back to the top level —
|
||
// folder drop targets call stopPropagation() so this only fires then.
|
||
_setupRootDropTarget() {
|
||
this._treeContainer.addEventListener("dragover", (event) => {
|
||
if (!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");
|
||
const from = event.dataTransfer.getData(MOVE_MIME);
|
||
if (from) this._moveFile(from, "");
|
||
});
|
||
}
|
||
|
||
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.textContent = file.name;
|
||
name.title = file.name;
|
||
label.appendChild(checkbox);
|
||
label.appendChild(name);
|
||
row.appendChild(label);
|
||
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
|
||
|
||
return row;
|
||
}
|
||
|
||
_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`);
|
||
}
|
||
|
||
// 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 = "";
|
||
});
|
||
}
|
||
|
||
_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) {
|
||
const files = [...fileList].filter((file) => file.name.toLowerCase().endsWith(".gpx"));
|
||
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 (!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 (!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 (!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 (!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;
|
||
}
|
||
}
|
||
|
||
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(`<strong>${escapeHtml(name)}</strong>`);
|
||
if (description) parts.push(`<p>${linkify(description).replace(/\n/g, "<br>")}</p>`);
|
||
if (time) {
|
||
const parsed = new Date(time);
|
||
if (!Number.isNaN(parsed.getTime())) {
|
||
parts.push(`<p class="gpx-popup__time">${escapeHtml(parsed.toLocaleString())}</p>`);
|
||
}
|
||
}
|
||
return parts.length > 0 ? parts.join("") : "<em>No details</em>";
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement("div");
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Turns bare URLs in untrusted text into safe <a> 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 `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>${escapeHtml(trailing)}`;
|
||
}
|