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.

275 lines
9.0 KiB
TypeScript

// frontend/components/mail/Compose.tsx — 작성 슬라이드오버 (계정 사이클 · AI 작성 도우미 · 보내기)
"use client";
import { useEffect, useRef, useState } from "react";
import { Icon } from "@/components/Icon";
import type { ComposeAttachment, MailAccount } from "@/lib/types";
export type ComposeState = {
from: string;
to?: string;
cc?: string;
bcc?: string;
subject?: string;
body?: string;
inReplyTo?: string | null;
attachments?: ComposeAttachment[];
};
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const AI_DEFAULT =
"안녕하세요,\n\n메일 잘 받았습니다. 내용 확인하고 빠른 시일 내 회신드리겠습니다.\n\n감사합니다.\n지우 드림";
export function Compose({
init,
accounts,
onClose,
onSend,
}: {
init: ComposeState;
accounts: MailAccount[];
onClose: () => void;
onSend: (c: Required<Pick<ComposeState, "from">> & ComposeState) => void;
}) {
const [from, setFrom] = useState(init.from || accounts[0]?.id || "");
const [to, setTo] = useState(init.to || "");
const [cc, setCc] = useState(init.cc || "");
const [bcc, setBcc] = useState(init.bcc || "");
const [showCc, setShowCc] = useState(!!(init.cc || init.bcc));
const [subject, setSubject] = useState(init.subject || "");
const [body, setBody] = useState(init.body || "");
const [attachments, setAttachments] = useState<ComposeAttachment[]>(init.attachments || []);
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const onFiles = async (files: FileList | null) => {
if (!files) return;
const read = (f: File): Promise<ComposeAttachment> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
name: f.name,
mime: f.type || "application/octet-stream",
data_b64: String(reader.result).split(",")[1] ?? "",
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(f);
});
const added = await Promise.all(Array.from(files).map(read));
setAttachments((prev) => [...prev, ...added]);
if (fileRef.current) fileRef.current.value = "";
};
const removeAttachment = (i: number) =>
setAttachments((prev) => prev.filter((_, idx) => idx !== i));
const accMap: Record<string, MailAccount> = {};
accounts.forEach((a) => (accMap[a.id] = a));
const a = accMap[from];
const accTone = a?.tone ?? "ink";
const accName = a?.name ?? from;
const accEmail = a?.email ?? "";
const cycleFrom = () => {
const ids = accounts.map((x) => x.id);
if (!ids.length) return;
setFrom(ids[(ids.indexOf(from) + 1) % ids.length]);
};
const aiAction = (fn: (b: string) => string) => {
setBusy(true);
setTimeout(() => {
setBody(fn(body));
setBusy(false);
}, 650);
};
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
return (
<>
<div className="cp-backdrop" onClick={onClose} />
<aside className="compose" role="dialog" aria-label="메일 작성">
<div className="cp-head">
<div className="h-ic">
<Icon name="pen" />
</div>
<b>{init.subject ? "답장 작성" : "새 메일"}</b>
<button className="cp-close" onClick={onClose} aria-label="닫기">
<Icon name="x" />
</button>
</div>
<div className="cp-body">
<div className="cp-field">
<label></label>
<button className="from-pick" onClick={cycleFrom}>
<span className="acc-dot" style={{ background: `var(--${accTone})` }} />
{accName}
{accEmail ? ` · ${accEmail}` : ""}
<Icon name="chev" />
</button>
</div>
<div className="cp-field">
<label></label>
<input
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="이름 또는 이메일"
aria-label="받는 사람"
/>
{!showCc && (
<button className="cp-cc-toggle" onClick={() => setShowCc(true)} type="button">
</button>
)}
</div>
{showCc && (
<>
<div className="cp-field">
<label></label>
<input
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc — 쉼표로 구분"
aria-label="참조"
/>
</div>
<div className="cp-field">
<label></label>
<input
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc — 쉼표로 구분"
aria-label="숨은참조"
/>
</div>
</>
)}
<div className="cp-field">
<label></label>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="제목"
aria-label="제목"
/>
</div>
<div className="cp-ai">
<div className="cp-ai-head">
<div className="a-ic">
<Icon name="spark" />
</div>
<b> </b>
</div>
<div className="cp-ai-actions">
<button
className="cp-ai-btn"
disabled={busy}
onClick={() => aiAction(() => AI_DEFAULT)}
>
<Icon name="wand" />
</button>
<button
className="cp-ai-btn"
disabled={busy || !body}
onClick={() =>
aiAction(
(b) =>
"안녕하세요,\n\n" +
b.replace(/^안녕하세요,?\n*/, "") +
"\n\n늘 신경 써 주셔서 감사합니다.\n지우 드림",
)
}
>
<Icon name="wand" />
</button>
<button
className="cp-ai-btn"
disabled={busy || !body}
onClick={() =>
aiAction((b) => b.split("\n").filter(Boolean).slice(0, 2).join("\n"))
}
>
<Icon name="wand" />
</button>
<button
className="cp-ai-btn"
disabled={busy || !body}
onClick={() => aiAction((b) => b.trim())}
>
<Icon name="wand" />
</button>
</div>
</div>
<textarea
className="cp-text"
value={busy ? "아리가 작성 중…" : body}
onChange={(e) => setBody(e.target.value)}
aria-label="본문"
placeholder="내용을 입력하거나, 위에서 ‘초안 작성’을 눌러 아리에게 맡겨보세요…"
/>
{attachments.length > 0 && (
<div className="cp-attachments">
{attachments.map((at, i) => (
<div className="cp-att" key={i}>
<Icon name="file" />
<span className="cp-att-name">{at.name}</span>
<span className="cp-att-size">{fmtSize((at.data_b64.length * 3) / 4)}</span>
<button
className="cp-att-x"
onClick={() => removeAttachment(i)}
aria-label="첨부 제거"
type="button"
>
<Icon name="x" />
</button>
</div>
))}
</div>
)}
</div>
<div className="cp-foot">
<button
className="cp-send"
onClick={() =>
onSend({ from, to, cc, bcc, subject, body, inReplyTo: init.inReplyTo, attachments })
}
>
<Icon name="send" />
</button>
<input
ref={fileRef}
type="file"
multiple
hidden
onChange={(e) => onFiles(e.target.files)}
/>
<button className="cp-tool" aria-label="첨부" onClick={() => fileRef.current?.click()}>
<Icon name="clip" />
</button>
<div className="cp-spacer" />
<button className="cp-discard" onClick={onClose}>
</button>
</div>
</aside>
</>
);
}