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:
2026-08-20 15:06:39 +02:00
co-authored by Copilot
parent 65cd24a192
commit 3fa0297715
15 changed files with 3174 additions and 8 deletions
+151
View File
@@ -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()));
});
})();