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:
1 parent
4e0d2abbd0
commit
d813dd0b20
6 files changed
+440
-66
No files matched your search
+85
-18
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user