295 lines
15 KiB
JavaScript
295 lines
15 KiB
JavaScript
/* RustBox UI module: 70-contacts.js */
|
|
async function openContacts() {
|
|
$("#contacts-dialog").showModal();
|
|
await loadContactGroups();
|
|
await loadContacts();
|
|
}
|
|
|
|
async function loadContactGroups() {
|
|
try {
|
|
const data = await request("/contact-groups", { method: "GET" });
|
|
state.contactGroups = data.groups || [];
|
|
renderContactGroupFilter();
|
|
renderContactGroupMembership($("#contact-id").value || null, []);
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
function renderContactGroupFilter() {
|
|
const select = $("#contact-group-filter");
|
|
const current = select.value;
|
|
select.replaceChildren(new Option("All contacts", ""));
|
|
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
|
|
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
|
const hasGroup = Boolean(select.value);
|
|
$("#contact-group-rename").disabled = !hasGroup;
|
|
$("#contact-group-delete").disabled = !hasGroup;
|
|
}
|
|
|
|
async function downloadContactExport() {
|
|
try {
|
|
const headers = new Headers();
|
|
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
|
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
|
|
if (!response.ok) throw new Error(`Export failed (${response.status})`);
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = "contacts.vcf";
|
|
document.body.append(link);
|
|
link.click();
|
|
link.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
toast("Calendar exported", "success");
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function importContactVcard(files) {
|
|
const file = files?.[0];
|
|
if (!file) return;
|
|
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
|
|
const form = new FormData();
|
|
form.append("file", file, file.name || "contacts.vcf");
|
|
try {
|
|
const data = await request("/contacts/import", { method: "POST", body: form });
|
|
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
|
|
await loadContacts($("#contact-search").value.trim());
|
|
} catch (error) { toast(error.message, "error"); }
|
|
finally { $("#contact-import-file").value = ""; }
|
|
}
|
|
|
|
async function showContactDuplicates() {
|
|
try {
|
|
const data = await request("/contacts/duplicates", { method: "GET" });
|
|
const groups = data.duplicates || [];
|
|
const list = $("#contact-duplicate-list");
|
|
list.replaceChildren();
|
|
if (!groups.length) {
|
|
list.append(el("div", "empty-list", "No duplicate contacts found"));
|
|
}
|
|
for (const group of groups) {
|
|
const card = el("section", "duplicate-contact-group");
|
|
card.append(el("strong", "", group.email));
|
|
const rows = el("div", "duplicate-contact-options");
|
|
for (const contact of group.contacts || []) {
|
|
const row = el("div", "duplicate-contact-row");
|
|
const summary = el("div", "duplicate-contact-summary");
|
|
summary.append(el("strong", "", contact.name || contact.email));
|
|
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
|
|
const keep = el("button", "button quiet", "Keep this");
|
|
keep.type = "button";
|
|
keep.addEventListener("click", async () => {
|
|
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
|
|
if (!sources.length) return;
|
|
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
|
|
try {
|
|
let target = contact;
|
|
for (const source of sources) {
|
|
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
|
|
}
|
|
toast("Contacts merged");
|
|
await loadContacts($("#contact-search").value.trim());
|
|
await showContactDuplicates();
|
|
editContact(target);
|
|
} catch (error) { toast(error.message, "error"); }
|
|
});
|
|
row.append(summary, keep); rows.append(row);
|
|
}
|
|
card.append(rows); list.append(card);
|
|
}
|
|
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function loadContacts(query = "") {
|
|
try {
|
|
const groupId = $("#contact-group-filter")?.value || "";
|
|
let contacts;
|
|
if (groupId) {
|
|
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
|
|
contacts = data.members || [];
|
|
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
|
|
} else {
|
|
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
|
|
contacts = data.contacts || [];
|
|
}
|
|
state.contacts = contacts;
|
|
renderContacts();
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function createContactGroup() {
|
|
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
|
|
if (!name?.trim()) return;
|
|
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
|
|
catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function renameContactGroup() {
|
|
const id = $("#contact-group-filter").value;
|
|
const group = state.contactGroups.find((item) => item.id === id);
|
|
if (!group) return;
|
|
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
|
|
if (!name?.trim() || name.trim() === group.name) return;
|
|
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
|
|
catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function deleteContactGroup() {
|
|
const id = $("#contact-group-filter").value;
|
|
const group = state.contactGroups.find((item) => item.id === id);
|
|
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
|
|
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
|
|
catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function loadContactMembership(contactId) {
|
|
if (!contactId) return renderContactGroupMembership(null, []);
|
|
try {
|
|
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
|
|
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
function renderContactGroupMembership(contactId, selectedIds) {
|
|
const list = $("#contact-group-membership");
|
|
if (!list) return;
|
|
const selected = new Set(selectedIds || []);
|
|
list.replaceChildren();
|
|
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
|
|
for (const group of state.contactGroups) {
|
|
const label = el("label", "checkbox-row");
|
|
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
|
|
input.addEventListener("change", async () => {
|
|
try {
|
|
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
|
|
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
|
|
await loadContactGroups();
|
|
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
|
|
});
|
|
label.append(input, el("span", "", group.name)); list.append(label);
|
|
}
|
|
}
|
|
|
|
function renderContacts() {
|
|
const list = $("#contact-list");
|
|
list.replaceChildren();
|
|
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
|
|
for (const contact of state.contacts) {
|
|
const button = el("button", "contact-item");
|
|
button.type = "button";
|
|
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
|
|
button.addEventListener("click", () => editContact(contact));
|
|
list.append(button);
|
|
}
|
|
}
|
|
|
|
function editContact(contact = null) {
|
|
$("#contact-id").value = contact?.id || "";
|
|
$("#contact-name").value = contact?.name || "";
|
|
$("#contact-email").value = contact?.email || "";
|
|
$("#contact-emails").value = (contact?.emails || []).join("\n");
|
|
$("#contact-company").value = contact?.company || "";
|
|
$("#contact-phone").value = contact?.phone || "";
|
|
$("#contact-phones").value = (contact?.phones || []).join("\n");
|
|
$("#contact-job-title").value = contact?.job_title || "";
|
|
$("#contact-website").value = contact?.website || "";
|
|
$("#contact-birthday").value = contact?.birthday || "";
|
|
$("#contact-address").value = contact?.address || "";
|
|
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
|
|
$("#contact-notes").value = contact?.notes || "";
|
|
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
|
|
$("#contact-delete").disabled = !contact;
|
|
$("#contact-compose").disabled = !contact?.email;
|
|
$("#contact-photo-upload").disabled = !contact?.id;
|
|
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
|
|
renderContactPhoto(contact);
|
|
loadContactMembership(contact?.id || null);
|
|
}
|
|
|
|
function clearContactPhotoObjectUrl() {
|
|
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
|
|
state.contactPhotoUrl = null;
|
|
}
|
|
|
|
async function renderContactPhoto(contact) {
|
|
clearContactPhotoObjectUrl();
|
|
const image = $("#contact-photo-preview");
|
|
const placeholder = $("#contact-photo-placeholder");
|
|
image.classList.add("hidden");
|
|
image.removeAttribute("src");
|
|
placeholder.classList.remove("hidden");
|
|
if (!contact?.id || !contact?.has_photo) return;
|
|
try {
|
|
const headers = new Headers();
|
|
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
|
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
|
|
if (!response.ok) return;
|
|
const blob = await response.blob();
|
|
state.contactPhotoUrl = URL.createObjectURL(blob);
|
|
image.src = state.contactPhotoUrl;
|
|
image.classList.remove("hidden");
|
|
placeholder.classList.add("hidden");
|
|
} catch (_) { }
|
|
}
|
|
|
|
async function uploadContactPhoto(files) {
|
|
const id = $("#contact-id").value;
|
|
const file = files?.[0];
|
|
if (!id || !file) return;
|
|
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
|
|
const form = new FormData();
|
|
form.append("file", file, file.name || "photo");
|
|
try {
|
|
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
|
|
const contact = state.contacts.find((item) => item.id === id);
|
|
if (contact) contact.has_photo = true;
|
|
$("#contact-photo-delete").disabled = false;
|
|
await renderContactPhoto({ id, has_photo: true });
|
|
toast("Contact photo saved");
|
|
} catch (error) { toast(error.message, "error"); }
|
|
finally { $("#contact-photo-file").value = ""; }
|
|
}
|
|
|
|
async function deleteContactPhoto() {
|
|
const id = $("#contact-id").value;
|
|
if (!id) return;
|
|
try {
|
|
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
|
|
const contact = state.contacts.find((item) => item.id === id);
|
|
if (contact) contact.has_photo = false;
|
|
$("#contact-photo-delete").disabled = true;
|
|
await renderContactPhoto({ id, has_photo: false });
|
|
toast("Contact photo removed");
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function saveContact(event) {
|
|
event.preventDefault();
|
|
const payload = {
|
|
id: $("#contact-id").value || null,
|
|
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
|
|
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
|
|
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
|
|
is_favorite: $("#contact-favorite").checked, source: "manual",
|
|
};
|
|
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
|
|
catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function deleteContact() {
|
|
const id = $("#contact-id").value;
|
|
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
|
|
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
|
|
catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
function composeToContact() {
|
|
const email = $("#contact-email").value.trim();
|
|
if (!email) return;
|
|
$("#contacts-dialog").close();
|
|
openCompose("new");
|
|
$("#compose-to").value = email;
|
|
}
|
|
|