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
+436
View File
@@ -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>
);
}