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:
2026-08-02 10:12:45 +02:00
co-authored by Claude Opus 4.8
parent 57a1f1562f
commit a985b112b1
8 changed files with 1304 additions and 2 deletions
@@ -201,6 +201,16 @@ const CSS = `
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
.cal-secrow:hover .cal-secadd{opacity:1}
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
/* collapsed icon-only rail (matches Mail's 60px rail) */
.cal-rail{width:60px;align-items:center}
.cal-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
.cal-railprimary{padding:2px 0 8px}
.cal-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
.cal-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
.cal-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}
.cal-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-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}
.cal-railnew:hover{opacity:.9}
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.cal-resizer:hover::after{background:#3b82f6}
@@ -813,7 +823,7 @@ export default function mount(el, ctx) {
// ---- render ----------------------------------------------------------------
function render() {
el.innerHTML = `<div class="cal-app">
${state.leftCollapsed ? "" : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
${state.leftCollapsed ? railPanel() : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
<div class="cal-maincol">
${topbar()}
<div class="cal-mainarea">
@@ -858,7 +868,6 @@ export default function mount(el, ctx) {
function topbar() {
return `<div class="cal-topbar">
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
<div class="cal-searchbox">
<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>
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
@@ -867,6 +876,26 @@ export default function mount(el, ctx) {
</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 TODAY_IC = `<svg width="18" height="18" 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"/><circle cx="12" cy="15.5" r="2.3" fill="currentColor" stroke="none"/></svg>`;
return `<div class="wm-side cal-rail" style="flex:0 0 auto">
<div class="cal-railtop">
<button class="cal-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
<button class="cal-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
</div>
<div class="cal-railprimary"><button class="cal-railnew" data-act="new" title="New event">${COMPOSE_ICON}</button></div>
<div class="cal-railnav">
<button class="cal-railbtn" data-act="today" title="Today">${TODAY_IC}</button>
</div>
<div class="cal-railfoot">
<button class="cal-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
<button class="cal-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
</div>
</div>`;
}
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
// switcher + collapse/theme, primary button, an app-specific scrollable body
// (mini-month + calendars here), and the shared foot. Identical to Mail.