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
+434
-60
No files matched your search
@@ -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
|
||||
+26
-9
@@ -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 `<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
|
||||
/api/gpx/file/<path>` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+230
-28
@@ -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;
|
||||
|
||||
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);
|
||||
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) {
|
||||
|
||||
+8
-4
@@ -6,11 +6,15 @@
|
||||
<button type="button" class="gpx-panel__close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<form class="gpx-upload">
|
||||
<input type="file" name="file" accept=".gpx" required>
|
||||
<input type="text" name="folder" placeholder="folder/subfolder (optional)">
|
||||
<button type="submit">Upload</button>
|
||||
<div class="gpx-dropzone">
|
||||
Drop .gpx files here, or click to browse
|
||||
<input type="file" class="gpx-dropzone__input" accept=".gpx" multiple hidden>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="gpx-tree"></div>
|
||||
|
||||
Reference in new issue
Block a user