v0.0.5
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+274 -274
View File
@@ -1,294 +1,294 @@
/* 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"); }
}
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
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 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"); }
}
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"); }
}
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 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 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 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 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 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 || []);
async function showContactDuplicates() {
try {
const data = await request("/contacts/duplicates", { method: "GET" });
const groups = data.duplicates || [];
const list = $("#contact-duplicate-list");
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);
if (!groups.length) {
list.append(el("div", "empty-list", "No duplicate contacts found"));
}
}
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);
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"); }
}
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);
}
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"); }
}
function clearContactPhotoObjectUrl() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
state.contactPhotoUrl = null;
}
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 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 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 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 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 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 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"); }
}
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"); }
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);
}
}
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 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 composeToContact() {
const email = $("#contact-email").value.trim();
if (!email) return;
$("#contacts-dialog").close();
openCompose("new");
$("#compose-to").value = email;
}
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;
}