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,79 @@
|
||||
// Contact form behavior: adding/removing repeatable phone, email, and
|
||||
// address rows, plus a live preview when choosing a new photo.
|
||||
(function initContactForm(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".add-row-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const containerId = btn.dataset.addTarget;
|
||||
if (!containerId) {
|
||||
return;
|
||||
}
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const rows = container.querySelectorAll<HTMLElement>(".form-row");
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (!lastRow) {
|
||||
return;
|
||||
}
|
||||
// Clone the last row rather than keeping a separate <template>,
|
||||
// so the JS-added row always matches whatever markup the server
|
||||
// rendered (including the TYPE select's options).
|
||||
const newRow = lastRow.cloneNode(true) as HTMLElement;
|
||||
newRow.querySelectorAll("input").forEach((el) => {
|
||||
(el as HTMLInputElement).value = "";
|
||||
});
|
||||
newRow.querySelectorAll("select").forEach((el) => {
|
||||
(el as HTMLSelectElement).selectedIndex = 0;
|
||||
});
|
||||
container.appendChild(newRow);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.classList.contains("remove-row-btn")) {
|
||||
return;
|
||||
}
|
||||
const row = target.closest<HTMLElement>(".form-row");
|
||||
const container = row?.parentElement;
|
||||
if (!row || !container) {
|
||||
return;
|
||||
}
|
||||
const rows = container.querySelectorAll(".form-row");
|
||||
if (rows.length > 1) {
|
||||
row.remove();
|
||||
return;
|
||||
}
|
||||
// Keep at least one row per section — just clear it instead of
|
||||
// removing it entirely.
|
||||
row.querySelectorAll("input").forEach((el) => {
|
||||
(el as HTMLInputElement).value = "";
|
||||
});
|
||||
row.querySelectorAll("select").forEach((el) => {
|
||||
(el as HTMLSelectElement).selectedIndex = 0;
|
||||
});
|
||||
});
|
||||
|
||||
const photoInput = document.getElementById("photo-input") as HTMLInputElement | null;
|
||||
const photoPreview = document.getElementById("photo-preview") as HTMLImageElement | null;
|
||||
const currentAvatar = document.getElementById("current-avatar") as HTMLElement | null;
|
||||
const removePhotoCheckbox = document.getElementById("remove-photo") as HTMLInputElement | null;
|
||||
|
||||
photoInput?.addEventListener("change", () => {
|
||||
const file = photoInput.files?.[0];
|
||||
if (!file || !photoPreview) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
photoPreview.src = reader.result as string;
|
||||
photoPreview.classList.remove("hidden");
|
||||
currentAvatar?.classList.add("hidden");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
if (removePhotoCheckbox) {
|
||||
removePhotoCheckbox.checked = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// 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(): void {
|
||||
const dropZone = document.getElementById("file-drop-zone") as HTMLDivElement | null;
|
||||
const filesInput = document.getElementById("upload-files-input") as HTMLInputElement | null;
|
||||
const folderInput = document.getElementById("upload-folder-input") as HTMLInputElement | null;
|
||||
const newFolderButton = document.getElementById("new-folder-button") as HTMLButtonElement | null;
|
||||
const status = document.getElementById("upload-status") as HTMLParagraphElement | null;
|
||||
if (!dropZone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadUrl = dropZone.dataset.uploadUrl || window.location.pathname;
|
||||
|
||||
function setStatus(msg: string): void {
|
||||
if (status) {
|
||||
status.textContent = msg;
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(files: File[]): Promise<void> {
|
||||
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 as File & { webkitRelativePath?: string }).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: FileSystemEntry): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
(entry as FileSystemFileEntry).file((file) => {
|
||||
Object.defineProperty(file, "webkitRelativePath", {
|
||||
value: entry.fullPath.replace(/^\//, ""),
|
||||
});
|
||||
resolve([file]);
|
||||
}, () => resolve([]));
|
||||
return;
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const allEntries: FileSystemEntry[] = [];
|
||||
const readBatch = (): void => {
|
||||
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: FileSystemEntry[] = [];
|
||||
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