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>
78 lines
2.8 KiB
JavaScript
78 lines
2.8 KiB
JavaScript
"use strict";
|
|
// Contact form behavior: adding/removing repeatable phone, email, and
|
|
// address rows, plus a live preview when choosing a new photo.
|
|
(function initContactForm() {
|
|
document.querySelectorAll(".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(".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);
|
|
newRow.querySelectorAll("input").forEach((el) => {
|
|
el.value = "";
|
|
});
|
|
newRow.querySelectorAll("select").forEach((el) => {
|
|
el.selectedIndex = 0;
|
|
});
|
|
container.appendChild(newRow);
|
|
});
|
|
});
|
|
document.addEventListener("click", (e) => {
|
|
const target = e.target;
|
|
if (!target.classList.contains("remove-row-btn")) {
|
|
return;
|
|
}
|
|
const row = target.closest(".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.value = "";
|
|
});
|
|
row.querySelectorAll("select").forEach((el) => {
|
|
el.selectedIndex = 0;
|
|
});
|
|
});
|
|
const photoInput = document.getElementById("photo-input");
|
|
const photoPreview = document.getElementById("photo-preview");
|
|
const currentAvatar = document.getElementById("current-avatar");
|
|
const removePhotoCheckbox = document.getElementById("remove-photo");
|
|
photoInput?.addEventListener("change", () => {
|
|
const file = photoInput.files?.[0];
|
|
if (!file || !photoPreview) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
photoPreview.src = reader.result;
|
|
photoPreview.classList.remove("hidden");
|
|
currentAvatar?.classList.add("hidden");
|
|
};
|
|
reader.readAsDataURL(file);
|
|
if (removePhotoCheckbox) {
|
|
removePhotoCheckbox.checked = false;
|
|
}
|
|
});
|
|
})();
|