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
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>
|
|
);
|
|
}
|