- DELETE /files/{path} recursively removes a file/folder (root itself
can't be deleted, 404 on missing paths); wired to a new "Delete"
action in the web UI with a confirmation prompt.
- Files now open inline (Content-Disposition: inline) so browsers can
play/preview natively-supported types (video, audio, images, PDF)
directly instead of always forcing a download. A separate
"Download" link (?download=1) still forces a save-as.
- Narrow screens get a stacked card list (name, size, modified date,
download/delete actions) instead of a squeezed table, so nothing is
hidden or requires horizontal scrolling; the table layout is kept
unchanged for sm+ screens.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
174 lines
6.7 KiB
JavaScript
174 lines
6.7 KiB
JavaScript
"use strict";
|
|
// File browser page behavior: uploading via the "Upload files"/"Upload
|
|
// folder" buttons and via drag & drop onto the listing, both re-using the
|
|
// same upload() call. Folder uploads (button or drop) recreate their
|
|
// directory structure server-side by encoding the relative path in each
|
|
// uploaded file's name (see FormData.append's third argument below).
|
|
(function initFileBrowser() {
|
|
const dropZone = document.getElementById("file-drop-zone");
|
|
const filesInput = document.getElementById("upload-files-input");
|
|
const folderInput = document.getElementById("upload-folder-input");
|
|
const newFolderButton = document.getElementById("new-folder-button");
|
|
const status = document.getElementById("upload-status");
|
|
if (!dropZone) {
|
|
return;
|
|
}
|
|
const uploadUrl = dropZone.dataset.uploadUrl || window.location.pathname;
|
|
function setStatus(msg) {
|
|
if (status) {
|
|
status.textContent = msg;
|
|
}
|
|
}
|
|
async function upload(files) {
|
|
if (files.length === 0) {
|
|
return;
|
|
}
|
|
const formData = new FormData();
|
|
for (const file of files) {
|
|
// A folder-selected/dropped file's relative path (e.g.
|
|
// "photos/2024/img.jpg") is sent as a parallel "paths" field
|
|
// (same order as "files") since the server strips any
|
|
// directory component from the file's own filename per the
|
|
// multipart spec — see internal/web/files.go.
|
|
const relPath = file.webkitRelativePath || file.name;
|
|
formData.append("files", file, file.name);
|
|
formData.append("paths", relPath);
|
|
}
|
|
setStatus(`Uploading ${files.length} file(s)…`);
|
|
try {
|
|
const resp = await fetch(uploadUrl, { method: "POST", body: formData });
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
setStatus(`Upload failed: ${text}`);
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
}
|
|
catch (err) {
|
|
setStatus(`Upload failed: ${String(err)}`);
|
|
}
|
|
}
|
|
filesInput?.addEventListener("change", () => {
|
|
void upload(Array.from(filesInput.files || []));
|
|
filesInput.value = "";
|
|
});
|
|
folderInput?.addEventListener("change", () => {
|
|
void upload(Array.from(folderInput.files || []));
|
|
folderInput.value = "";
|
|
});
|
|
const folderNameRe = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
newFolderButton?.addEventListener("click", async () => {
|
|
const name = window.prompt("New folder name:");
|
|
if (!name) {
|
|
return;
|
|
}
|
|
if (!folderNameRe.test(name)) {
|
|
setStatus("Folder name must be 1-64 letters, digits, '-' or '_'.");
|
|
return;
|
|
}
|
|
setStatus(`Creating folder "${name}"…`);
|
|
try {
|
|
const resp = await fetch(`${uploadUrl}?mkdir=1`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({ name }),
|
|
});
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
setStatus(`Could not create folder: ${text}`);
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
}
|
|
catch (err) {
|
|
setStatus(`Could not create folder: ${String(err)}`);
|
|
}
|
|
});
|
|
document.querySelectorAll(".delete-entry-button").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const relPath = button.dataset.path || "";
|
|
const name = button.dataset.name || relPath;
|
|
if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
setStatus(`Deleting "${name}"…`);
|
|
try {
|
|
const resp = await fetch(`/web/files/${relPath}`, { method: "DELETE" });
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
setStatus(`Could not delete "${name}": ${text}`);
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
}
|
|
catch (err) {
|
|
setStatus(`Could not delete "${name}": ${String(err)}`);
|
|
}
|
|
});
|
|
});
|
|
// Recursively walk a dropped DataTransferItem (file or directory) into
|
|
// a flat list of File objects, using the browser's non-standard but
|
|
// widely-supported webkitGetAsEntry/FileSystemEntry APIs to support
|
|
// dragging & dropping whole folders.
|
|
function readEntry(entry) {
|
|
return new Promise((resolve) => {
|
|
if (entry.isFile) {
|
|
entry.file((file) => {
|
|
Object.defineProperty(file, "webkitRelativePath", {
|
|
value: entry.fullPath.replace(/^\//, ""),
|
|
});
|
|
resolve([file]);
|
|
}, () => resolve([]));
|
|
return;
|
|
}
|
|
if (entry.isDirectory) {
|
|
const reader = entry.createReader();
|
|
const allEntries = [];
|
|
const readBatch = () => {
|
|
reader.readEntries(async (batch) => {
|
|
if (batch.length === 0) {
|
|
const nested = await Promise.all(allEntries.map(readEntry));
|
|
resolve(nested.flat());
|
|
return;
|
|
}
|
|
allEntries.push(...batch);
|
|
readBatch();
|
|
}, () => resolve([]));
|
|
};
|
|
readBatch();
|
|
return;
|
|
}
|
|
resolve([]);
|
|
});
|
|
}
|
|
dropZone.addEventListener("dragover", (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add("border-indigo-400", "bg-indigo-50");
|
|
});
|
|
dropZone.addEventListener("dragleave", () => {
|
|
dropZone.classList.remove("border-indigo-400", "bg-indigo-50");
|
|
});
|
|
dropZone.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove("border-indigo-400", "bg-indigo-50");
|
|
const items = e.dataTransfer?.items;
|
|
if (!items) {
|
|
return;
|
|
}
|
|
const entries = [];
|
|
for (const item of Array.from(items)) {
|
|
const entry = item.webkitGetAsEntry?.();
|
|
if (entry) {
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
if (entries.length === 0) {
|
|
// Fallback for browsers without webkitGetAsEntry support: flat
|
|
// files only, no folder traversal.
|
|
void upload(Array.from(e.dataTransfer?.files || []));
|
|
return;
|
|
}
|
|
void Promise.all(entries.map(readEntry)).then((groups) => upload(groups.flat()));
|
|
});
|
|
})();
|