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
@@ -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 (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white"
|
||||
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }}
|
||||
>
|
||||
{initials(name || email)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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<InviteSignal | null>(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 (
|
||||
<aside className="flex h-full min-h-0 flex-col bg-app-surface">
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-4 border-b border-app-border px-4">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onTab(id)}
|
||||
className={`-mb-px border-b-2 py-[15px] text-[15px] ${
|
||||
tab === id ? "border-app-accent font-semibold text-app-fg" : "border-transparent text-app-muted hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button className="ml-auto rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg" title={t("common.close")} onClick={onClose}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Calendar stays mounted (keeps its state) while hidden, so switching tabs is instant. */}
|
||||
<div className={`min-h-0 flex-1 ${tab === "calendar" ? "" : "hidden"}`}>
|
||||
<PanelCalendar invite={invite} />
|
||||
</div>
|
||||
{tab !== "calendar" && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{tab === "people" && <PeopleTab thread={thread} onInviteAll={inviteAll} />}
|
||||
{tab === "attachments" && <AttachmentsTab thread={thread} />}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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<HTMLDivElement>(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 <div ref={ref} className="h-full" />;
|
||||
}
|
||||
|
||||
// ---- footer link (Open X in new tab) --------------------------------------
|
||||
function OpenInNewTab({ label, href }: { label: string; href: string }) {
|
||||
return (
|
||||
<div className="border-t border-app-border p-3 text-center">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-app-muted hover:text-app-fg"
|
||||
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
{label}
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="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 MenuItem({ checked, onClick, children }: { checked?: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-app-hover" onClick={onClick}>
|
||||
<span className="w-3 shrink-0 text-app-accent">{checked ? "✓" : ""}</span>
|
||||
<span className="flex items-center gap-2">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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<PeopleSource>("conversation");
|
||||
const [srcMenu, setSrcMenu] = useState(false);
|
||||
const [moreMenu, setMoreMenu] = useState(false);
|
||||
const [openEmail, setOpenEmail] = useState<string | null>(null);
|
||||
const [openName, setOpenName] = useState("");
|
||||
|
||||
// "In conversation" = unique senders from the open thread.
|
||||
const convPeople = useMemo(() => {
|
||||
const seen = new Map<string, Address>();
|
||||
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<Group[]>("/plugins/contacts/groups"), enabled: source === "autosaved" });
|
||||
const autosavedId = groups.data?.find((g) => g.name === "Autosaved")?.id ?? "";
|
||||
const filterMap: Record<PeopleSource, string> = { 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 <PersonDetail email={openEmail} name={openName} onBack={() => 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 (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 border-b border-app-border px-4 py-2">
|
||||
<div className="relative">
|
||||
<button className="flex items-center gap-1.5 rounded px-1 py-1 text-[15px] font-semibold hover:text-app-fg" onClick={() => setSrcMenu((m) => !m)}>
|
||||
{SOURCES.find(([s]) => s === source)?.[1]}
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M4 6l4 4 4-4" /></svg>
|
||||
</button>
|
||||
{srcMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setSrcMenu(false)} />
|
||||
<div className="absolute left-0 z-20 mt-1 min-w-[170px] rounded-lg border border-app-border bg-app-elevated py-1 text-[13px] shadow-lg">
|
||||
{SOURCES.map(([s, label]) => (
|
||||
<MenuItem key={s} checked={source === s} onClick={() => { setSource(s); setSrcMenu(false); }}>{label}</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative ml-auto">
|
||||
<button className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" onClick={() => setMoreMenu((m) => !m)}>
|
||||
<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>
|
||||
</button>
|
||||
{moreMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMoreMenu(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 min-w-[190px] rounded-lg border border-app-border bg-app-elevated py-1 text-[13px] shadow-lg">
|
||||
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-app-hover disabled:opacity-40" disabled={!allEmails.length} onClick={() => { setMoreMenu(false); navigate(`/mail?compose=1&to=${encodeURIComponent(allEmails.join(", "))}`); }}>
|
||||
{t("panel.composeAll")}
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-app-hover disabled:opacity-40" disabled={!allEmails.length} onClick={() => { setMoreMenu(false); onInviteAll(allEmails); }}>
|
||||
{t("panel.inviteAll")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">{t("panel.senders")}</div>
|
||||
{people.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-app-faint">{t("panel.noPeople")}</div>
|
||||
) : (
|
||||
people.map((p, i) => (
|
||||
<button key={p.primary_email + i} className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-app-hover" onClick={() => { setOpenName(p.display_name); setOpenEmail(p.primary_email); }}>
|
||||
<Avatar name={p.display_name} email={p.primary_email} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{p.display_name}</div>
|
||||
<div className="truncate text-[13px] text-app-muted">{p.primary_email}</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-app-faint"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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<ContactDetail>(`/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 }) => (
|
||||
<button className="flex flex-col items-center gap-1.5 disabled:opacity-40" disabled={disabled} onClick={onClick}>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-app-border text-app-fg">{icon}</span>
|
||||
<span className="text-[12px] text-app-muted">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
<button className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg" onClick={onBack}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><path d="M15 6l-6 6 6 6" /></svg>
|
||||
</button>
|
||||
<span className="ml-auto text-app-faint">{c?.starred ? "★" : "☆"}</span>
|
||||
{contactId && <button className="text-[13px] text-app-accent" onClick={() => window.open("/p/contacts", "_blank", "noopener")}>{t("common.edit")}</button>}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={display} email={primary} size={56} />
|
||||
<div className="text-[20px] font-semibold">{display}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-around">
|
||||
<ActionBtn label={t("panel.call")} disabled={!c?.phones?.length} onClick={() => c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="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>} />
|
||||
<ActionBtn label={t("panel.message")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="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>} />
|
||||
<ActionBtn label={t("panel.compose")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="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>} />
|
||||
<ActionBtn label={t("panel.invite")} onClick={() => window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-[15px] font-semibold">{t("panel.contactDetails")}</div>
|
||||
{contactId && <button className="text-[13px] text-app-accent hover:underline" onClick={() => window.open("/p/contacts", "_blank", "noopener")}>{t("panel.showAll")}</button>}
|
||||
</div>
|
||||
<div className="border-t border-app-border py-2 text-sm">
|
||||
<a href={`mailto:${primary}`} className="text-app-accent hover:underline">{primary}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-1 text-[15px] font-semibold">{t("panel.mailSection")}</div>
|
||||
{mail.isLoading ? (
|
||||
<div className="border-t border-app-border py-3 text-sm text-app-faint">{t("common.loading")}</div>
|
||||
) : (mail.data?.threads ?? []).length === 0 ? (
|
||||
<div className="border-t border-app-border py-3 text-sm text-app-faint">{t("panel.noMatches")}</div>
|
||||
) : (
|
||||
(mail.data?.threads ?? []).slice(0, 5).map((h) => (
|
||||
<button key={h.thread.id} className="flex w-full gap-2 border-t border-app-border py-2 text-left hover:bg-app-hover" onClick={() => navigate(`/mail/t/${h.thread.id}`)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-semibold">{h.thread.subject || t("panel.noSubject")}</div>
|
||||
<div className="truncate text-[12px] text-app-muted">{h.thread.snippet}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button className="mt-3 flex items-center gap-2 rounded bg-app-hover px-3 py-1.5 text-[13px] hover:opacity-90" onClick={() => navigate(`/mail?q=${encodeURIComponent(email)}`)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
{t("panel.findAll", { name: display.split(" ")[0] || display })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-1 text-[15px] font-semibold">{t("panel.eventsSection")}</div>
|
||||
<div className="border-t border-app-border py-3 text-sm text-app-faint">{t("panel.noMatches")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<OpenInNewTab label={t("panel.openContact")} href="/p/contacts" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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 <div className="px-4 py-6 text-sm text-app-faint">{t("panel.openConversation")}</div>;
|
||||
if (q.isLoading) return <div className="px-4 py-6 text-sm text-app-faint">{t("common.loading")}</div>;
|
||||
const groups = q.data ?? [];
|
||||
if (groups.length === 0) return <div className="px-4 py-6 text-sm text-app-faint">{t("panel.noAttachments")}</div>;
|
||||
|
||||
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 (
|
||||
<div className="px-3 py-2">
|
||||
{groups.map(({ msg, atts }) => {
|
||||
const previewable = atts.filter((a) => isPreviewable(a.filename));
|
||||
return (
|
||||
<div key={msg.id} className="mb-3">
|
||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||
<span className="truncate text-[14px] font-semibold text-app-accent">{senderName(msg)}</span>
|
||||
<span className="shrink-0 text-[12px] text-app-muted">{shortDate(msg.date)}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{atts.map((a) => {
|
||||
const canPreview = isPreviewable(a.filename);
|
||||
return (
|
||||
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-app-border px-3 py-2">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" className="shrink-0 text-app-muted"><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" /></svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
{canPreview ? (
|
||||
<button
|
||||
className="block max-w-full truncate text-left text-[13px] font-medium text-app-accent hover:underline"
|
||||
title={a.filename}
|
||||
onClick={() => setPreview({ messageId: msg.id, list: previewable, index: previewable.findIndex((p) => p.id === a.id) })}
|
||||
>
|
||||
{a.filename}
|
||||
</button>
|
||||
) : (
|
||||
<div className="truncate text-[13px] font-medium" title={a.filename}>{a.filename}</div>
|
||||
)}
|
||||
<div className="text-[12px] text-app-muted">{fmtSize(a.size_bytes)}</div>
|
||||
</div>
|
||||
<a href={api.attachmentDownloadUrl(msg.id, a.id)} className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" title={t("panel.download")}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{preview && (
|
||||
<Suspense fallback={null}>
|
||||
<AttachmentPreview
|
||||
messageId={preview.messageId}
|
||||
attachments={preview.list}
|
||||
index={preview.index}
|
||||
onIndex={(i) => setPreview((p) => (p ? { ...p, index: i } : p))}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<PanelTab>(() => (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() {
|
||||
<span className="ml-auto text-[13px] text-app-faint">
|
||||
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
|
||||
</span>
|
||||
<button
|
||||
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
|
||||
title={t("panel.toggle")}
|
||||
aria-label={t("panel.toggle")}
|
||||
aria-pressed={rightOpen}
|
||||
onClick={() => (rightOpen ? setRightOpen(false) : openPanel(rightTab))}
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M15 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ThreadView
|
||||
@@ -944,6 +966,19 @@ export function MailLayout() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
|
||||
{!rightOpen && (
|
||||
<button
|
||||
className="absolute right-3 top-3 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
title={t("panel.toggle")}
|
||||
aria-label={t("panel.toggle")}
|
||||
onClick={() => openPanel(rightTab)}
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M15 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<EmptyIllustration className="w-56 max-w-[55%]" />
|
||||
<span className="text-3xl">{t("mail.selectConversation")}</span>
|
||||
</div>
|
||||
@@ -967,6 +1002,14 @@ export function MailLayout() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{rightOpen && (
|
||||
<>
|
||||
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
|
||||
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
|
||||
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setRightOpen(false)} thread={detailQuery.data} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
})();
|
||||
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 90 KiB |