You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

222 lines
7.2 KiB
TypeScript

// frontend/components/mail/Reader.tsx — 리딩 패널 (툴바 + 본문 + 첨부 + 대화 + AriPanel)
"use client";
import { useEffect, useState } from "react";
import { Icon } from "@/components/Icon";
import { mailApi } from "@/lib/mail/api";
import type { AiReply, Conversation, EmailDetail, MailAccount, Person } from "@/lib/types";
import { AriPanel } from "./AriPanel";
import { MailHtmlBody } from "./MailHtmlBody";
function fmtBytes(n: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
export function Reader({
m,
person,
account,
added,
loading,
onAdd,
onReply,
onReplyAll,
onForward,
onAddAll,
onArchive,
onStar,
onMarkUnread,
onDelete,
onOpenMsg,
}: {
m: EmailDetail | null;
person?: Person;
account?: MailAccount;
added: Record<string, boolean>;
loading?: boolean;
onAdd: (key: string, kind: "task" | "event" | "file", index: number) => void;
onReply: (m: EmailDetail, replyIndex: number | null, r: AiReply | null) => void;
onReplyAll: (m: EmailDetail) => void;
onForward: (m: EmailDetail) => void;
onAddAll: (m: EmailDetail) => void;
onArchive: (id: string) => void;
onStar: (id: string) => void;
onMarkUnread: (id: string) => void;
onDelete: (id: string) => void;
onOpenMsg?: (id: string) => void;
}) {
const [thread, setThread] = useState<Conversation | null>(null);
const threadId = m?.thread_id ?? "";
const mid = m?.id ?? "";
useEffect(() => {
setThread(null);
if (!mid || !threadId) return;
let live = true;
mailApi
.thread(mid)
.then((t) => live && t.count > 1 && setThread(t))
.catch(() => {});
return () => {
live = false;
};
}, [mid, threadId]);
if (!m) {
return (
<div className="reader">
<div className="rd-empty">
<div className="e-ico">
<Icon name="mail" />
</div>
<h3>{loading ? "메일을 여는 중…" : "읽을 메일을 선택하세요"}</h3>
<p> ·· .</p>
</div>
</div>
);
}
const name = person?.name ?? (m.from_name || m.from);
const initial = person?.initial ?? name.slice(0, 1);
const color = person?.color ?? "var(--muted)";
const email = person?.email ?? m.from_email;
const role = person?.role;
const accTone = account?.tone ?? "ink";
const accName = account?.name ?? m.account;
const accEmail = account?.email ?? "";
return (
<div className="reader">
<div className="rd-toolbar">
<button className="rd-tool" onClick={() => onReply(m, null, null)}>
<Icon name="reply" />
</button>
<button className="rd-tool icon-only" aria-label="전체 답장" onClick={() => onReplyAll(m)}>
<Icon name="replyAll" />
</button>
<button className="rd-tool icon-only" aria-label="전달" onClick={() => onForward(m)}>
<Icon name="forward" />
</button>
<div className="rd-spacer" />
<button className="rd-tool icon-only" aria-label="별표" onClick={() => onStar(m.id)}>
<Icon name="star" w={m.starred ? 0 : 1.9} fill={m.starred ? "currentColor" : "none"} />
</button>
<button
className="rd-tool icon-only"
aria-label="안 읽음으로 표시"
onClick={() => onMarkUnread(m.id)}
>
<Icon name="mail" />
</button>
<button className="rd-tool icon-only" onClick={() => onArchive(m.id)} aria-label="보관">
<Icon name="archive" />
</button>
<button
className="rd-tool icon-only danger"
aria-label="삭제"
onClick={() => onDelete(m.id)}
>
<Icon name="trash" />
</button>
</div>
<div className="rd-scroll" role="region" aria-label="메일 본문" tabIndex={0}>
<div className="rd-inner">
<h1 className="rd-subject">{m.subject}</h1>
<div className="rd-sender">
<div className="av-lg" style={{ background: color }}>
{initial}
</div>
<div className="who">
<b>{name}</b> {role && <span className="em">· {role}</span>}
{email && <div className="em">{email}</div>}
</div>
<div className="when">{m.date}</div>
</div>
<div className="rd-to"> : {m.to}</div>
{m.cc && <div className="rd-to">: {m.cc}</div>}
<div className="rd-acctag">
<span className="acc-dot" style={{ background: `var(--${accTone})` }} />
{accName}
{accEmail ? ` · ${accEmail}` : ""}
</div>
<div className="rd-divider" />
{m.body_html ? (
<MailHtmlBody html={m.body_html} />
) : (
<div className="rd-body">
{m.body.map((para, i) => (
<p key={i}>{para}</p>
))}
</div>
)}
{m.attachments && m.attachments.length > 0 && (
<div className="rd-attachments">
{m.attachments.map((at, i) =>
at.id ? (
<a
className="att-chip"
key={i}
href={mailApi.attachmentUrl(m.id, at.id)}
download={at.name}
title={`${at.name} 내려받기`}
>
<Icon name="file" />
<div className="at-meta">
<b>{at.name}</b>
<span>{fmtBytes(at.size)}</span>
</div>
<Icon name="down" />
</a>
) : (
<div className="att-chip" key={i}>
<Icon name="file" />
<div className="at-meta">
<b>{at.name}</b>
<span>{fmtBytes(at.size)}</span>
</div>
</div>
),
)}
</div>
)}
{thread && (
<div className="rd-thread">
<div className="rd-thread-head">
<Icon name="mail" />
{thread.count}
</div>
{thread.messages
.filter((tm) => tm.id !== m.id)
.map((tm) => (
<button
className="rd-thread-msg"
key={tm.id}
onClick={() => onOpenMsg?.(tm.id)}
disabled={!onOpenMsg}
>
<b className="tm-from">{tm.from}</b>
<span className="tm-prev">{tm.preview}</span>
<span className="tm-time">{tm.time}</span>
</button>
))}
</div>
)}
{m.ai && (
<AriPanel
m={m}
added={added}
onAdd={onAdd}
onReply={(mail, idx, r) => onReply(mail as EmailDetail, idx, r)}
onAddAll={(mail) => onAddAll(mail as EmailDetail)}
/>
)}
</div>
</div>
</div>
);
}