M5 Contacts plugin (builtin) + collapsed icon-rail for Calendar
Contacts (SPEC Appendix B, phase B1) — new builtin plugin `webmail_plugin_contacts`:
- Tables plg_contacts_{books,contacts,groups,group_members} (RLS-isolated,
migration contacts0001): vCard-style structured contact fields, address books,
groups with membership.
- API /api/plugins/contacts/: list (filter all|vip|recent|nogroup, group, q),
get/create/update/delete, star toggle, groups CRUD, and the recipients.suggest
capability endpoint (enriches the composer). Display name derived server-side
from name parts so search + suggestions work regardless of client.
- Frontend ui/page.js: three-pane app — shared .wm-side-* sidebar template with a
"Contacts" header, alphabetically grouped list, detail pane (avatar, VIP star,
Call/Message/Compose/Invite, Contact details, live Mail search, Events stub) and
a full edit form (name, org, birthday, repeatable emails/phones, address, notes).
Collapsed sidebar: both Contacts and Calendar now show a 60px icon-only rail (like
Mail) with expand toggle, primary button, quick nav and Settings/Sign out — instead
of hiding the panel (Contacts) / a top-bar button (Calendar).
Deferred: Gravatar photos, vCard import/export + merge (B2), CardDAV sync (B3),
"Add sender to contacts".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -0,0 +1,591 @@
|
||||
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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_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 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:6px;height:49px;padding:0 14px;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}
|
||||
.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-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-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-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,
|
||||
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;
|
||||
|
||||
// ---- 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;
|
||||
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>`; }
|
||||
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-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>`;
|
||||
return `
|
||||
<div class="ct-bar">
|
||||
<input type="checkbox" class="ct-cbx" disabled title="Select all"/>
|
||||
<span class="sp"></span>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
// ---- 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>`;
|
||||
}
|
||||
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();
|
||||
const c = state.detail;
|
||||
if (!c) return `<div class="ct-detail"><div class="ct-empty">Select a contact.</div></div>`;
|
||||
const groups = (c.groups || []).map((g) => esc(g.name)).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("");
|
||||
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>
|
||||
<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>
|
||||
${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>
|
||||
</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 display = [prefix, given, family].filter(Boolean).join(" ") || (emails[0] ? emails[0].value : "");
|
||||
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: v("[data-f=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() {
|
||||
el.innerHTML = `<div class="ct-app">
|
||||
${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>
|
||||
</div>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
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); };
|
||||
|
||||
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; reloadList(false);
|
||||
});
|
||||
|
||||
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
|
||||
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", () => ctx.navigate("/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", async () => {
|
||||
state.menu = null;
|
||||
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 = ""; };
|
||||
}
|
||||
Reference in New Issue
Block a user