diff --git a/app.rb b/app.rb index f8a8a3b..54e450f 100644 --- a/app.rb +++ b/app.rb @@ -63,5 +63,51 @@ module Maptool halt 422, json(error: "could not parse GPX file") 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 diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 0724b51..57030f4 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -116,20 +116,37 @@ terms, the API is free for personal/non-commercial use. == GPX tracks and waypoints -The "GPX" map control (`public/js/gpx.js`) opens a side panel to upload -GPX files and toggle them on the map, individually. +The "GPX" map control (`public/js/gpx.js`) opens a side panel to +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 -whatever folder hierarchy the user types into the upload form (e.g. -`Hiking/2026`) — `Maptool::GpxStore` -(`lib/maptool/gpx_store.rb`) creates folders as needed. Every path -coming from the client (upload folder, file path in a URL) is +Storage:: Files are stored as-is under `data/gpx/`, in a +folder hierarchy the user manages directly — `Maptool::GpxStore` +(`lib/maptool/gpx_store.rb`) provides `save`, `create_folder`, +`delete_file`, `delete_folder` and `move`. Every path coming from the +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 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, which the panel renders recursively (folders as `
`, 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 /api/gpx/file/` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses diff --git a/lib/maptool/gpx_store.rb b/lib/maptool/gpx_store.rb index e731e31..cad0eb1 100644 --- a/lib/maptool/gpx_store.rb +++ b/lib/maptool/gpx_store.rb @@ -41,6 +41,42 @@ module Maptool path 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 def resolve_dir(folder) @@ -72,7 +108,9 @@ module Maptool end 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) rel = relative.empty? ? entry : "#{relative}/#{entry}" diff --git a/public/css/style.css b/public/css/style.css index c4dcab3..7985772 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } diff --git a/public/js/gpx.js b/public/js/gpx.js index 77cd6cb..8f4531a 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -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) { diff --git a/views/index.erb b/views/index.erb index 4cff2eb..53c8a14 100644 --- a/views/index.erb +++ b/views/index.erb @@ -6,11 +6,15 @@ -
- - - - +
+ Drop .gpx files here, or click to browse + +
+ + + + +