From a393939628b671c0edabe1c7d4a18f73d3eba597 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Sun, 2 Aug 2026 11:57:39 +0200 Subject: [PATCH] Contacts: full detail pane, search, selection UX, empty illustration; Calendar invite deep-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=, which pops a new-event form with that guest already added, then strips the param. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA --- frontend/icons/contacts-illustration.svg | 191 ++++++++++++++++++ .../webmail_plugin_calendar/ui/page.js | 11 + .../webmail_plugin_contacts/pyproject.toml | 2 +- .../webmail_plugin_contacts/router.py | 41 ++-- .../ui/contacts-illustration.svg | 191 ++++++++++++++++++ .../webmail_plugin_contacts/ui/page.js | 178 ++++++++++++++-- 6 files changed, 581 insertions(+), 33 deletions(-) create mode 100644 frontend/icons/contacts-illustration.svg create mode 100644 plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/contacts-illustration.svg diff --git a/frontend/icons/contacts-illustration.svg b/frontend/icons/contacts-illustration.svg new file mode 100644 index 0000000..27d76e3 --- /dev/null +++ b/frontend/icons/contacts-illustration.svg @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 719b6ad..6c196c0 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -1424,6 +1424,17 @@ export default function mount(el, ctx) { await loadCalendars(); await loadEvents(); render(); + // Deep link (Contacts "Invite"): /p/calendar?guest= opens a new-event + // form with that guest already added, then strips the param so refresh is clean. + try { + const guest = new URLSearchParams(window.location.search).get("guest"); + if (guest) { + window.history.replaceState({}, "", window.location.pathname); + openForm(null); + const addChip = expandGuests(); + if (addChip) addChip(guest, "", false); + } + } catch { /* ignore */ } })(); return () => { diff --git a/plugins/webmail_plugin_contacts/pyproject.toml b/plugins/webmail_plugin_contacts/pyproject.toml index a002f6e..7b3d4eb 100644 --- a/plugins/webmail_plugin_contacts/pyproject.toml +++ b/plugins/webmail_plugin_contacts/pyproject.toml @@ -17,4 +17,4 @@ contacts = "webmail_plugin_contacts:plugin" packages = ["webmail_plugin_contacts"] [tool.setuptools.package-data] -webmail_plugin_contacts = ["ui/*.js", "migrations/versions/*.py"] +webmail_plugin_contacts = ["ui/*.js", "ui/*.svg", "migrations/versions/*.py"] diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py index c6f79a2..909a829 100644 --- a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py @@ -50,8 +50,7 @@ def _json(value: object) -> Any: def _display_name(row: dict[str, object]) -> str: - if row.get("name_display"): - return str(row["name_display"]) + # 1) a real personal name (N parts) always wins. parts = _json(row.get("name_parts")) or {} if isinstance(parts, dict): joined = " ".join( @@ -61,10 +60,14 @@ def _display_name(row: dict[str, object]) -> str: ) if joined: return joined - emails = _json(row.get("emails")) - if emails and isinstance(emails, list): - return str(emails[0].get("value", "")) if isinstance(emails[0], dict) else "" - return "" + org = str(row.get("org") or "").strip() + nd = str(row.get("name_display") or "").strip() + primary = _primary_email(row) + # 2) a stored FN that is a real name (not just the legacy email fallback) wins. + if nd and nd != primary: + return nd + # 3) otherwise the company name, 4) otherwise the email. + return org or primary def _primary_email(row: dict[str, object]) -> str: @@ -157,13 +160,20 @@ async def _group_map(s: AsyncSession) -> dict[str, list[dict[str, str]]]: # --- bundle ------------------------------------------------------------------ +_UI_FILES = { + "page.js": "text/javascript", + "contacts-illustration.svg": "image/svg+xml", # empty reading-pane illustration +} + + @router.get("/ui/{filename}") async def bundle(filename: str) -> FileResponse: - if filename != "page.js": + media_type = _UI_FILES.get(filename) + if media_type is None: raise HTTPException(status_code=404, detail="not_found") return FileResponse( _UI / filename, - media_type="text/javascript", + media_type=media_type, headers={"Cache-Control": "no-cache"}, ) @@ -242,7 +252,9 @@ async def list_contacts( ) params["g"] = group if q.strip(): - where.append("(c.name_display ILIKE :q OR c.emails::text ILIKE :q)") + where.append( + "(c.name_display ILIKE :q OR c.emails::text ILIKE :q OR c.org ILIKE :q)" + ) params["q"] = f"%{q.strip()}%" order = ( "c.updated_at DESC" @@ -302,8 +314,10 @@ def _derive_name(body: ContactIn) -> str: for x in (np.prefix, np.given, np.middle, np.family, np.suffix) if x.strip() ).strip() - if not name and body.emails: - name = body.emails[0].value.strip() + if not name and body.org.strip(): + name = body.org.strip() + # Deliberately NOT falling back to the email — name_display holds only a real + # name or company; the email fallback for display happens at read time. return name @@ -435,8 +449,9 @@ async def suggest( rows = ( await s.execute( text( - "SELECT id, name_display, emails, default_email FROM plg_contacts_contacts " - "WHERE name_display ILIKE :q OR emails::text ILIKE :q " + "SELECT id, name_display, name_parts, org, emails, default_email " + "FROM plg_contacts_contacts " + "WHERE name_display ILIKE :q OR emails::text ILIKE :q OR org ILIKE :q " "ORDER BY lower(name_display) LIMIT 10" ), {"q": like}, diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/contacts-illustration.svg b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/contacts-illustration.svg new file mode 100644 index 0000000..27d76e3 --- /dev/null +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/contacts-illustration.svg @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js index 9ad96e1..9a38e00 100644 --- a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js @@ -27,6 +27,13 @@ const IC_PHONE = ``; const IC_COMPOSE = ``; const IC_CAL = ``; +const IC_WORK = ``; +const IC_MAPPIN = ``; +const IC_LINK = ``; +const IC_SEARCH = ``; +const CHECK_SVG = ``; +// A Mail-style square selection checkbox (unchecked = outline, checked = filled). +const checkbox = (on) => `${on ? CHECK_SVG : ""}`; const IC_PENCIL = IC_COMPOSE; const IC_SORT = ``; const IC_MORE = ``; @@ -44,6 +51,10 @@ function avatarHtml(name, email, size) { const s = size || 40; return `${esc(initials(name || email))}`; } +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 += `
${esc(letter)}
`; } + const ck = state.checked.has(c.id); html += `
- ${avatarHtml(c.display_name, c.primary_email, 38)} +
+
${avatarHtml(c.display_name, c.primary_email, 38)}
+ +
${esc(name)}
${esc(c.primary_email || "")}
`; } if (!rows.length) html = `
No contacts${state.q ? " match your search" : " yet"}.
`; + const allChecked = rows.length > 0 && rows.every((c) => state.checked.has(c.id)); return `
- - +
+
${html}
`; } + function confirmModal() { + const name = state.detail ? (state.detail.display_name || state.detail.default_email || "this contact") : "this contact"; + return `
+

Delete contact

+

Delete “${esc(name)}”? This can't be undone.

+
+ + +
+
`; + } + // ---- right: detail / edit ------------------------------------------------ function detailBar() { if (state.mode === "edit" || state.mode === "new") { return `
`; } + if (state.checked.size > 0) { + return `
`; + } const disabled = state.detail ? "" : "opacity:.4;pointer-events:none"; return `
@@ -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 `
+ + ${n} contact${n === 1 ? "" : "s"} selected +
`; + } const c = state.detail; - if (!c) return `
Select a contact.
`; + if (!c) return `
+ + Select a contact +
`; + const drow = (icon, label, val) => `
${icon}
${esc(label)}
${val}
`; + 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) => `
${IC_MAIL}
${esc(cap(e.type || "email"))}
`).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"), `${esc(e.value)}`); }); + (c.phones || []).forEach((p) => { if (p.value) details += drow(IC_PHONE, cap(p.type || "phone"), `${esc(p.value)}`); }); + (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"), `${esc(u.value)}`); }); + if (c.nickname) details += drow(IC_PERSON, "Nickname", esc(c.nickname)); + if (c.birthday) details += drow(IC_CAL, "Birthday", esc(fmtBirthday(c.birthday))); + details += `
${IC_PENCIL}
${c.notes ? `
Notes
${esc(c.notes)}
` : `Add notes`}
`; + return `
${avatarHtml(c.display_name, c.default_email, 84)}
${esc(c.display_name || c.default_email || "(no name)")}
+ ${work ? `
${IC_WORK} ${esc(work)}
` : ""}
@@ -337,9 +441,7 @@ export default function mount(el, ctx) {
Contact details
- ${groups ? `
${IC_GROUP}
Groups
${groups}
` : ""} - ${emailsHtml} -
${IC_PENCIL}
${c.notes ? `
Notes
${esc(c.notes)}
` : `Add notes`}
+ ${details}
@@ -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 = `
+ // 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 = `
${state.leftCollapsed ? railPanel() : `
${leftPanel()}
`}
${listPanel()}
${detailBar()}${detailPanel()}
+ ${state.confirmDelete ? confirmModal() : ""}
`; 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);