Contacts: full detail pane, search, selection UX, empty illustration; Calendar invite deep-link
Contacts plugin: - Detail pane now renders ALL populated fields (job/company subtitle, every email, phones, addresses, websites, nickname, birthday, notes) — not just the email. - Company fallback for the display name: personal name → company → email, applied at read time so existing contacts whose name_display holds only the email now show the company without a re-save; name_display no longer stores emails going forward. - Search box in the list top bar (like Calendar): debounced, matches name/email/company, keeps focus + caret across re-renders. - Mail-style selection: hovering a row's avatar reveals a square checkbox; once anything is checked every row shows one (select mode), the bar "select all" matches and aligns with the row checkboxes, and the detail pane shows "N contact(s) selected". - Delete contact now confirms via a modal. - Empty reading pane shows contacts-illustration.svg (served from the plugin's ui/ dir), mirroring Mail's email-illustration empty state. Calendar: - Invite from a contact opens /p/calendar?guest=<email>, which pops a new-event form with that guest already added, then strips the param. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -27,6 +27,13 @@ const IC_PHONE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" st
|
||||
const IC_CHAT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/></svg>`;
|
||||
const IC_COMPOSE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`;
|
||||
const IC_CAL = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>`;
|
||||
const IC_WORK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18"/></svg>`;
|
||||
const IC_MAPPIN = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
|
||||
const IC_LINK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>`;
|
||||
const IC_SEARCH = `<svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>`;
|
||||
const CHECK_SVG = `<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-7"/></svg>`;
|
||||
// A Mail-style square selection checkbox (unchecked = outline, checked = filled).
|
||||
const checkbox = (on) => `<span class="ct-check${on ? " on" : ""}">${on ? CHECK_SVG : ""}</span>`;
|
||||
const IC_PENCIL = IC_COMPOSE;
|
||||
const IC_SORT = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4v16m0 0-3-3m3 3 3-3M17 20V4m0 0-3 3m3-3 3 3"/></svg>`;
|
||||
const IC_MORE = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>`;
|
||||
@@ -44,6 +51,10 @@ function avatarHtml(name, email, size) {
|
||||
const s = size || 40;
|
||||
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}</span>`;
|
||||
}
|
||||
function fmtBirthday(s) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s));
|
||||
return m ? `${MONTHS[+m[2] - 1]} ${+m[3]}, ${m[1]}` : String(s);
|
||||
}
|
||||
function shortDate(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return "";
|
||||
@@ -70,9 +81,28 @@ const CSS = `
|
||||
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
|
||||
.ct-listcol{flex:0 0 46%;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
|
||||
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.ct-bar{display:flex;align-items:center;gap:6px;height:49px;padding:0 14px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
|
||||
.ct-bar{display:flex;align-items:center;gap:8px;height:49px;padding:0 14px 0 18px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
|
||||
.ct-bar .sp{flex:1}
|
||||
.ct-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;color:rgb(var(--app-muted));cursor:pointer}
|
||||
/* Mail-style selection checkbox (bar "select all" + per-row on avatar-hover) */
|
||||
.ct-check{display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;border:2px solid rgb(var(--app-faint));color:#fff;box-sizing:border-box;transition:background .1s,border-color .1s}
|
||||
.ct-check.on{background:rgb(var(--app-accent));border-color:rgb(var(--app-accent))}
|
||||
.ct-selall{width:38px;flex:0 0 auto;display:flex;align-items:center;justify-content:center}
|
||||
.ct-selallbtn{border:0;background:none;cursor:pointer;padding:0;display:flex}
|
||||
.ct-selallbtn:hover .ct-check{border-color:rgb(var(--app-muted))}
|
||||
.ct-avwrap{position:relative;width:38px;height:38px;flex:0 0 auto}
|
||||
.ct-avshow{position:absolute;inset:0}
|
||||
.ct-avcheck{position:absolute;inset:0;display:none;align-items:center;justify-content:center;border:0;background:none;cursor:pointer;padding:0}
|
||||
.ct-avcheck:hover .ct-check{border-color:rgb(var(--app-muted))}
|
||||
.ct-app:not(.selmode) .ct-row:hover .ct-avshow{visibility:hidden}
|
||||
.ct-app:not(.selmode) .ct-row:hover .ct-avcheck{display:flex}
|
||||
.ct-app.selmode .ct-avshow{visibility:hidden}
|
||||
.ct-app.selmode .ct-avcheck{display:flex}
|
||||
.ct-searchbox{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
|
||||
.ct-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
|
||||
.ct-search{flex:1;min-width:0;padding:6px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
|
||||
.ct-searchclear{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;flex:0 0 auto;font-size:14px;line-height:1}
|
||||
.ct-searchclear:hover{color:rgb(var(--app-fg))}
|
||||
.ct-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;color:rgb(var(--app-muted));cursor:pointer;flex:0 0 auto}
|
||||
.ct-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||
.ct-barbtn{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:6px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px}
|
||||
.ct-barbtn:hover{background:rgb(var(--app-hover))}
|
||||
@@ -86,11 +116,15 @@ const CSS = `
|
||||
.ct-rowname{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ct-rowmail{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ct-empty{padding:60px 20px;text-align:center;color:rgb(var(--app-muted))}
|
||||
.ct-emptyill{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;color:rgb(var(--app-faint))}
|
||||
.ct-emptyill img{width:224px;max-width:55%}
|
||||
.ct-emptyill span{font-size:28px}
|
||||
.ct-detail{flex:1;min-height:0;overflow:auto;padding:20px}
|
||||
.ct-card{max-width:760px;margin:0 auto;border:1px solid rgb(var(--app-border));border-radius:12px;padding:22px 26px}
|
||||
.ct-cardhead{display:flex;gap:20px;align-items:flex-start;position:relative}
|
||||
.ct-headinfo{flex:1;min-width:0}
|
||||
.ct-name{font-size:22px;font-weight:700;margin:6px 0 12px}
|
||||
.ct-name{font-size:22px;font-weight:700;margin:6px 0 10px}
|
||||
.ct-sub{display:flex;align-items:center;gap:6px;color:rgb(var(--app-muted));font-size:14px;margin:-6px 0 12px}
|
||||
.ct-star{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
|
||||
.ct-star.on{color:#eab308}
|
||||
.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
@@ -126,6 +160,13 @@ const CSS = `
|
||||
.ct-menu{position:absolute;z-index:20;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0 / .3);padding:6px;min-width:180px}
|
||||
.ct-menu button,.ct-menu label{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:7px 9px;border-radius:6px;cursor:pointer;font-size:13px;color:rgb(var(--app-fg))}
|
||||
.ct-menu button:hover,.ct-menu label:hover{background:rgb(var(--app-hover))}
|
||||
.ct-modalbg{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:rgb(0 0 0 / .5);padding:16px}
|
||||
.ct-modal{width:100%;max-width:360px;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:12px;padding:20px;box-shadow:0 12px 40px rgb(0 0 0 / .35)}
|
||||
.ct-modal h3{font-size:16px;font-weight:600}
|
||||
.ct-modal p{margin-top:8px;font-size:14px;color:rgb(var(--app-muted))}
|
||||
.ct-modal .row{margin-top:20px;display:flex;justify-content:flex-end;gap:8px}
|
||||
.ct-danger{background:#ef4444;color:#fff;padding:6px 16px;border-radius:6px;font-weight:600;font-size:13px;cursor:pointer;border:0}
|
||||
.ct-danger:hover{opacity:.9}
|
||||
.ct-primary{background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));padding:6px 16px;border-radius:6px;font-weight:600;font-size:13px;cursor:pointer}
|
||||
.ct-primary:hover{opacity:.9}
|
||||
.ct-ghostbtn{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px}
|
||||
@@ -148,6 +189,8 @@ export default function mount(el, ctx) {
|
||||
mode: "view", // view | edit | new
|
||||
mail: null, // { loading, threads }
|
||||
sortAsc: true,
|
||||
checked: new Set(), // multi-selected contact ids (Mail-style)
|
||||
confirmDelete: false,
|
||||
menu: null, // "groups" | "more" | null
|
||||
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
|
||||
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
|
||||
@@ -157,6 +200,14 @@ export default function mount(el, ctx) {
|
||||
};
|
||||
|
||||
const api = ctx.api;
|
||||
let searchTimer = null;
|
||||
|
||||
// Reload the list for the current search term, then re-render (keeps search focus).
|
||||
async function runSearch() {
|
||||
await loadContacts();
|
||||
await loadDetail(state.selectedId);
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- data loading --------------------------------------------------------
|
||||
async function loadGroups() {
|
||||
@@ -189,6 +240,7 @@ export default function mount(el, ctx) {
|
||||
state.selectedId = id;
|
||||
state.mode = "view";
|
||||
state.menu = null;
|
||||
state.checked.clear(); // opening a contact exits multi-select mode
|
||||
await loadDetail(id);
|
||||
render();
|
||||
}
|
||||
@@ -275,29 +327,53 @@ export default function mount(el, ctx) {
|
||||
const ch = (name[0] || "#").toUpperCase();
|
||||
const key = /[A-Z]/.test(ch) ? ch : "#";
|
||||
if (key !== letter) { letter = key; html += `<div class="ct-alpha">${esc(letter)}</div>`; }
|
||||
const ck = state.checked.has(c.id);
|
||||
html += `<div class="ct-row${c.id === state.selectedId ? " sel" : ""}" data-contact="${c.id}">
|
||||
${avatarHtml(c.display_name, c.primary_email, 38)}
|
||||
<div class="ct-avwrap">
|
||||
<div class="ct-avshow">${avatarHtml(c.display_name, c.primary_email, 38)}</div>
|
||||
<button class="ct-avcheck" data-check="${c.id}" aria-label="Select contact">${checkbox(ck)}</button>
|
||||
</div>
|
||||
<div class="ct-rowmain">
|
||||
<div class="ct-rowname">${esc(name)}</div>
|
||||
<div class="ct-rowmail">${esc(c.primary_email || "")}</div>
|
||||
</div></div>`;
|
||||
}
|
||||
if (!rows.length) html = `<div class="ct-empty">No contacts${state.q ? " match your search" : " yet"}.</div>`;
|
||||
const allChecked = rows.length > 0 && rows.every((c) => state.checked.has(c.id));
|
||||
return `
|
||||
<div class="ct-bar">
|
||||
<input type="checkbox" class="ct-cbx" disabled title="Select all"/>
|
||||
<span class="sp"></span>
|
||||
<div class="ct-selall"><button class="ct-selallbtn" data-act="select-all" aria-label="Select all">${checkbox(allChecked)}</button></div>
|
||||
<div class="ct-searchbox">
|
||||
${IC_SEARCH}
|
||||
<input class="ct-search" placeholder="Search contacts" value="${esc(state.q)}"/>
|
||||
${state.q ? `<button class="ct-searchclear" data-act="search-clear" title="Clear">✕</button>` : ""}
|
||||
</div>
|
||||
<button class="ct-iconbtn" data-act="sort" title="Sort">${IC_SORT}</button>
|
||||
<button class="ct-iconbtn" data-act="more-list" title="More">${IC_MORE}</button>
|
||||
</div>
|
||||
<div class="ct-list">${html}</div>`;
|
||||
}
|
||||
|
||||
function confirmModal() {
|
||||
const name = state.detail ? (state.detail.display_name || state.detail.default_email || "this contact") : "this contact";
|
||||
return `<div class="ct-modalbg" data-act="confirm-cancel"><div class="ct-modal" data-stop>
|
||||
<h3>Delete contact</h3>
|
||||
<p>Delete “${esc(name)}”? This can't be undone.</p>
|
||||
<div class="row">
|
||||
<button class="ct-ghostbtn" data-act="confirm-cancel">Cancel</button>
|
||||
<button class="ct-danger" data-act="confirm-delete">Delete</button>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
// ---- right: detail / edit ------------------------------------------------
|
||||
function detailBar() {
|
||||
if (state.mode === "edit" || state.mode === "new") {
|
||||
return `<div class="ct-bar"><button class="ct-primary" data-act="save">Save</button><button class="ct-ghostbtn" data-act="cancel">Cancel</button></div>`;
|
||||
}
|
||||
if (state.checked.size > 0) {
|
||||
return `<div class="ct-bar"><button class="ct-barbtn" data-act="clear-sel">Clear selection</button></div>`;
|
||||
}
|
||||
const disabled = state.detail ? "" : "opacity:.4;pointer-events:none";
|
||||
return `<div class="ct-bar" style="${disabled}">
|
||||
<button class="ct-barbtn" data-act="edit">${IC_PENCIL} Edit</button>
|
||||
@@ -315,16 +391,44 @@ export default function mount(el, ctx) {
|
||||
|
||||
function detailPanel() {
|
||||
if (state.mode === "edit" || state.mode === "new") return editForm();
|
||||
// Multi-select: show the selection count (like Mail) instead of a single contact.
|
||||
if (state.checked.size > 0) {
|
||||
const n = state.checked.size;
|
||||
return `<div class="ct-detail"><div class="ct-emptyill">
|
||||
<img src="/api/plugins/contacts/ui/contacts-illustration.svg" alt="" aria-hidden="true"/>
|
||||
<span>${n} contact${n === 1 ? "" : "s"} selected</span>
|
||||
</div></div>`;
|
||||
}
|
||||
const c = state.detail;
|
||||
if (!c) return `<div class="ct-detail"><div class="ct-empty">Select a contact.</div></div>`;
|
||||
if (!c) return `<div class="ct-detail"><div class="ct-emptyill">
|
||||
<img src="/api/plugins/contacts/ui/contacts-illustration.svg" alt="" aria-hidden="true"/>
|
||||
<span>Select a contact</span>
|
||||
</div></div>`;
|
||||
const drow = (icon, label, val) => `<div class="ct-drow"><span class="ct-dico">${icon}</span><div><div class="ct-dlabel">${esc(label)}</div><div class="ct-dval">${val}</div></div></div>`;
|
||||
const withHttp = (u) => (/^https?:\/\//i.test(u) ? u : "https://" + u);
|
||||
const groups = (c.groups || []).map((g) => esc(g.name)).join(", ");
|
||||
const work = [c.title, c.department, c.org].filter(Boolean).join(", ");
|
||||
const emailRows = (c.emails && c.emails.length ? c.emails : (c.default_email ? [{ value: c.default_email, type: "personal" }] : []));
|
||||
const emailsHtml = emailRows.map((e) => `<div class="ct-drow"><span class="ct-dico">${IC_MAIL}</span><div><div class="ct-dlabel">${esc(cap(e.type || "email"))}</div><div class="ct-dval"><a href="mailto:${esc(e.value)}">${esc(e.value)}</a></div></div></div>`).join("");
|
||||
|
||||
let details = "";
|
||||
if (groups) details += drow(IC_GROUP, "Groups", groups);
|
||||
emailRows.forEach((e) => { if (e.value) details += drow(IC_MAIL, cap(e.type || "email"), `<a href="mailto:${esc(e.value)}">${esc(e.value)}</a>`); });
|
||||
(c.phones || []).forEach((p) => { if (p.value) details += drow(IC_PHONE, cap(p.type || "phone"), `<a href="tel:${esc(p.value)}">${esc(p.value)}</a>`); });
|
||||
(c.addresses || []).forEach((a) => {
|
||||
const line = [a.street, [a.postal_code, a.city].filter(Boolean).join(" "), a.country].filter(Boolean).join(", ");
|
||||
if (line) details += drow(IC_MAPPIN, cap(a.type || "address"), esc(line));
|
||||
});
|
||||
(c.urls || []).forEach((u) => { if (u.value) details += drow(IC_LINK, cap(u.type || "website"), `<a href="${esc(withHttp(u.value))}" target="_blank" rel="noopener noreferrer">${esc(u.value)}</a>`); });
|
||||
if (c.nickname) details += drow(IC_PERSON, "Nickname", esc(c.nickname));
|
||||
if (c.birthday) details += drow(IC_CAL, "Birthday", esc(fmtBirthday(c.birthday)));
|
||||
details += `<div class="ct-drow"><span class="ct-dico">${IC_PENCIL}</span><div>${c.notes ? `<div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap">${esc(c.notes)}</div>` : `<span class="ct-dlabel">Add notes</span>`}</div></div>`;
|
||||
|
||||
return `<div class="ct-detail"><div class="ct-card">
|
||||
<div class="ct-cardhead">
|
||||
${avatarHtml(c.display_name, c.default_email, 84)}
|
||||
<div class="ct-headinfo">
|
||||
<div class="ct-name">${esc(c.display_name || c.default_email || "(no name)")}</div>
|
||||
${work ? `<div class="ct-sub">${IC_WORK} ${esc(work)}</div>` : ""}
|
||||
<div class="ct-actions">
|
||||
<button class="ct-act" data-act="call">${IC_PHONE} Call</button>
|
||||
<button class="ct-act" data-act="message">${IC_CHAT} Message</button>
|
||||
@@ -337,9 +441,7 @@ export default function mount(el, ctx) {
|
||||
|
||||
<div class="ct-sec">
|
||||
<div class="ct-sectitle">Contact details</div>
|
||||
${groups ? `<div class="ct-drow"><span class="ct-dico">${IC_GROUP}</span><div><div class="ct-dlabel">Groups</div><div class="ct-dval">${groups}</div></div></div>` : ""}
|
||||
${emailsHtml}
|
||||
<div class="ct-drow"><span class="ct-dico">${IC_PENCIL}</span><div>${c.notes ? `<div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap">${esc(c.notes)}</div>` : `<span class="ct-dlabel">Add notes</span>`}</div></div>
|
||||
${details}
|
||||
</div>
|
||||
|
||||
<div class="ct-sec">
|
||||
@@ -434,7 +536,9 @@ export default function mount(el, ctx) {
|
||||
const emails = [...el.querySelectorAll("[data-emailrow]")].map((r) => ({ value: r.querySelector("[data-val]").value.trim(), type: r.querySelector("[data-type]").value })).filter((e) => e.value);
|
||||
const phones = [...el.querySelectorAll("[data-phonerow]")].map((r) => ({ value: r.querySelector("[data-val]").value.trim(), type: r.querySelector("[data-type]").value })).filter((p) => p.value);
|
||||
const given = v("[data-f=given]"), family = v("[data-f=family]"), prefix = v("[data-f=prefix]");
|
||||
const display = [prefix, given, family].filter(Boolean).join(" ") || (emails[0] ? emails[0].value : "");
|
||||
const org = v("[data-f=org]");
|
||||
// FN = personal name, else company. Never the email (display falls back to it).
|
||||
const display = [prefix, given, family].filter(Boolean).join(" ") || org || "";
|
||||
const month = v("[data-f=bmonth]"), day = v("[data-f=bday]"), year = v("[data-f=byear]");
|
||||
let birthday = null;
|
||||
if (month && day && year) birthday = `${year.padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
@@ -445,7 +549,7 @@ export default function mount(el, ctx) {
|
||||
display_name: display,
|
||||
name_parts: { prefix, given, family, middle: "", suffix: "" },
|
||||
nickname: v("[data-f=nickname]"),
|
||||
title: v("[data-f=title]"), department: v("[data-f=department]"), org: v("[data-f=org]"),
|
||||
title: v("[data-f=title]"), department: v("[data-f=department]"), org,
|
||||
birthday,
|
||||
emails, default_email: emails[0] ? emails[0].value : "",
|
||||
phones,
|
||||
@@ -472,18 +576,28 @@ export default function mount(el, ctx) {
|
||||
|
||||
// ---- render + wire -------------------------------------------------------
|
||||
function render() {
|
||||
el.innerHTML = `<div class="ct-app">
|
||||
// If the search box currently has focus, keep it (and the caret) across the
|
||||
// re-render — covers the debounced reload and the async mail-load re-render.
|
||||
const prev = el.querySelector(".ct-search");
|
||||
const keepFocus = prev && document.activeElement === prev;
|
||||
const caret = keepFocus ? prev.selectionStart : null;
|
||||
el.innerHTML = `<div class="ct-app${state.checked.size ? " selmode" : ""}">
|
||||
${state.leftCollapsed ? railPanel() : `<div class="wm-side ct-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div>`}
|
||||
<div class="ct-listcol">${listPanel()}</div>
|
||||
<div class="ct-detailcol">${detailBar()}${detailPanel()}</div>
|
||||
${state.confirmDelete ? confirmModal() : ""}
|
||||
</div>`;
|
||||
wire();
|
||||
if (keepFocus) {
|
||||
const si = el.querySelector(".ct-search");
|
||||
if (si) { si.focus(); const p = caret != null ? caret : si.value.length; si.setSelectionRange(p, p); }
|
||||
}
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
||||
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
|
||||
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; reloadList(false); };
|
||||
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); reloadList(false); };
|
||||
|
||||
on("[data-app]", "click", (e) => { const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))]; state.menuOpen = false; if (a.to) ctx.navigate(a.to); else render(); });
|
||||
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
|
||||
@@ -499,10 +613,31 @@ export default function mount(el, ctx) {
|
||||
on("[data-group]", "click", (e) => {
|
||||
state.group = e.currentTarget.getAttribute("data-group");
|
||||
state.groupLabel = e.currentTarget.getAttribute("data-groupname");
|
||||
state.filter = "group"; state.menuOpen = false; reloadList(false);
|
||||
state.filter = "group"; state.menuOpen = false; state.checked.clear(); reloadList(false);
|
||||
});
|
||||
|
||||
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
|
||||
// Selection checkboxes (revealed on avatar-hover / in select mode) — Mail-style.
|
||||
on("[data-check]", "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const id = e.currentTarget.getAttribute("data-check");
|
||||
if (state.checked.has(id)) state.checked.delete(id); else state.checked.add(id);
|
||||
render();
|
||||
});
|
||||
act("select-all", () => {
|
||||
const ids = state.contacts.map((c) => c.id);
|
||||
const all = ids.length > 0 && ids.every((id) => state.checked.has(id));
|
||||
if (all) state.checked.clear(); else ids.forEach((id) => state.checked.add(id));
|
||||
render();
|
||||
});
|
||||
act("clear-sel", () => { state.checked.clear(); render(); });
|
||||
on(".ct-search", "input", (e) => {
|
||||
state.q = e.currentTarget.value;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(runSearch, 250);
|
||||
});
|
||||
on(".ct-search", "keydown", (e) => { if (e.key === "Escape") { state.q = ""; runSearch(); } });
|
||||
act("search-clear", () => { state.q = ""; runSearch(); });
|
||||
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
|
||||
act("new", () => { state.mode = "new"; state.selectedId = null; state.detail = null; render(); });
|
||||
|
||||
@@ -514,7 +649,10 @@ export default function mount(el, ctx) {
|
||||
act("call", () => { const p = state.detail.phones && state.detail.phones[0]; if (p) window.open(`tel:${p.value}`); });
|
||||
act("message", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
|
||||
act("compose", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
|
||||
act("invite", () => ctx.navigate("/p/calendar"));
|
||||
act("invite", () => {
|
||||
const email = state.detail && state.detail.default_email;
|
||||
ctx.navigate(email ? `/p/calendar?guest=${encodeURIComponent(email)}` : "/p/calendar");
|
||||
});
|
||||
act("find-mail", () => ctx.navigate(`/mail/search/${encodeURIComponent('"' + state.detail.default_email + '"')}`));
|
||||
|
||||
// menus
|
||||
@@ -529,8 +667,10 @@ export default function mount(el, ctx) {
|
||||
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
await loadGroups(); await loadDetail(state.selectedId); render();
|
||||
});
|
||||
act("delete", async () => {
|
||||
state.menu = null;
|
||||
act("delete", () => { state.menu = null; state.confirmDelete = true; render(); });
|
||||
act("confirm-cancel", () => { state.confirmDelete = false; render(); });
|
||||
act("confirm-delete", async () => {
|
||||
state.confirmDelete = false;
|
||||
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
|
||||
state.selectedId = null;
|
||||
await reloadList(false);
|
||||
|
||||
Reference in New Issue
Block a user