Add web UI for WebDAV files and CardDAV contacts
Files browser (web/files): - Alphabetical listing grouped folders-then-files - Upload via button or drag-and-drop, including whole folders - Folder creation - Fixed layout for long filenames without spaces (table-fixed + break-all) Contacts (web/contacts): - Full CRUD for CardDAV contacts (create/edit/delete) - VCF import (multi-card files) and export (single/all) - Photo upload with preview, birthday field - TYPE labels (private/business) for phone, email, address - Repeatable multi-input rows for phones/emails/addresses instead of textareas - Sanitizes a known malformed TYPE parameter pattern from some vCard exporters (e.g. Nextcloud Contacts) that otherwise caused phone numbers to be silently dropped on import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"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)}`);
|
||||
}
|
||||
});
|
||||
// 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()));
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user