Mail right panel: Calendar / People / Attachments (shared calendar forms)

New slide-out right panel in Mail (hidden by default, toggle in the reading pane,
resizable, persisted). Three tabs:
- Calendar: mounts the calendar plugin bundle in a new embed:"panel" mode, so the
  agenda, mini-month, event popover, event form and search results are the calendar
  app's OWN code — future changes propagate to both. Events click → real popover;
  New event / Edit / "Invite all" open the shared event form in the panel; search
  shows the calendar's results panel; All-calendars filter + open-in-new-tab.
- People: conversation senders / contact books (All/Recent/VIPs/Autosaved) drilling
  into a contact detail (Call/Message/Compose/Invite, related mail, find-all);
  "Compose to all" / "Invite all to new event".
- Attachments: every attachment in the thread, grouped by sender, with download and
  clickable previews (reuses the mail AttachmentPreview overlay for supported types).

Calendar bundle: mount(el, ctx, props) reads embed/newEventGuests; renderEmbed() +
panelBar/panelFoot; embed-aware agenda (TODAY); form/popover repositioned for the
narrow panel; guest deep-link accepts comma-separated emails.

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-03 08:38:44 +02:00
co-authored by Claude Opus 4.8
parent 421b44a8ab
commit 1d1a621ce2
13 changed files with 622 additions and 4 deletions
@@ -238,6 +238,20 @@ const CSS = `
.cal-srch-none{color:rgb(var(--app-faint));padding:6px 0}
.cal-srch-today{color:#3b82f6;font-size:11px;font-weight:600;letter-spacing:.04em;text-align:right;padding:0 14px}
.cal-mainhead{display:flex;align-items:center;gap:14px;padding:10px 16px;flex:0 0 auto}
/* embed (Mail right panel) compact layout */
.cal-embed{flex-direction:column;height:100%}
.cal-panelbar{display:flex;align-items:center;gap:2px;padding:6px 10px 2px;flex:0 0 auto}
.cal-panelsearch{padding:4px 10px}
.cal-embed-body{flex:1;min-height:0;overflow:auto}
.cal-embed-mini{padding:6px 14px 0}
.cal-agenda-today{background:none;border:0;color:rgb(var(--app-accent));font-weight:600;font-size:13px;cursor:pointer}
.cal-panelfoot{flex:0 0 auto;border-top:1px solid rgb(var(--app-border));padding:10px;text-align:center}
.cal-panelfoot button{display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;font:inherit;font-size:13px}
.cal-panelfoot button:hover{color:rgb(var(--app-fg))}
.cal-panelcal{position:relative}
.cal-embed .cal-searchbox{margin:0}
.cal-embed .cal-form{right:8px;width:auto;top:52px;max-height:calc(100% - 64px)}
.cal-embed .cal-pop{position:fixed}
.cal-view{flex:1;min-height:0;overflow:auto;position:relative}
.cal-btn{border:0;border-radius:6px;padding:6px 10px;cursor:pointer;font:inherit;background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.cal-btn-primary{background:#3b82f6;color:#fff;flex:1}
@@ -370,7 +384,7 @@ const CSS = `
.cal-modal .foot{border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;justify-content:flex-end;gap:8px}
`;
export default function mount(el, ctx) {
export default function mount(el, ctx, props) {
if (!document.getElementById("cal-styles")) {
const style = document.createElement("style");
style.id = "cal-styles";
@@ -378,7 +392,14 @@ export default function mount(el, ctx) {
document.head.appendChild(style);
}
// Embed mode: the SAME app runs compactly inside Mail's right panel — reusing the
// agenda, mini-month, event popover, event form and search results verbatim, so
// any change here propagates to both places.
const embed = !!(props && props.embed === "panel");
const newEventGuests = (props && props.newEventGuests) || null;
const state = {
embed,
view: "month",
anchor: startOfDay(new Date()),
mini: startOfMonth(new Date()),
@@ -394,6 +415,8 @@ export default function mount(el, ctx) {
eventPopover: null, // null | { ev, rect }
confirm: null, // null | { ev } — delete confirmation modal
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
searchOpen: false, // embed: search box revealed
panelCalOpen: false, // embed: "All calendars" dropdown open
me: "",
quota: "",
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
@@ -406,6 +429,10 @@ export default function mount(el, ctx) {
const api = ctx.api;
function rangeFor() {
if (state.embed) {
const gs = startOfMonth(state.anchor);
return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda
}
if (state.view === "month") {
const gs = startOfWeek(startOfMonth(state.anchor));
return [gs, addDays(gs, 42)];
@@ -823,6 +850,7 @@ export default function mount(el, ctx) {
// ---- render ----------------------------------------------------------------
function render() {
if (state.embed) { renderEmbed(); return; }
el.innerHTML = `<div class="cal-app">
${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">
@@ -845,6 +873,47 @@ export default function mount(el, ctx) {
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
}
// Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
function renderEmbed() {
el.innerHTML = `<div class="cal-app cal-embed">
${panelBar()}
<div class="cal-embed-body">
${state.search ? searchPanelHtml() : `<div class="cal-embed-mini">${miniMonth()}</div>${agenda()}`}
</div>
${panelFoot()}
${state.composing ? formHtml() : ""}
${state.eventPopover ? eventPopoverHtml() : ""}
${state.confirm ? confirmHtml() : ""}
</div>`;
wire();
if (state.eventPopover) positionPopover();
if (state.search && !state.eventPopover && !state.composing && !state.confirm) focusSearch();
}
function panelBar() {
const cur = state.hidden.size && state.calendars.length - state.hidden.size === 1
? state.calendars.find((c) => !state.hidden.has(c.id))
: null;
return `<div class="cal-panelbar">
<button class="cal-iconbtn" data-act="new" title="New event"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button class="cal-iconbtn${state.searchOpen ? " on" : ""}" data-act="panel-search" title="Search"><svg width="17" height="17" 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></button>
<div style="flex:1"></div>
<div class="cal-dd cal-panelcal">
<button class="cal-iconbtn" data-act="panelcal" style="width:auto;gap:6px;padding:6px 8px;font-size:13px"><svg width="15" height="15" 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>${cur ? esc(cur.name) : "All calendars"} <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 6l4 4 4-4"/></svg></button>
${state.panelCalOpen ? `<div class="cal-ddpop" style="right:0;left:auto">
<button data-panelcal="all">${!cur ? "✓ " : ""}All calendars</button>
${state.calendars.map((c) => `<button data-panelcal="${c.id}">${cur && cur.id === c.id ? "✓ " : ""}<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:${esc(c.color)};margin-right:4px"></span>${esc(c.name)}</button>`).join("")}
</div>` : ""}
</div>
</div>
${state.searchOpen || state.search ? `<div class="cal-panelsearch"><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 : "")}"/>${state.search ? `<button class="cal-iconbtn" data-act="search-clear" title="Clear">✕</button>` : ""}</div></div>` : ""}`;
}
function panelFoot() {
return `<div class="cal-panelfoot"><button data-act="open-newtab">Open calendar in new tab <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg></button></div>`;
}
function focusSearch() {
const si = el.querySelector(".cal-search");
if (si && document.activeElement !== si) {
@@ -982,7 +1051,7 @@ export default function mount(el, ctx) {
const chev = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg>`;
const colorItems = COLORS.map((cc) => `<button type="button" data-color="${cc[1]}"><span class="cal-dot" style="background:${cc[1]}"></span>${cc[0]}</button>`).join("");
const tz0 = c.tz || browserTz;
const formLeft = state.leftCollapsed ? 12 : state.leftW + 12; // clear the full-height sidebar
const formLeft = state.embed ? 8 : (state.leftCollapsed ? 12 : state.leftW + 12); // clear the full-height sidebar
return `<div class="cal-form" style="left:${formLeft}px">
<div class="cal-form-body">
<h4 style="margin-top:0">What</h4><input type="text" data-f="summary"/>
@@ -1191,7 +1260,10 @@ export default function mount(el, ctx) {
function agenda() {
const start = startOfMonth(state.anchor);
const evs = visibleEvents();
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div><button class="cal-iconbtn" data-act="panel">✕</button></div><div class="cal-agenda">`;
const rightAction = state.embed
? `<button class="cal-agenda-today" data-act="mini-today">TODAY</button>`
: `<button class="cal-iconbtn" data-act="panel">✕</button>`;
let html = `<div class="cal-mainhead"><span class="cal-title">${MONTHS[start.getMonth()]} <span>${start.getFullYear()}</span></span><div style="flex:1"></div>${rightAction}</div><div class="cal-agenda">`;
for (let i = 0; i < 30; i++) {
const d = addDays(start, i);
if (d.getMonth() !== start.getMonth()) break;
@@ -1209,6 +1281,20 @@ export default function mount(el, ctx) {
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
// --- embed (Mail right panel) toolbar ---
act("panel-search", () => { state.searchOpen = !state.searchOpen; if (!state.searchOpen && state.search) closeSearch(); else render(); });
act("panelcal", (e) => { e.stopPropagation(); state.panelCalOpen = !state.panelCalOpen; render(); });
on("[data-panelcal]", "click", (e) => {
e.stopPropagation();
const id = e.currentTarget.getAttribute("data-panelcal");
if (id === "all") state.hidden.clear();
else state.hidden = new Set(state.calendars.map((c) => c.id).filter((x) => x !== id));
state.panelCalOpen = false; render();
});
act("open-newtab", () => window.open("/p/calendar", "_blank", "noopener"));
if (state.panelCalOpen) {
setTimeout(() => { const close = () => { state.panelCalOpen = false; document.removeEventListener("click", close); render(); }; document.addEventListener("click", close); }, 0);
}
// --- search ---
on(".cal-search", "input", (e) => {
const q = e.currentTarget.value;
@@ -1452,6 +1538,14 @@ export default function mount(el, ctx) {
await loadCalendars();
await loadEvents();
render();
// Embed mode ("Invite all to new event" from Mail's People tab): open a new-event
// form with the given guests, using the SAME form as the calendar app.
if (state.embed && newEventGuests && newEventGuests.length) {
openForm(null);
const addChip = expandGuests();
if (addChip) newEventGuests.map((g) => String(g).trim()).filter(Boolean).forEach((g) => addChip(g, "", false));
return;
}
// Deep link (Contacts "Invite"): /p/calendar?guest=<email> opens a new-event
// form with that guest already added, then strips the param so refresh is clean.
try {
@@ -1460,7 +1554,7 @@ export default function mount(el, ctx) {
window.history.replaceState({}, "", window.location.pathname);
openForm(null);
const addChip = expandGuests();
if (addChip) addChip(guest, "", false);
if (addChip) guest.split(",").map((g) => g.trim()).filter(Boolean).forEach((g) => addChip(g, "", false));
}
} catch { /* ignore */ }
})();