Files
webmail-client/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js
T
lukasandClaude Opus 4.8 a393939628 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
2026-08-02 11:57:39 +02:00

732 lines
49 KiB
JavaScript

// contacts · app.page bundle (SPEC §B.6). A full-bleed three-pane contacts app:
// shared sidebar template (.wm-side-*, identical to Mail/Calendar) with a
// "Contacts" header, an alphabetically grouped contact list, and a detail pane
// with an edit-in-place form. Framework-agnostic vanilla DOM; theme-aware via
// rgb(var(--app-*)); data comes from the plugin API.
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const EMAIL_TYPES = ["personal", "work", "other"];
const PHONE_TYPES = ["mobile", "home", "work", "other"];
const ADDR_TYPES = ["home", "work", "other"];
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
// Icons copied from Mail/Calendar (frontend/icons/*.svg) so the apps match exactly.
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
const PLUS_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>`;
const IC_BOOK = `<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="4" y="3" width="14" height="18" rx="2"/><path d="M4 8h-.5M4 12h-.5M4 16h-.5"/><circle cx="11" cy="10" r="2"/><path d="M8 16c.5-1.5 1.7-2 3-2s2.5.5 3 2"/></svg>`;
const IC_STAR = `<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="M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9 6.8 19.6l1-5.8L3.5 9.7l5.9-.9z"/></svg>`;
const IC_CLOCK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>`;
const IC_GROUP = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3 3-5 6-5s6 2 6 5"/><path d="M16 6a3 3 0 0 1 0 5m1 8c0-2-1-3.5-2.5-4.3"/></svg>`;
const IC_PERSON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg>`;
const IC_MAIL = `<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="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>`;
const IC_PHONE = `<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="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2"/></svg>`;
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>`;
const CHEV = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>`;
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
const AVATAR_COLORS = ["#ef4444","#f97316","#eab308","#22c55e","#14b8a6","#3b82f6","#8b5cf6","#ec4899","#6b7280"];
function avatarColor(key) { let h = 0; for (const c of String(key || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0; return AVATAR_COLORS[h % AVATAR_COLORS.length]; }
function initials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return ((parts[0][0] || "") + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
}
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 "";
const now = new Date();
const opts = d.getFullYear() === now.getFullYear() ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" };
return d.toLocaleDateString("en-GB", opts);
}
const CSS = `
.ct-app{display:flex;height:100vh;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
.ct-app .wm-side-item.active{background:rgb(var(--app-active));color:rgb(var(--app-fg));font-weight:600}
.ct-app .wm-side-item .ct-dico{margin-top:0}
/* collapsed icon-only rail (matches Mail's 60px rail) */
.ct-rail{width:60px;align-items:center}
.ct-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
.ct-railprimary{padding:2px 0 8px}
.ct-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
.ct-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
.ct-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
.ct-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.ct-railnew:hover{opacity:.9}
.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: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}
/* 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))}
.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
.ct-list{flex:1;min-height:0;overflow:auto}
.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 18px 2px;z-index:1}
.ct-row{display:flex;align-items:center;gap:12px;padding:9px 18px;cursor:pointer;border-bottom:1px solid rgb(var(--app-border) / .5)}
.ct-row:hover{background:rgb(var(--app-hover))}
.ct-row.sel{background:rgb(var(--app-active))}
.ct-rowmain{min-width:0;flex:1}
.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 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}
.ct-act{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid rgb(var(--app-border));border-radius:7px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px;background:rgb(var(--app-surface))}
.ct-act:hover{background:rgb(var(--app-hover))}
.ct-sec{margin-top:26px}
.ct-sectitle{font-size:15px;font-weight:700;margin-bottom:6px}
.ct-drow{display:flex;gap:14px;align-items:flex-start;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
.ct-dico{color:rgb(var(--app-muted));margin-top:2px}
.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
.ct-dval{margin-top:1px}
.ct-mailrow{display:flex;gap:12px;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
.ct-mailmain{min-width:0;flex:1}
.ct-mailsub{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-mailsnip{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-maildate{color:rgb(var(--app-muted));font-size:12px;flex:0 0 auto}
.ct-findbtn{margin-top:14px;display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:7px;background:rgb(var(--app-hover));cursor:pointer;font-size:13px}
/* --- edit form --- */
.ct-form{max-width:640px;margin:0 auto;padding:6px 0 40px}
.ct-formav{text-align:center;margin-bottom:6px}
.ct-formav .ct-av{margin:0 auto}
.ct-formav .cap{color:rgb(var(--app-muted));font-size:12px;margin-top:6px}
.ct-frow{display:flex;gap:10px;margin-bottom:12px}
.ct-fsec{font-weight:700;margin:22px 0 10px}
.ct-flabel{width:90px;flex:0 0 auto;color:rgb(var(--app-muted));font-size:13px;padding-top:9px}
.ct-in,.ct-sel{width:100%;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:6px;padding:8px 10px;color:rgb(var(--app-fg));font-size:14px;outline:none}
.ct-in:focus,.ct-sel:focus{border-color:rgb(var(--app-accent))}
.ct-multi{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.ct-multi .ct-sel{width:130px;flex:0 0 auto}
.ct-rm{color:rgb(var(--app-muted));cursor:pointer;padding:4px;flex:0 0 auto}
.ct-rm:hover{color:rgb(var(--app-fg))}
.ct-addline{color:rgb(var(--app-accent));cursor:pointer;font-size:13px;display:inline-flex;align-items:center;gap:6px;margin-top:2px}
.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}
.ct-ghostbtn:hover{background:rgb(var(--app-hover))}
`;
export default function mount(el, ctx) {
if (!document.getElementById("ct-styles")) {
const style = document.createElement("style");
style.id = "ct-styles";
style.textContent = CSS;
document.head.appendChild(style);
}
const state = {
filter: "all", group: "", groupLabel: "All contacts",
q: "",
contacts: [], groups: [],
selectedId: null, detail: null,
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",
menuOpen: false,
me: "", quota: "",
apps: [{ label: "Mail", to: "/mail" }, { label: "Contacts", to: null }],
};
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() {
try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
}
async function loadContacts() {
const qs = `filter=${encodeURIComponent(state.filter)}&group=${encodeURIComponent(state.group)}&q=${encodeURIComponent(state.q)}`;
const res = await api.pluginFetch(`/contacts?${qs}`);
state.contacts = res.contacts || [];
if (!state.contacts.find((c) => c.id === state.selectedId)) {
state.selectedId = state.contacts[0] ? state.contacts[0].id : null;
}
}
async function loadDetail(id) {
if (!id) { state.detail = null; state.mail = null; return; }
state.detail = await api.pluginFetch(`/contacts/${id}`);
loadMail(state.detail.default_email);
}
async function loadMail(email) {
state.mail = { loading: true, threads: [] };
if (!email) { state.mail = { loading: false, threads: [] }; render(); return; }
try {
const res = await api.fetch(`/search?q=${encodeURIComponent('"' + email + '"')}&limit=5`);
state.mail = { loading: false, threads: (res.threads || []).map((h) => h.thread) };
} catch { state.mail = { loading: false, threads: [] }; }
render();
}
async function selectContact(id) {
state.selectedId = id;
state.mode = "view";
state.menu = null;
state.checked.clear(); // opening a contact exits multi-select mode
await loadDetail(id);
render();
}
async function reloadList(keepSelection) {
if (!keepSelection) state.selectedId = state.selectedId;
await loadContacts();
await loadDetail(state.selectedId);
render();
}
// ---- sidebar (shared .wm-side-* template; identical to Mail/Calendar) -----
function navItem(icon, label, active, act) {
return `<button class="wm-side-item${active ? " active" : ""}" data-act="${act}"><span class="ct-dico">${icon}</span>${esc(label)}</button>`;
}
function leftPanel() {
const f = state.filter;
const grp = (g) => `<button class="wm-side-item${state.group === g.id ? " active" : ""}" data-group="${g.id}" data-groupname="${esc(g.name)}"><span class="ct-dico">${IC_GROUP}</span>${esc(g.name)}</button>`;
return `
<div class="wm-side-head">
<div class="wm-side-switch">
<button data-act="menu">Contacts ${CHEV}</button>
${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
</div>
<div class="wm-side-headicons">
<button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
</div>
<div class="wm-side-primary">
<button data-act="new">${PLUS_ICON}New contact</button>
</div>
<div class="wm-side-body">
<div class="wm-side-nav">
${navItem(IC_BOOK, "All contacts", !state.group && f === "all", "filter-all")}
${navItem(IC_STAR, "VIPs", !state.group && f === "vip", "filter-vip")}
${navItem(IC_CLOCK, "Recent", !state.group && f === "recent", "filter-recent")}
</div>
<div class="wm-side-section">Groups</div>
<div class="wm-side-nav">
${state.groups.map(grp).join("")}
${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
</div>
</div>
<div class="wm-side-foot">
<button data-act="settings">${SETTINGS_ICON}Settings</button>
<button data-act="signout">${LOGOUT_ICON}Sign out</button>
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
</div>`;
}
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
// toggle, so it can always be re-opened.
function railPanel() {
const nav = (icon, act, on, title) => `<button class="ct-railbtn${on ? " active" : ""}" data-act="${act}" title="${title}">${icon}</button>`;
return `<div class="wm-side ct-rail" style="flex:0 0 auto">
<div class="ct-railtop">
<button class="ct-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
<button class="ct-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
<div class="ct-railprimary"><button class="ct-railnew" data-act="new" title="New contact">${PLUS_ICON}</button></div>
<div class="ct-railnav">
${nav(IC_BOOK, "filter-all", !state.group && state.filter === "all", "All contacts")}
${nav(IC_STAR, "filter-vip", !state.group && state.filter === "vip", "VIPs")}
${nav(IC_CLOCK, "filter-recent", !state.group && state.filter === "recent", "Recent")}
</div>
<div class="ct-railfoot">
<button class="ct-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
<button class="ct-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
</div>
</div>`;
}
// ---- middle: contact list ------------------------------------------------
function listPanel() {
const rows = [...state.contacts].sort((a, b) => {
const r = (a.display_name || a.primary_email || "").localeCompare(b.display_name || b.primary_email || "", undefined, { sensitivity: "base" });
return state.sortAsc ? r : -r;
});
let html = "";
let letter = null;
for (const c of rows) {
const name = c.display_name || c.primary_email || "(no name)";
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}">
<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">
<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>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-groups">${IC_GROUP} Groups ${CHEV}</button>${state.menu === "groups" ? groupsMenu() : ""}</div>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-more">More ${CHEV}</button>${state.menu === "more" ? moreMenu() : ""}</div>
</div>`;
}
function groupsMenu() {
const mine = new Set((state.detail.groups || []).map((g) => g.id));
return `<div class="ct-menu" data-stop>${state.groups.map((g) => `<label><input type="checkbox" data-toggle-group="${g.id}" ${mine.has(g.id) ? "checked" : ""}/> ${esc(g.name)}</label>`).join("") || `<div class="ct-dlabel" style="padding:6px 9px">No groups yet</div>`}</div>`;
}
function moreMenu() {
return `<div class="ct-menu" data-stop><button data-act="delete">Delete contact</button></div>`;
}
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-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" }] : []));
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>
<button class="ct-act" data-act="compose">${IC_COMPOSE} Compose</button>
<button class="ct-act" data-act="invite">${IC_CAL} Invite</button>
</div>
</div>
<button class="ct-star${c.starred ? " on" : ""}" data-act="star" title="VIP">${IC_STAR}</button>
</div>
<div class="ct-sec">
<div class="ct-sectitle">Contact details</div>
${details}
</div>
<div class="ct-sec">
<div class="ct-sectitle">Mail</div>
${mailSection(c)}
</div>
<div class="ct-sec">
<div class="ct-sectitle">Events</div>
<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">No matches found</div>
</div>
</div></div>`;
}
function mailSection(c) {
if (!state.mail || state.mail.loading) return `<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">Loading…</div>`;
if (!state.mail.threads.length) return `<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">No matches found</div>`;
const rows = state.mail.threads.map((t) => {
const who = (t.participants && t.participants[0]) ? (t.participants[0].name || t.participants[0].email) : (c.display_name || "");
return `<div class="ct-mailrow">${avatarHtml(who, (t.participants && t.participants[0] && t.participants[0].email) || c.default_email, 32)}
<div class="ct-mailmain"><div class="ct-mailsub">${esc(t.subject || "(no subject)")}</div><div class="ct-mailsnip">${esc(t.snippet || "")}</div></div>
<div class="ct-maildate">${esc(shortDate(t.last_message_at))}</div></div>`;
}).join("");
return rows + `<button class="ct-findbtn" data-act="find-mail">${IC_MAIL} Find all conversations with ${esc((c.display_name || "").split(" ")[0] || "this contact")}</button>`;
}
// ---- edit form -----------------------------------------------------------
function typeSelect(kind, current) {
const opts = kind === "email" ? EMAIL_TYPES : kind === "phone" ? PHONE_TYPES : ADDR_TYPES;
return `<select class="ct-sel" data-type>${opts.map((o) => `<option value="${o}"${o === current ? " selected" : ""}>${esc(cap(o))}</option>`).join("")}</select>`;
}
function emailRow(e) {
return `<div class="ct-multi" data-emailrow>${typeSelect("email", (e && e.type) || "personal")}<input class="ct-in" data-val placeholder="email@example.com" value="${esc(e ? e.value : "")}"/><span class="ct-rm" data-act="rm-email">✕</span></div>`;
}
function phoneRow(p) {
return `<div class="ct-multi" data-phonerow>${typeSelect("phone", (p && p.type) || "mobile")}<input class="ct-in" data-val placeholder="0123 456 789" value="${esc(p ? p.value : "")}"/><span class="ct-rm" data-act="rm-phone">✕</span></div>`;
}
function editForm() {
const c = state.detail && state.mode === "edit" ? state.detail : {};
const np = c.name_parts || {};
const emails = (c.emails && c.emails.length ? c.emails : [null]);
const phones = (c.phones && c.phones.length ? c.phones : [null]);
const url0 = (c.urls && c.urls[0]) || {};
const ad0 = (c.addresses && c.addresses[0]) || {};
const bday = c.birthday ? new Date(c.birthday) : null;
const monthSel = `<select class="ct-sel" data-f=bmonth><option value="">Month</option>${MONTHS.map((m, i) => `<option value="${i + 1}"${bday && bday.getMonth() === i ? " selected" : ""}>${m}</option>`).join("")}</select>`;
return `<div class="ct-detail"><div class="ct-form">
<div class="ct-formav">${avatarHtml(c.display_name, c.default_email, 88)}<div class="cap">Photo from initials</div></div>
<div class="ct-frow">
<input class="ct-in" data-f=prefix placeholder="Title" style="flex:0 0 110px" value="${esc(np.prefix || "")}"/>
<input class="ct-in" data-f=given placeholder="First name" value="${esc(np.given || "")}"/>
<input class="ct-in" data-f=family placeholder="Last name" value="${esc(np.family || "")}"/>
</div>
<div class="ct-frow">
<input class="ct-in" data-f=title placeholder="Job title" value="${esc(c.title || "")}"/>
<input class="ct-in" data-f=department placeholder="Department" value="${esc(c.department || "")}"/>
<input class="ct-in" data-f=org placeholder="Company" value="${esc(c.org || "")}"/>
</div>
<div class="ct-fsec">Personal</div>
<div class="ct-frow"><div class="ct-flabel">Nickname</div><input class="ct-in" data-f=nickname value="${esc(c.nickname || "")}"/></div>
<div class="ct-frow"><div class="ct-flabel">Birthday</div>${monthSel}
<input class="ct-in" data-f=bday placeholder="Day" style="flex:0 0 90px" value="${bday ? bday.getDate() : ""}"/>
<input class="ct-in" data-f=byear placeholder="Year" style="flex:0 0 110px" value="${bday ? bday.getFullYear() : ""}"/></div>
<div class="ct-fsec">Email</div>
<div data-emails>${emails.map(emailRow).join("")}</div>
<span class="ct-addline" data-act="add-email">${PLUS_ICON} Add email</span>
<div class="ct-fsec">Phone</div>
<div data-phones>${phones.map(phoneRow).join("")}</div>
<span class="ct-addline" data-act="add-phone">${PLUS_ICON} Add phone</span>
<div class="ct-fsec">Online</div>
<div class="ct-multi"><select class="ct-sel" data-f=urltype><option>Website</option></select><input class="ct-in" data-f=url placeholder="www.example.com" value="${esc(url0.value || "")}"/></div>
<div class="ct-fsec">Address</div>
<div class="ct-frow">${typeSelectAddr(ad0.type || "home")}<input class="ct-in" data-f=country placeholder="Country" value="${esc(ad0.country || "")}"/></div>
<div class="ct-frow"><input class="ct-in" data-f=street placeholder="Street" value="${esc(ad0.street || "")}"/></div>
<div class="ct-frow"><input class="ct-in" data-f=postal placeholder="Postal code" style="flex:0 0 160px" value="${esc(ad0.postal_code || "")}"/><input class="ct-in" data-f=city placeholder="City" value="${esc(ad0.city || "")}"/></div>
<div class="ct-fsec">Notes</div>
<textarea class="ct-in" data-f=notes rows="3">${esc(c.notes || "")}</textarea>
</div></div>`;
}
function typeSelectAddr(current) {
return `<select class="ct-sel" data-f=addrtype style="flex:0 0 130px">${ADDR_TYPES.map((o) => `<option value="${o}"${o === current ? " selected" : ""}>${esc(cap(o))}</option>`).join("")}</select>`;
}
function collectForm() {
const v = (sel) => { const n = el.querySelector(sel); return n ? n.value.trim() : ""; };
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 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")}`;
const url = v("[data-f=url]");
const addr = { type: v("[data-f=addrtype]") || "home", country: v("[data-f=country]"), street: v("[data-f=street]"), postal_code: v("[data-f=postal]"), city: v("[data-f=city]") };
const hasAddr = addr.country || addr.street || addr.postal_code || addr.city;
return {
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,
birthday,
emails, default_email: emails[0] ? emails[0].value : "",
phones,
urls: url ? [{ value: url, type: "website" }] : [],
addresses: hasAddr ? [addr] : [],
notes: v("[data-f=notes]"),
starred: state.detail ? !!state.detail.starred : false,
group_ids: state.detail ? (state.detail.groups || []).map((g) => g.id) : [],
};
}
async function saveForm() {
const body = collectForm();
if (state.mode === "new") {
const res = await api.pluginFetch("/contacts", { method: "POST", body: JSON.stringify(body) });
state.selectedId = res.id;
} else {
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
}
state.mode = "view";
await loadGroups();
await reloadList(true);
}
// ---- render + wire -------------------------------------------------------
function render() {
// 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; 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(); });
act("left-toggle", () => { state.leftCollapsed = !state.leftCollapsed; try { localStorage.setItem("ct.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ } render(); });
act("theme", () => { const dark = document.documentElement.classList.toggle("dark"); try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ } render(); });
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcontacts"));
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("filter-all", () => setFilter("all"));
act("filter-vip", () => setFilter("vip"));
act("filter-recent", () => setFilter("recent"));
act("filter-nogroup", () => setFilter("nogroup"));
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; 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(); });
// detail actions
act("edit", () => { state.mode = "edit"; state.menu = null; render(); });
act("cancel", () => { state.mode = "view"; render(); });
act("save", () => saveForm());
act("star", async () => { const r = await api.pluginFetch(`/contacts/${state.selectedId}/star`, { method: "POST" }); state.detail.starred = r.starred; render(); });
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", () => {
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
act("menu-groups", (e) => { e.stopPropagation(); state.menu = state.menu === "groups" ? null : "groups"; render(); });
act("menu-more", (e) => { e.stopPropagation(); state.menu = state.menu === "more" ? null : "more"; render(); });
on("[data-stop]", "click", (e) => e.stopPropagation());
on("[data-toggle-group]", "change", async (e) => {
const gid = e.currentTarget.getAttribute("data-toggle-group");
const cur = new Set((state.detail.groups || []).map((g) => g.id));
if (e.currentTarget.checked) cur.add(gid); else cur.delete(gid);
const body = detailToBody(state.detail, [...cur]);
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
await loadGroups(); await loadDetail(state.selectedId); render();
});
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);
});
// edit form multi-rows
act("add-email", () => { const box = el.querySelector("[data-emails]"); const w = document.createElement("div"); w.innerHTML = emailRow(null); const row = w.firstElementChild; row.querySelector("[data-act=rm-email]").addEventListener("click", () => row.remove()); box.appendChild(row); });
act("add-phone", () => { const box = el.querySelector("[data-phones]"); const w = document.createElement("div"); w.innerHTML = phoneRow(null); const row = w.firstElementChild; row.querySelector("[data-act=rm-phone]").addEventListener("click", () => row.remove()); box.appendChild(row); });
on("[data-act=rm-email]", "click", (e) => e.currentTarget.closest("[data-emailrow]").remove());
on("[data-act=rm-phone]", "click", (e) => e.currentTarget.closest("[data-phonerow]").remove());
// close popover menus on outside click
if (state.menu || state.menuOpen) {
setTimeout(() => {
const close = () => { state.menu = null; state.menuOpen = false; document.removeEventListener("click", close); render(); };
document.addEventListener("click", close);
}, 0);
}
}
// Map a loaded detail contact back into a ContactIn body (for group toggles).
function detailToBody(c, groupIds) {
return {
display_name: c.display_name, name_parts: c.name_parts || {}, nickname: c.nickname || "",
title: c.title || "", department: c.department || "", org: c.org || "",
birthday: c.birthday || null, emails: c.emails || [], default_email: c.default_email || "",
phones: c.phones || [], urls: c.urls || [], addresses: c.addresses || [],
notes: c.notes || "", starred: !!c.starred, group_ids: groupIds,
};
}
async function loadChrome() {
try {
const manifest = await api.fetch("/plugins/ui-manifest");
const pages = (manifest.plugins || []).flatMap((p) => p.pages.map((pg) => ({ label: pg.nav.label, to: pg.route === "/contacts" ? null : "/p" + pg.route })));
state.apps = [{ label: "Mail", to: "/mail" }, ...pages];
} catch { /* keep defaults */ }
try { const accounts = await api.fetch("/accounts"); if (accounts && accounts[0]) state.me = accounts[0].email; } catch { /* ignore */ }
try {
const q = await api.fetch("/settings/quota");
if (q && q.quota_bytes) {
const pct = (q.used_bytes / q.quota_bytes) * 100;
const gb = q.quota_bytes / 1024 ** 3;
state.quota = `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}% of ${Number.isInteger(gb) ? gb : gb.toFixed(1)} GB`;
}
} catch { /* ignore */ }
}
(async () => {
await loadChrome();
await loadGroups();
await loadContacts();
await loadDetail(state.selectedId);
render();
})();
return () => { el.innerHTML = ""; };
}