Add GPX drag & drop, folder management, delete, and folder-first sort

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).
This commit is contained in:
randy committed 2026-08-25 15:14:09 +02:00
1 parent 4e0d2abbd0
commit d813dd0b20
6 files changed
+440 -66

No files matched your search

+85 -18
View File
@@ -127,31 +127,49 @@ html, body {
cursor: pointer;
}
.gpx-upload {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid #eee;
}
.gpx-upload input {
font: inherit;
font-size: 13px;
}
.gpx-upload button {
align-self: flex-start;
font: inherit;
font-size: 13px;
.gpx-dropzone {
margin: 10px 12px;
padding: 14px 10px;
border: 2px dashed #ccc;
border-radius: 4px;
text-align: center;
color: #666;
cursor: pointer;
}
.gpx-dropzone.gpx-drop-hover {
border-color: #4a90d9;
background: #eef5fd;
color: #333;
}
.gpx-upload__status {
margin: 0;
margin: 0 12px 10px;
color: #666;
}
.gpx-new-folder {
display: flex;
gap: 6px;
margin: 0 12px 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.gpx-new-folder input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 13px;
}
.gpx-new-folder button {
font: inherit;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.gpx-tree {
overflow-y: auto;
padding: 8px 12px;
@@ -170,6 +188,20 @@ html, body {
.gpx-tree summary {
cursor: pointer;
font-weight: 600;
border-radius: 3px;
}
.gpx-tree__folder-row {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: calc(100% - 18px);
}
.gpx-tree summary.gpx-drop-hover {
background: #eef5fd;
outline: 1px dashed #4a90d9;
}
.gpx-tree__file {
@@ -177,14 +209,49 @@ html, body {
align-items: center;
gap: 4px;
padding: 2px 0;
cursor: grab;
}
.gpx-tree__file label {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.gpx-tree__file label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gpx-tree__delete {
flex-shrink: 0;
border: none;
background: none;
color: #999;
font-size: 14px;
line-height: 1;
padding: 0 4px;
cursor: pointer;
}
.gpx-tree__delete:hover {
color: #e63946;
}
.gpx-tree__empty {
color: #888;
font-style: italic;
}
.gpx-tree.gpx-drop-hover {
outline: 1px dashed #4a90d9;
outline-offset: -2px;
}
.maplibregl-popup-content p {
margin: 4px 0 0;
}
+235 -33
View File
@@ -2,11 +2,19 @@ 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 (stored server-side in a folder hierarchy) and
// toggling their tracks/waypoints on and off the map.
// 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;
@@ -30,12 +38,14 @@ export class GpxPanelControl {
this._panel = document.getElementById("gpx-panel");
this._treeContainer = this._panel.querySelector(".gpx-tree");
this._uploadForm = this._panel.querySelector(".gpx-upload");
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
this._panel
.querySelector(".gpx-panel__close")
.addEventListener("click", () => this._togglePanel(false));
this._uploadForm.addEventListener("submit", (event) => this._handleUpload(event));
this._setupDropzone();
this._setupNewFolderForm();
this._setupRootDropTarget();
this._loadTree();
@@ -50,6 +60,70 @@ export class GpxPanelControl {
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();
@@ -74,23 +148,9 @@ export class GpxPanelControl {
children.forEach((child) => {
const li = document.createElement("li");
if (child.type === "folder") {
const details = document.createElement("details");
details.open = true;
const summary = document.createElement("summary");
summary.textContent = child.name;
details.appendChild(summary);
details.appendChild(this._renderChildren(child.children));
li.appendChild(details);
li.appendChild(this._renderFolder(child));
} else {
const label = document.createElement("label");
label.className = "gpx-tree__file";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this._visible.has(child.path);
checkbox.addEventListener("change", () => this._toggleFile(child.path, checkbox.checked));
label.appendChild(checkbox);
label.append(` ${child.name}`);
li.appendChild(label);
li.appendChild(this._renderFile(child));
}
ul.appendChild(li);
});
@@ -98,6 +158,86 @@ export class GpxPanelControl {
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);
@@ -169,21 +309,83 @@ export class GpxPanelControl {
if (this._map.getSource(id)) this._map.removeSource(id);
}
async _handleUpload(event) {
event.preventDefault();
const formData = new FormData(this._uploadForm);
this._setStatus("Uploading…");
async _uploadFiles(fileList, folder) {
const files = [...fileList].filter((file) => file.name.toLowerCase().endsWith(".gpx"));
if (files.length === 0) return;
try {
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
if (!response.ok) throw new Error("upload failed");
const { tree } = await response.json();
this._renderTree(tree);
this._uploadForm.reset();
this._setStatus("Uploaded.");
} catch (err) {
this._setStatus("Upload failed.");
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) {