diff --git a/frontend/src/components/mail/RightPanel.tsx b/frontend/src/components/mail/RightPanel.tsx new file mode 100644 index 0000000..55b2a48 --- /dev/null +++ b/frontend/src/components/mail/RightPanel.tsx @@ -0,0 +1,436 @@ +// Mail's slide-out right panel (hidden by default, toggled like Calendar's right +// panel). Three tabs: Calendar (mini-month + agenda + search, from the calendar +// plugin), People (conversation senders / contact books, drilling into a contact +// detail), and Attachments (every attachment in the open thread). Data comes from +// the plugin APIs + the current thread; actions reuse the existing deep links +// (/mail?compose=, /mail?q=, /p/calendar?guest=). +import { useQuery } from "@tanstack/react-query"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useT } from "../../i18n"; +import { api, apiFetch } from "../../lib/api"; +import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types"; +import { loadBundle } from "../../plugins/bundleLoader"; +import { usePluginContext } from "../../plugins/context"; +import { isPreviewable } from "../thread/previewKind"; + +interface InviteSignal { guests: string[]; n: number } + +// Lazy: the preview bundles pdf.js — loaded only when a preview is opened. +const AttachmentPreview = lazy(() => + import("../thread/AttachmentPreview").then((m) => ({ default: m.AttachmentPreview })), +); + +export type PanelTab = "calendar" | "people" | "attachments"; + +// ---- small helpers --------------------------------------------------------- +const AV_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280"]; +function avatarColor(key: string) { + let h = 0; + for (const c of key || "?") h = (h * 31 + c.charCodeAt(0)) >>> 0; + return AV_COLORS[h % AV_COLORS.length]; +} +function initials(name: string) { + const p = (name || "").trim().split(/\s+/).filter(Boolean); + if (!p.length) return "?"; + return ((p[0][0] || "") + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase(); +} +function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) { + return ( + + {initials(name || email)} + + ); +} +function fmtSize(b: number) { + if (b < 1024) return `${b} B`; + if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`; + return `${(b / 1024 / 1024).toFixed(1)} MB`; +} +interface ContactListItem { id: string; display_name: string; primary_email: string; org: string; starred: boolean } +interface ContactDetail { + id: string; display_name: string; org: string; title: string; department: string; + emails: { value: string; type: string }[]; default_email: string; + phones: { value: string; type: string }[]; notes: string; starred: boolean; + groups: { id: string; name: string }[]; +} +interface Group { id: string; name: string; count: number } + +// =========================================================================== +export function RightPanel({ + tab, + onTab, + onClose, + thread, +}: { + tab: PanelTab; + onTab: (t: PanelTab) => void; + onClose: () => void; + thread: ThreadDetail | undefined; +}) { + const t = useT(); + // "Invite all to new event" (People tab) opens the SHARED calendar event form in + // the Calendar tab, prefilled with the selected people. + const [invite, setInvite] = useState(null); + const inviteAll = (guests: string[]) => { onTab("calendar"); setInvite({ guests, n: Date.now() }); }; + const TABS: [PanelTab, string][] = [ + ["calendar", t("panel.calendar")], + ["people", t("panel.people")], + ["attachments", t("panel.attachments")], + ]; + return ( + + ); +} + +// ---- Calendar tab = the calendar plugin bundle, mounted in "panel" embed mode. +// This is the shared surface: the agenda, mini-month, event popover, event form +// and search results are the calendar app's own code, so future changes propagate. +const CAL_BUNDLE = "/api/plugins/calendar/ui/page.js"; +function PanelCalendar({ invite }: { invite: InviteSignal | null }) { + const ref = useRef(null); + const ctx = usePluginContext("calendar"); + // Remount when a new "Invite all" fires so the form opens with those guests. + const inviteN = invite?.n ?? 0; + useEffect(() => { + let alive = true; + let cleanup: (() => void) | void; + loadBundle(CAL_BUNDLE).then((mount) => { + if (!alive || !ref.current) return; + ref.current.innerHTML = ""; + cleanup = mount(ref.current, ctx, { embed: "panel", newEventGuests: invite?.guests }); + }); + return () => { alive = false; if (typeof cleanup === "function") cleanup(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ctx, inviteN]); + return
; +} + +// ---- footer link (Open X in new tab) -------------------------------------- +function OpenInNewTab({ label, href }: { label: string; href: string }) { + return ( +
+ +
+ ); +} + + +function MenuItem({ checked, onClick, children }: { checked?: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +// =========================================================================== +// People tab +// =========================================================================== +type PeopleSource = "conversation" | "all" | "recent" | "vip" | "autosaved"; +function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined; onInviteAll: (emails: string[]) => void }) { + const t = useT(); + const navigate = useNavigate(); + const [source, setSource] = useState("conversation"); + const [srcMenu, setSrcMenu] = useState(false); + const [moreMenu, setMoreMenu] = useState(false); + const [openEmail, setOpenEmail] = useState(null); + const [openName, setOpenName] = useState(""); + + // "In conversation" = unique senders from the open thread. + const convPeople = useMemo(() => { + const seen = new Map(); + for (const m of thread?.messages ?? []) { + const a = m.from_addr[0]; + if (a?.email && !seen.has(a.email.toLowerCase())) seen.set(a.email.toLowerCase(), a); + } + return [...seen.values()].map((a) => ({ id: "", display_name: a.name || a.email, primary_email: a.email })); + }, [thread]); + + const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch("/plugins/contacts/groups"), enabled: source === "autosaved" }); + const autosavedId = groups.data?.find((g) => g.name === "Autosaved")?.id ?? ""; + const filterMap: Record = { conversation: "", all: "all", recent: "recent", vip: "vip", autosaved: "all" }; + const contactList = useQuery({ + queryKey: ["panel-contacts", source, autosavedId], + enabled: source !== "conversation" && (source !== "autosaved" || !!autosavedId), + queryFn: () => + apiFetch<{ contacts: ContactListItem[] }>( + `/plugins/contacts/contacts?filter=${filterMap[source]}&group=${source === "autosaved" ? autosavedId : ""}&q=`, + ), + }); + + const people = source === "conversation" ? convPeople : (contactList.data?.contacts ?? []); + const allEmails = people.map((p) => p.primary_email).filter(Boolean); + + if (openEmail) { + return setOpenEmail(null)} />; + } + + const SOURCES: [PeopleSource, string][] = [ + ["conversation", t("panel.inConversation")], + ["all", t("panel.allContacts")], + ["recent", t("panel.recent")], + ["vip", t("panel.vips")], + ["autosaved", t("panel.autosaved")], + ]; + + return ( +
+
+
+ + {srcMenu && ( + <> +
setSrcMenu(false)} /> +
+ {SOURCES.map(([s, label]) => ( + { setSource(s); setSrcMenu(false); }}>{label} + ))} +
+ + )} +
+
+ + {moreMenu && ( + <> +
setMoreMenu(false)} /> +
+ + +
+ + )} +
+
+ +
{t("panel.senders")}
+ {people.length === 0 ? ( +
{t("panel.noPeople")}
+ ) : ( + people.map((p, i) => ( + + )) + )} +
+ ); +} + +// ---- People: drill-in contact detail -------------------------------------- +function PersonDetail({ email, name, onBack }: { email: string; name: string; onBack: () => void }) { + const t = useT(); + const navigate = useNavigate(); + // Resolve to a saved contact by email (best match), else show a synthetic card. + const suggest = useQuery({ queryKey: ["contact-suggest", email], queryFn: () => apiFetch<{ email: string; contact_id: string }[]>(`/plugins/contacts/suggest?q=${encodeURIComponent(email)}`) }); + const contactId = suggest.data?.find((s) => s.email.toLowerCase() === email.toLowerCase())?.contact_id; + const detail = useQuery({ queryKey: ["contact", contactId], enabled: !!contactId, queryFn: () => apiFetch(`/plugins/contacts/contacts/${contactId}`) }); + const mail = useQuery({ queryKey: ["panel-person-mail", email], queryFn: () => api.search(`"${email}"`) }); + + const c = detail.data; + const display = c?.display_name || name || email; + const primary = c?.default_email || email; + + const ActionBtn = ({ icon, label, onClick, disabled }: { icon: React.ReactNode; label: string; onClick: () => void; disabled?: boolean }) => ( + + ); + + return ( +
+
+ + {c?.starred ? "★" : "☆"} + {contactId && } +
+
+
+ +
{display}
+
+
+ c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={} /> + navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={} /> + navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={} /> + window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={} /> +
+ +
+
+
{t("panel.contactDetails")}
+ {contactId && } +
+
+ {primary} +
+
+ +
+
{t("panel.mailSection")}
+ {mail.isLoading ? ( +
{t("common.loading")}
+ ) : (mail.data?.threads ?? []).length === 0 ? ( +
{t("panel.noMatches")}
+ ) : ( + (mail.data?.threads ?? []).slice(0, 5).map((h) => ( + + )) + )} + +
+ +
+
{t("panel.eventsSection")}
+
{t("panel.noMatches")}
+
+
+ +
+ ); +} + +// =========================================================================== +// Attachments tab +// =========================================================================== +function AttachmentsTab({ thread }: { thread: ThreadDetail | undefined }) { + const t = useT(); + const withAtt = (thread?.messages ?? []).filter((m) => m.has_attachments); + const q = useQuery({ + queryKey: ["panel-attachments", thread?.id, withAtt.map((m) => m.id).join(",")], + enabled: !!thread && withAtt.length > 0, + queryFn: async () => { + const groups = await Promise.all(withAtt.map(async (m) => ({ msg: m, atts: await api.messageAttachments(m.id) }))); + return groups.filter((g) => g.atts.length > 0); + }, + }); + + // Full-screen preview overlay, per message (previewable subset of that message). + const [preview, setPreview] = useState<{ messageId: string; list: AttachmentOut[]; index: number } | null>(null); + + if (!thread) return
{t("panel.openConversation")}
; + if (q.isLoading) return
{t("common.loading")}
; + const groups = q.data ?? []; + if (groups.length === 0) return
{t("panel.noAttachments")}
; + + const senderName = (m: MessageEnvelope) => m.from_addr[0]?.name || m.from_addr[0]?.email || ""; + const shortDate = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { month: "short", day: "numeric" }); + + return ( +
+ {groups.map(({ msg, atts }) => { + const previewable = atts.filter((a) => isPreviewable(a.filename)); + return ( +
+
+ {senderName(msg)} + {shortDate(msg.date)} +
+
+ {atts.map((a) => { + const canPreview = isPreviewable(a.filename); + return ( +
+ +
+ {canPreview ? ( + + ) : ( +
{a.filename}
+ )} +
{fmtSize(a.size_bytes)}
+
+ + + +
+ ); + })} +
+
+ ); + })} + {preview && ( + + setPreview((p) => (p ? { ...p, index: i } : p))} + onClose={() => setPreview(null)} + /> + + )} +
+ ); +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index fd6603a..6c832f7 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -99,6 +99,51 @@ export const en = { "mail.emptySpam": "Empty spam", "mail.more": "More", "mail.markAllRead": "Mark all as read", + + "panel.toggle": "Toggle side panel", + "panel.calendar": "Calendar", + "panel.people": "People", + "panel.attachments": "Attachments", + "panel.search": "Search", + "panel.newEvent": "New event", + "panel.miniCalendar": "Mini calendar", + "panel.week": "Week", + "panel.month": "Month", + "panel.view": "View", + "panel.day": "Day", + "panel.3days": "3 days", + "panel.agenda": "Agenda", + "panel.allCalendars": "All calendars", + "panel.searchEvents": "Search events", + "panel.today": "Today", + "panel.noEvents": "No events", + "panel.untitled": "(untitled)", + "panel.allDay": "All day", + "panel.openCalendar": "Open calendar in new tab", + "panel.inConversation": "In conversation", + "panel.allContacts": "All contacts", + "panel.recent": "Recent", + "panel.vips": "VIPs", + "panel.autosaved": "Autosaved", + "panel.senders": "Senders", + "panel.noPeople": "No people", + "panel.composeAll": "Compose to all", + "panel.inviteAll": "Invite all to new event", + "panel.call": "Call", + "panel.message": "Message", + "panel.compose": "Compose", + "panel.invite": "Invite", + "panel.contactDetails": "Contact details", + "panel.showAll": "Show all", + "panel.mailSection": "Mail", + "panel.noSubject": "(no subject)", + "panel.findAll": "Find all conversations with {name}", + "panel.eventsSection": "Events", + "panel.noMatches": "No matches found", + "panel.openContact": "Open contact in new tab", + "panel.openConversation": "Open a conversation to see its attachments.", + "panel.noAttachments": "No attachments", + "panel.download": "Download", "mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.", "mail.loadFailed": "Couldn't load this message.", "mail.imagesBlocked": "Images from this sender are blocked.", diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 6845e26..dd1a17f 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -12,6 +12,7 @@ import { ThemeToggle } from "../components/ThemeToggle"; import { EmptyIllustration } from "../components/EmptyIllustration"; import { BackfillBanner } from "../components/BackfillBanner"; import { ThreadView } from "../components/thread/ThreadView"; +import { RightPanel, type PanelTab } from "../components/mail/RightPanel"; import { useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; import { api } from "../lib/api"; @@ -93,12 +94,21 @@ export function MailLayout() { const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384); // Collapsed sidebar shows icons only (persisted). const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1"); + // Right info panel (Calendar / People / Attachments) — hidden by default. + const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1"); + const [rightTab, setRightTab] = useState(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar"); + const [rightW, setRightW] = useState(() => Number(localStorage.getItem("panel.rightW")) || 340); const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420); // The list can be dragged up to 75% of the window width. const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75); + const clampRight = (w: number) => Math.min(Math.max(w, 280), 560); useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]); useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]); useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]); + useEffect(() => localStorage.setItem("panel.right", rightOpen ? "1" : "0"), [rightOpen]); + useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]); + useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]); + const openPanel = (tab: PanelTab) => { setRightTab(tab); setRightOpen(true); }; useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]); const isDrafts = view === "folder:drafts"; @@ -930,6 +940,18 @@ export function MailLayout() { {t("mail.messageCount", { count: detailQuery.data.messages.length })} +
) : (
+ {!rightOpen && ( + + )} {t("mail.selectConversation")}
@@ -967,6 +1002,14 @@ export function MailLayout() {
)} + {rightOpen && ( + <> + setRightW((w) => clampRight(w - dx))} /> +
+ setRightOpen(false)} thread={detailQuery.data} /> +
+ + )}
); } diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 089b131..84e04cb 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -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 = `
${state.leftCollapsed ? railPanel() : `
${leftPanel()}
`}
@@ -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 = `
+ ${panelBar()} +
+ ${state.search ? searchPanelHtml() : `
${miniMonth()}
${agenda()}`} +
+ ${panelFoot()} + ${state.composing ? formHtml() : ""} + ${state.eventPopover ? eventPopoverHtml() : ""} + ${state.confirm ? confirmHtml() : ""} +
`; + 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 `
+ + +
+
+ + ${state.panelCalOpen ? `
+ + ${state.calendars.map((c) => ``).join("")} +
` : ""} +
+
+ ${state.searchOpen || state.search ? `
` : ""}`; + } + + function panelFoot() { + return `
`; + } + 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 = ``; const colorItems = COLORS.map((cc) => ``).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 `

What

@@ -1191,7 +1260,10 @@ export default function mount(el, ctx) { function agenda() { const start = startOfMonth(state.anchor); const evs = visibleEvents(); - let html = `
${MONTHS[start.getMonth()]} ${start.getFullYear()}
`; + const rightAction = state.embed + ? `` + : ``; + let html = `
${MONTHS[start.getMonth()]} ${start.getFullYear()}
${rightAction}
`; 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= 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 */ } })(); diff --git a/screenshots/right-panel-1.png b/screenshots/right-panel-1.png new file mode 100644 index 0000000..8f3de1c Binary files /dev/null and b/screenshots/right-panel-1.png differ diff --git a/screenshots/right-panel-2.png b/screenshots/right-panel-2.png new file mode 100644 index 0000000..115441a Binary files /dev/null and b/screenshots/right-panel-2.png differ diff --git a/screenshots/right-panel-3.png b/screenshots/right-panel-3.png new file mode 100644 index 0000000..75860f9 Binary files /dev/null and b/screenshots/right-panel-3.png differ diff --git a/screenshots/right-panel-4.png b/screenshots/right-panel-4.png new file mode 100644 index 0000000..70428f1 Binary files /dev/null and b/screenshots/right-panel-4.png differ diff --git a/screenshots/right-panel-5.png b/screenshots/right-panel-5.png new file mode 100644 index 0000000..bfe98f5 Binary files /dev/null and b/screenshots/right-panel-5.png differ diff --git a/screenshots/right-panel-6.png b/screenshots/right-panel-6.png new file mode 100644 index 0000000..6b2c0fb Binary files /dev/null and b/screenshots/right-panel-6.png differ diff --git a/screenshots/right-panel-7.png b/screenshots/right-panel-7.png new file mode 100644 index 0000000..7886aa2 Binary files /dev/null and b/screenshots/right-panel-7.png differ diff --git a/screenshots/right-panel-8.png b/screenshots/right-panel-8.png new file mode 100644 index 0000000..16ef2b3 Binary files /dev/null and b/screenshots/right-panel-8.png differ diff --git a/screenshots/right-panel-9.png b/screenshots/right-panel-9.png new file mode 100644 index 0000000..5b529c7 Binary files /dev/null and b/screenshots/right-panel-9.png differ