166 lines
6.6 KiB
TypeScript
166 lines
6.6 KiB
TypeScript
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||
import Button from "../../../components/Button";
|
||
|
||
export type MessagePreviewAttachment = {
|
||
filename?: string | null;
|
||
label?: string | null;
|
||
detail?: string | null;
|
||
contentType?: string | null;
|
||
sizeBytes?: number | null;
|
||
archiveGroup?: string | null;
|
||
archiveLabel?: string | null;
|
||
};
|
||
|
||
export type MessagePreviewMetaItem = {
|
||
label: string;
|
||
value: React.ReactNode;
|
||
};
|
||
|
||
export type MessagePreviewNavigation = {
|
||
index: number;
|
||
total: number;
|
||
onFirst: () => void;
|
||
onPrevious: () => void;
|
||
onNext: () => void;
|
||
onLast: () => void;
|
||
};
|
||
|
||
export type MessagePreviewOverlayProps = {
|
||
title?: string;
|
||
subject?: string | null;
|
||
bodyMode?: "text" | "html";
|
||
text?: string | null;
|
||
html?: string | null;
|
||
recipientLabel?: React.ReactNode;
|
||
recipientNote?: React.ReactNode;
|
||
metaItems?: MessagePreviewMetaItem[];
|
||
attachments?: MessagePreviewAttachment[];
|
||
raw?: string | null;
|
||
rawLabel?: string;
|
||
navigation?: MessagePreviewNavigation;
|
||
closeLabel?: string;
|
||
onClose: () => void;
|
||
};
|
||
|
||
export default function MessagePreviewOverlay({
|
||
title = "Message preview",
|
||
subject,
|
||
bodyMode = "text",
|
||
text,
|
||
html,
|
||
recipientLabel,
|
||
recipientNote,
|
||
metaItems = [],
|
||
attachments = [],
|
||
raw,
|
||
rawLabel = "Raw MIME",
|
||
navigation,
|
||
closeLabel = "Close",
|
||
onClose
|
||
}: MessagePreviewOverlayProps) {
|
||
const shownSubject = subject?.trim() || "No subject";
|
||
const shownText = text?.trim() || "No plain-text body to preview.";
|
||
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
|
||
|
||
return (
|
||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
||
<div className="modal-panel template-preview-modal message-preview-modal">
|
||
<header className="modal-header">
|
||
<h2 id="message-preview-title">{title}</h2>
|
||
<button className="modal-close" onClick={onClose}>×</button>
|
||
</header>
|
||
<div className="modal-body">
|
||
{(recipientLabel || recipientNote || navigation) && (
|
||
<div className="template-preview-toolbar">
|
||
<div>
|
||
{recipientLabel && <strong>{recipientLabel}</strong>}
|
||
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
||
</div>
|
||
{navigation && (
|
||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
||
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{metaItems.length > 0 && (
|
||
<div className="detail-grid message-preview-meta">
|
||
{metaItems.map((item) => (
|
||
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="template-preview-box">
|
||
<h3>{shownSubject}</h3>
|
||
{bodyMode === "html" ? (
|
||
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
|
||
) : (
|
||
<pre>{shownText}</pre>
|
||
)}
|
||
</div>
|
||
|
||
<MessagePreviewAttachmentBoxes attachments={attachments} />
|
||
|
||
{raw && (
|
||
<details className="message-preview-raw">
|
||
<summary>{rawLabel}</summary>
|
||
<pre className="mock-message-raw">{raw}</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
|
||
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
|
||
const archiveGroups = new Map<string, MessagePreviewAttachment[]>();
|
||
for (const attachment of attachments) {
|
||
if (!attachment.archiveGroup) continue;
|
||
const group = archiveGroups.get(attachment.archiveGroup) ?? [];
|
||
group.push(attachment);
|
||
archiveGroups.set(attachment.archiveGroup, group);
|
||
}
|
||
|
||
function attachmentChip(attachment: MessagePreviewAttachment, index: number) {
|
||
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
|
||
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
|
||
return (
|
||
<div className="attachment-file-chip" key={`${filename}:${index}`}>
|
||
<strong>{filename}</strong>
|
||
{details && <span>{details}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="template-preview-attachments message-preview-attachments">
|
||
<h3>Attachments</h3>
|
||
{attachments.length === 0 ? (
|
||
<p className="muted small-note">No attachments are effective for this message.</p>
|
||
) : (
|
||
<div className="message-preview-attachment-layout">
|
||
{direct.length > 0 && <div className="attachment-chip-grid">{direct.map(attachmentChip)}</div>}
|
||
{[...archiveGroups.entries()].map(([groupName, items]) => (
|
||
<section className="attachment-zip-group" key={groupName}>
|
||
<header>
|
||
<strong>{items[0]?.archiveLabel || groupName}</strong>
|
||
<span>{items.length} file{items.length === 1 ? "" : "s"} inside ZIP</span>
|
||
</header>
|
||
<div className="attachment-chip-grid">{items.map(attachmentChip)}</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|