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
+434 -60

No files matched your search

+46
View File
@@ -63,5 +63,51 @@ module Maptool
halt 422, json(error: "could not parse GPX file") halt 422, json(error: "could not parse GPX file")
end end
end end
post "/api/gpx/folder" do
begin
GPX_STORE.create_folder(params[:path].to_s)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
end
json tree: GPX_STORE.tree
end
delete "/api/gpx/file/*" do
begin
GPX_STORE.delete_file(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
rescue Errno::ENOENT
halt 404
end
json tree: GPX_STORE.tree
end
delete "/api/gpx/folder/*" do
begin
GPX_STORE.delete_folder(params[:splat].first)
rescue GpxStore::InvalidPath
halt 400
rescue Errno::ENOENT
halt 404
end
json tree: GPX_STORE.tree
end
post "/api/gpx/move" do
begin
path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s)
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid path")
rescue Errno::ENOENT
halt 404
end
json path: path, tree: GPX_STORE.tree
end
end end
end end
+26 -9
View File
@@ -116,20 +116,37 @@ terms, the API is free for personal/non-commercial use.
== GPX tracks and waypoints == GPX tracks and waypoints
The "GPX" map control (`public/js/gpx.js`) opens a side panel to upload The "GPX" map control (`public/js/gpx.js`) opens a side panel to
GPX files and toggle them on the map, individually. upload GPX files by dragging them in, organize them into folders (also
by dragging), and toggle each one on the map individually.
Storage:: Uploaded files are stored as-is under `data/gpx/`, in Storage:: Files are stored as-is under `data/gpx/`, in a
whatever folder hierarchy the user types into the upload form (e.g. folder hierarchy the user manages directly — `Maptool::GpxStore`
`Hiking/2026`) — `Maptool::GpxStore` (`lib/maptool/gpx_store.rb`) provides `save`, `create_folder`,
(`lib/maptool/gpx_store.rb`) creates folders as needed. Every path `delete_file`, `delete_folder` and `move`. Every path coming from the
coming from the client (upload folder, file path in a URL) is client (a folder to create/upload/move into, a file path in a URL) is
resolved with `File.expand_path` and checked against the store root resolved with `File.expand_path` and checked against the store root
before touching the filesystem, so `../..`-style path traversal can't before touching the filesystem, so `../..`-style path traversal can't
escape `data/gpx/`. escape `data/gpx/`; `delete_folder` additionally refuses an empty path
or one that resolves to the store root itself, so it can never `rm
-rf` the whole store.
`GET /api/gpx/tree` returns the resulting folder/file tree as JSON, `GET /api/gpx/tree` returns the resulting folder/file tree as JSON,
which the panel renders recursively (folders as `<details>`, files as which the panel renders recursively (folders as `<details>`, files as
a checkbox). a checkbox, both with a delete button).
Drag & drop:: The dropzone at the top of the panel accepts one or more
`.gpx` files (`POST /api/gpx`, multipart) into the top level, or click
it to browse instead. An already-uploaded file (a
`.gpx-tree__file` row, `draggable`) can be dragged onto a folder to
move it there (`POST /api/gpx/move`), or dropped anywhere else in the
tree to move it back to the top level; an OS file can also be dropped
directly onto a folder to upload straight into it. Both drag sources
land on the same drop targets and are told apart by a custom
`application/x-gpx-path` drag payload type versus the browser's own
`"Files"` type. If a file that's currently checked (visible on the
map) gets moved, its map layers are re-added under the new path so it
stays visible; if it (or its containing folder) gets deleted, its
layers are removed instead.
Rendering:: Checking a file fetches `GET Rendering:: Checking a file fetches `GET
/api/gpx/file/<path>` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses /api/gpx/file/<path>` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses
+39 -1
View File
@@ -41,6 +41,42 @@ module Maptool
path path
end end
def create_folder(path)
raise InvalidPath if path.to_s.strip.empty?
FileUtils.mkdir_p(resolve_dir(path.to_s))
end
def delete_file(relative_path)
path = resolve_file(relative_path)
raise Errno::ENOENT, path unless File.file?(path)
File.delete(path)
end
def delete_folder(relative_path)
raise InvalidPath if relative_path.to_s.strip.empty?
dir = resolve_dir(relative_path.to_s)
raise InvalidPath if dir == @root
raise Errno::ENOENT, dir unless File.directory?(dir)
FileUtils.rm_rf(dir)
end
# Moves a file into `to_folder` (created if needed), disambiguating the
# filename on collision. Returns the new store-relative path.
def move(from:, to_folder:)
source = resolve_file(from)
raise Errno::ENOENT, source unless File.file?(source)
dest_dir = resolve_dir(to_folder.to_s)
FileUtils.mkdir_p(dest_dir)
name = unique_filename(dest_dir, File.basename(source))
FileUtils.mv(source, File.join(dest_dir, name))
to_folder.to_s.empty? ? name : "#{to_folder}/#{name}"
end
private private
def resolve_dir(folder) def resolve_dir(folder)
@@ -72,7 +108,9 @@ module Maptool
end end
def list_children(dir, relative) def list_children(dir, relative)
Dir.children(dir).sort.filter_map do |entry| entries = Dir.children(dir).sort_by { |entry| [File.directory?(File.join(dir, entry)) ? 0 : 1, entry] }
entries.filter_map do |entry|
full = File.join(dir, entry) full = File.join(dir, entry)
rel = relative.empty? ? entry : "#{relative}/#{entry}" rel = relative.empty? ? entry : "#{relative}/#{entry}"
+85 -18
View File
@@ -127,31 +127,49 @@ html, body {
cursor: pointer; cursor: pointer;
} }
.gpx-upload { .gpx-dropzone {
display: flex; margin: 10px 12px;
flex-direction: column; padding: 14px 10px;
gap: 6px; border: 2px dashed #ccc;
padding: 10px 12px; border-radius: 4px;
border-bottom: 1px solid #eee; text-align: center;
} color: #666;
.gpx-upload input {
font: inherit;
font-size: 13px;
}
.gpx-upload button {
align-self: flex-start;
font: inherit;
font-size: 13px;
cursor: pointer; cursor: pointer;
} }
.gpx-dropzone.gpx-drop-hover {
border-color: #4a90d9;
background: #eef5fd;
color: #333;
}
.gpx-upload__status { .gpx-upload__status {
margin: 0; margin: 0 12px 10px;
color: #666; 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 { .gpx-tree {
overflow-y: auto; overflow-y: auto;
padding: 8px 12px; padding: 8px 12px;
@@ -170,6 +188,20 @@ html, body {
.gpx-tree summary { .gpx-tree summary {
cursor: pointer; cursor: pointer;
font-weight: 600; 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 { .gpx-tree__file {
@@ -177,14 +209,49 @@ html, body {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 2px 0; padding: 2px 0;
cursor: grab;
}
.gpx-tree__file label {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
cursor: pointer; 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 { .gpx-tree__empty {
color: #888; color: #888;
font-style: italic; font-style: italic;
} }
.gpx-tree.gpx-drop-hover {
outline: 1px dashed #4a90d9;
outline-offset: -2px;
}
.maplibregl-popup-content p { .maplibregl-popup-content p {
margin: 4px 0 0; margin: 4px 0 0;
} }
+230 -28
View File
@@ -2,11 +2,19 @@ import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
const TREE_URL = "/api/gpx/tree"; const TREE_URL = "/api/gpx/tree";
const UPLOAD_URL = "/api/gpx"; const UPLOAD_URL = "/api/gpx";
const FOLDER_URL = "/api/gpx/folder";
const MOVE_URL = "/api/gpx/move";
const TRACK_COLOR = "#e63946"; 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 // A MapLibre IControl: a "GPX" button that opens a side panel for
// uploading GPX files (stored server-side in a folder hierarchy) and // uploading GPX files (via drag & drop, stored server-side in a folder
// toggling their tracks/waypoints on and off the map. // hierarchy the user can add to and reorganize by dragging), and toggling
// their tracks/waypoints on and off the map.
export class GpxPanelControl { export class GpxPanelControl {
constructor(map) { constructor(map) {
this._map = map; this._map = map;
@@ -30,12 +38,14 @@ export class GpxPanelControl {
this._panel = document.getElementById("gpx-panel"); this._panel = document.getElementById("gpx-panel");
this._treeContainer = this._panel.querySelector(".gpx-tree"); this._treeContainer = this._panel.querySelector(".gpx-tree");
this._uploadForm = this._panel.querySelector(".gpx-upload");
this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
this._panel this._panel
.querySelector(".gpx-panel__close") .querySelector(".gpx-panel__close")
.addEventListener("click", () => this._togglePanel(false)); .addEventListener("click", () => this._togglePanel(false));
this._uploadForm.addEventListener("submit", (event) => this._handleUpload(event));
this._setupDropzone();
this._setupNewFolderForm();
this._setupRootDropTarget();
this._loadTree(); this._loadTree();
@@ -50,6 +60,70 @@ export class GpxPanelControl {
this._panel.hidden = force === undefined ? !this._panel.hidden : !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() { async _loadTree() {
const response = await fetch(TREE_URL); const response = await fetch(TREE_URL);
const tree = await response.json(); const tree = await response.json();
@@ -74,23 +148,9 @@ export class GpxPanelControl {
children.forEach((child) => { children.forEach((child) => {
const li = document.createElement("li"); const li = document.createElement("li");
if (child.type === "folder") { if (child.type === "folder") {
const details = document.createElement("details"); li.appendChild(this._renderFolder(child));
details.open = true;
const summary = document.createElement("summary");
summary.textContent = child.name;
details.appendChild(summary);
details.appendChild(this._renderChildren(child.children));
li.appendChild(details);
} else { } else {
const label = document.createElement("label"); li.appendChild(this._renderFile(child));
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);
} }
ul.appendChild(li); ul.appendChild(li);
}); });
@@ -98,6 +158,86 @@ export class GpxPanelControl {
return ul; 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) { _toggleFile(path, visible) {
if (visible) { if (visible) {
this._visible.add(path); this._visible.add(path);
@@ -169,21 +309,83 @@ export class GpxPanelControl {
if (this._map.getSource(id)) this._map.removeSource(id); if (this._map.getSource(id)) this._map.removeSource(id);
} }
async _handleUpload(event) { async _uploadFiles(fileList, folder) {
event.preventDefault(); const files = [...fileList].filter((file) => file.name.toLowerCase().endsWith(".gpx"));
const formData = new FormData(this._uploadForm); if (files.length === 0) return;
this._setStatus("Uploading…");
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 { try {
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData }); const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
if (!response.ok) throw new Error("upload failed"); 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(); const { tree } = await response.json();
this._renderTree(tree); this._renderTree(tree);
this._uploadForm.reset();
this._setStatus("Uploaded.");
} catch (err) {
this._setStatus("Upload failed.");
} }
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) { _setStatus(text) {
+8 -4
View File
@@ -6,11 +6,15 @@
<button type="button" class="gpx-panel__close" aria-label="Close">&times;</button> <button type="button" class="gpx-panel__close" aria-label="Close">&times;</button>
</div> </div>
<form class="gpx-upload"> <div class="gpx-dropzone">
<input type="file" name="file" accept=".gpx" required> Drop .gpx files here, or click to browse
<input type="text" name="folder" placeholder="folder/subfolder (optional)"> <input type="file" class="gpx-dropzone__input" accept=".gpx" multiple hidden>
<button type="submit">Upload</button> </div>
<p class="gpx-upload__status" hidden></p> <p class="gpx-upload__status" hidden></p>
<form class="gpx-new-folder">
<input type="text" name="path" placeholder="new folder path (e.g. Hiking/2026)">
<button type="submit">+ folder</button>
</form> </form>
<div class="gpx-tree"></div> <div class="gpx-tree"></div>