Files
nidus/web/ts/contacts.ts
T
arnefandCopilot 3fa0297715 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>
2026-08-20 15:06:39 +02:00

80 lines
2.6 KiB
TypeScript

// 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;
}
});
})();