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