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>
80 lines
2.6 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
})();
|