1106 lines
47 KiB
TypeScript
1106 lines
47 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
|
import dynamic from "next/dynamic";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { toast } from "sonner";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import { ChevronsUpDown, Check } from "lucide-react";
|
|
import {
|
|
ResizablePanelGroup,
|
|
ResizablePanel,
|
|
ResizableHandle,
|
|
} from "@/components/ui/resizable";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Mail,
|
|
Inbox,
|
|
RefreshCw,
|
|
Plus,
|
|
Settings,
|
|
Trash2,
|
|
Loader2,
|
|
Search,
|
|
ChevronRight,
|
|
Paperclip,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
X,
|
|
Reply,
|
|
Forward,
|
|
FolderOpen,
|
|
Send,
|
|
Download,
|
|
Eye,
|
|
EyeOff,
|
|
} from "lucide-react";
|
|
import DOMPurify from "isomorphic-dompurify";
|
|
import {
|
|
getUserMailAccounts,
|
|
createUserMailAccount,
|
|
updateUserMailAccount,
|
|
deleteUserMailAccount,
|
|
testUserMailConnectionDirect,
|
|
streamUserMails,
|
|
getUserMailDetail,
|
|
markUserMailAsRead,
|
|
deleteUserMail,
|
|
getUserMailFolders,
|
|
moveUserMail,
|
|
sendUserMail,
|
|
getUserMailAttachments,
|
|
downloadAttachment,
|
|
streamFolderMails,
|
|
UserMailAccount,
|
|
ReceivedMail,
|
|
MailDetail,
|
|
CreateUserMailAccountDto,
|
|
MailFolder,
|
|
SendMailDto,
|
|
|
|
} from "@/lib/api/userMail";
|
|
import type ComposeDialogType from "./ComposeDialog";
|
|
const ComposeDialogDynamic = dynamic(() => import("./ComposeDialog"), { ssr: false }) as typeof ComposeDialogType;
|
|
|
|
const IMAP_PRESETS = [
|
|
{ label: "직접 입력", value: "custom", host: "", port: 993, useTls: true },
|
|
{ label: "Gmail", value: "gmail", host: "imap.gmail.com", port: 993, useTls: true },
|
|
{ label: "Naver", value: "naver", host: "imap.naver.com", port: 993, useTls: true },
|
|
{ label: "Outlook / Hotmail", value: "outlook", host: "outlook.office365.com", port: 993, useTls: true },
|
|
{ label: "Kakao", value: "kakao", host: "imap.kakao.com", port: 993, useTls: true },
|
|
{ label: "Daum", value: "daum", host: "imap.daum.net", port: 993, useTls: true },
|
|
];
|
|
|
|
const DEFAULT_FORM: CreateUserMailAccountDto = {
|
|
displayName: "",
|
|
email: "",
|
|
protocol: "imap",
|
|
host: "",
|
|
port: 993,
|
|
useTls: true,
|
|
username: "",
|
|
password: "",
|
|
};
|
|
|
|
export default function ImapMailPage() {
|
|
const [accounts, setAccounts] = useState<UserMailAccount[]>([]);
|
|
const [selectedAccount, setSelectedAccount] = useState<UserMailAccount | null>(null);
|
|
const [mailsMap, setMailsMap] = useState<Map<number, ReceivedMail[]>>(new Map());
|
|
const [loadingMap, setLoadingMap] = useState<Map<number, boolean>>(new Map());
|
|
const [minSeqnoMap, setMinSeqnoMap] = useState<Map<number, number | null>>(new Map());
|
|
const [loadingMoreMap, setLoadingMoreMap] = useState<Map<number, boolean>>(new Map());
|
|
const [selectedMail, setSelectedMail] = useState<MailDetail | null>(null);
|
|
const [downloadProgress, setDownloadProgress] = useState<Record<number, number>>({});
|
|
const [loadingAccounts, setLoadingAccounts] = useState(false);
|
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
const [showDialog, setShowDialog] = useState(false);
|
|
const [editingAccount, setEditingAccount] = useState<UserMailAccount | null>(null);
|
|
const [form, setForm] = useState<CreateUserMailAccountDto>(DEFAULT_FORM);
|
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
const [formErrors, setFormErrors] = useState<Partial<Record<'displayName' | 'email' | 'host' | 'username' | 'password', string>>>({});
|
|
|
|
// New states
|
|
const [folders, setFolders] = useState<MailFolder[]>([]);
|
|
const [currentFolder, setCurrentFolder] = useState<string>("INBOX");
|
|
const [composeOpen, setComposeOpen] = useState(false);
|
|
const [composeMode, setComposeMode] = useState<"new" | "reply" | "forward">("new");
|
|
const [composeTo, setComposeTo] = useState("");
|
|
const [composeCc, setComposeCc] = useState("");
|
|
const [composeSubject, setComposeSubject] = useState("");
|
|
const [composeInitialHtml, setComposeInitialHtml] = useState("");
|
|
const [composeInReplyTo, setComposeInReplyTo] = useState("");
|
|
const [composeReferences, setComposeReferences] = useState("");
|
|
const [composeSending, setComposeSending] = useState(false);
|
|
const [pendingDeleteMail, setPendingDeleteMail] = useState<ReceivedMail | null>(null);
|
|
const [pendingDeleteAccount, setPendingDeleteAccount] = useState<UserMailAccount | null>(null);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [hostPopoverOpen, setHostPopoverOpen] = useState(false);
|
|
|
|
const detailCacheRef = useRef<Map<string, MailDetail>>(new Map());
|
|
const prefetchingRef = useRef<Set<string>>(new Set());
|
|
const mailsMapRef = useRef<Map<number, ReceivedMail[]>>(new Map());
|
|
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// 현재 선택 계정 기준 파생값
|
|
const mails = selectedAccount ? (mailsMap.get(selectedAccount.id) || []) : [];
|
|
const loadingMails = selectedAccount ? (loadingMap.get(selectedAccount.id) ?? false) : false;
|
|
const minSeqno = selectedAccount ? (minSeqnoMap.get(selectedAccount.id) ?? null) : null;
|
|
const loadingMore = selectedAccount ? (loadingMoreMap.get(selectedAccount.id) ?? false) : false;
|
|
|
|
const imapAccounts = accounts.filter((a) => a.protocol === "imap");
|
|
|
|
useEffect(() => {
|
|
loadAccounts();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedAccount) {
|
|
setSelectedMail(null);
|
|
setCurrentFolder("INBOX");
|
|
loadFolders(selectedAccount);
|
|
// 아직 로딩 안 됐으면 시작
|
|
if (!mailsMap.has(selectedAccount.id) && !loadingMap.get(selectedAccount.id)) {
|
|
startStream(selectedAccount);
|
|
}
|
|
}
|
|
}, [selectedAccount]);
|
|
|
|
async function loadAccounts() {
|
|
setLoadingAccounts(true);
|
|
try {
|
|
const data = await getUserMailAccounts();
|
|
setAccounts(data);
|
|
// 모든 계정 동시 프리로드
|
|
const imapAccts = data.filter((a: UserMailAccount) => a.protocol === "imap");
|
|
for (const account of imapAccts) {
|
|
startStream(account);
|
|
}
|
|
} catch (e) {
|
|
console.error("계정 목록 로드 실패:", e);
|
|
} finally {
|
|
setLoadingAccounts(false);
|
|
}
|
|
}
|
|
|
|
async function loadFolders(account: UserMailAccount) {
|
|
try {
|
|
const data = await getUserMailFolders(account.id);
|
|
setFolders(data);
|
|
} catch {
|
|
setFolders([]);
|
|
}
|
|
}
|
|
|
|
async function prefetchDetail(account: UserMailAccount, mail: ReceivedMail) {
|
|
if (detailCacheRef.current.has(mail.id)) return;
|
|
if (prefetchingRef.current.has(mail.id)) return;
|
|
prefetchingRef.current.add(mail.id);
|
|
try {
|
|
const seqno = parseInt(mail.id.split("-").pop() || "0");
|
|
if (!seqno) return;
|
|
const detail = await getUserMailDetail(account.id, seqno);
|
|
if (detail) detailCacheRef.current.set(mail.id, detail);
|
|
} catch {
|
|
// 프리로드 실패 무시
|
|
} finally {
|
|
prefetchingRef.current.delete(mail.id);
|
|
}
|
|
}
|
|
|
|
function startStream(account: UserMailAccount, before: number | null = null, append = false) {
|
|
// 이미 로딩 중이면 스킵 (초기 로드 한정)
|
|
if (!append && loadingMap.get(account.id)) return;
|
|
|
|
setLoadingMap((prev) => new Map(prev).set(account.id, true));
|
|
|
|
const cancel = streamUserMails(
|
|
account.id, 20, before,
|
|
(mail) => {
|
|
setMailsMap((prev) => {
|
|
const next = new Map(prev);
|
|
const existing = next.get(account.id) || [];
|
|
const updated = append
|
|
? [...existing, mail]
|
|
: [mail, ...existing.filter((m) => m.id !== mail.id)];
|
|
next.set(account.id, updated);
|
|
mailsMapRef.current = next;
|
|
return next;
|
|
});
|
|
setMinSeqnoMap((prev) => {
|
|
const seqno = parseInt(mail.id.split("-").pop() || "0");
|
|
const current = prev.get(account.id) ?? null;
|
|
return new Map(prev).set(account.id, current === null ? seqno : Math.min(current, seqno));
|
|
});
|
|
setLoadingMap((prev) => new Map(prev).set(account.id, false));
|
|
},
|
|
() => {
|
|
setLoadingMap((prev) => new Map(prev).set(account.id, false));
|
|
setLoadingMoreMap((prev) => new Map(prev).set(account.id, false));
|
|
// 상위 5개 자동 프리로드 (순차, Gmail은 throttling으로 느려지므로 간격 늘림)
|
|
const currentMails = mailsMapRef.current.get(account.id) || [];
|
|
const top5 = currentMails.slice(0, 5);
|
|
const isGmail = account.host.toLowerCase().includes('gmail') || account.email.toLowerCase().includes('@gmail');
|
|
const interval = isGmail ? 800 : 100;
|
|
top5.forEach((m, i) => {
|
|
setTimeout(() => prefetchDetail(account, m), i * interval);
|
|
});
|
|
},
|
|
(err) => {
|
|
setLoadingMap((prev) => new Map(prev).set(account.id, false));
|
|
setLoadingMoreMap((prev) => new Map(prev).set(account.id, false));
|
|
console.error("스트리밍 오류:", err);
|
|
}
|
|
);
|
|
return cancel;
|
|
}
|
|
|
|
function handleFolderClick(folder: string) {
|
|
if (!selectedAccount) return;
|
|
setCurrentFolder(folder);
|
|
setSelectedMail(null);
|
|
|
|
if (folder === "INBOX") {
|
|
setMailsMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; });
|
|
setMinSeqnoMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; });
|
|
startStream(selectedAccount);
|
|
} else {
|
|
setMailsMap((prev) => { const n = new Map(prev); n.set(selectedAccount.id, []); return n; });
|
|
setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, true));
|
|
streamFolderMails(
|
|
selectedAccount.id, folder, 20, null,
|
|
(mail) => {
|
|
setMailsMap((prev) => {
|
|
const n = new Map(prev);
|
|
n.set(selectedAccount.id, [...(n.get(selectedAccount.id) || []), mail]);
|
|
return n;
|
|
});
|
|
setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false));
|
|
},
|
|
() => setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)),
|
|
(err) => { setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)); console.error(err); }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleMailClick(mail: ReceivedMail) {
|
|
if (!selectedAccount) return;
|
|
const seqno = parseInt(mail.id.split("-").pop() || "0");
|
|
|
|
// 캐시 히트 시 즉시 표시
|
|
const cached = detailCacheRef.current.get(mail.id);
|
|
if (cached) {
|
|
setSelectedMail(cached);
|
|
// 첨부파일 로드
|
|
if (seqno) {
|
|
}
|
|
if (!mail.isRead) {
|
|
markUserMailAsRead(selectedAccount.id, seqno).then(() => loadFolders(selectedAccount)).catch(() => {});
|
|
setMailsMap((prev) => {
|
|
const next = new Map(prev);
|
|
const mails = (next.get(selectedAccount.id) || []).map((m) =>
|
|
m.id === mail.id ? { ...m, isRead: true } : m
|
|
);
|
|
next.set(selectedAccount.id, mails);
|
|
mailsMapRef.current = next;
|
|
return next;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 캐시 미스 시 fetch
|
|
setLoadingDetail(true);
|
|
try {
|
|
const detail = await getUserMailDetail(selectedAccount.id, seqno);
|
|
if (detail) {
|
|
detailCacheRef.current.set(mail.id, detail);
|
|
setSelectedMail(detail);
|
|
}
|
|
// 첨부파일 로드
|
|
if (seqno) {
|
|
}
|
|
if (!mail.isRead) {
|
|
await markUserMailAsRead(selectedAccount.id, seqno);
|
|
setMailsMap((prev) => {
|
|
const next = new Map(prev);
|
|
const mails = (next.get(selectedAccount.id) || []).map((m) =>
|
|
m.id === mail.id ? { ...m, isRead: true } : m
|
|
);
|
|
next.set(selectedAccount.id, mails);
|
|
mailsMapRef.current = next;
|
|
return next;
|
|
});
|
|
loadFolders(selectedAccount);
|
|
}
|
|
} catch (e) {
|
|
console.error("메일 상세 로드 실패:", e);
|
|
} finally {
|
|
setLoadingDetail(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteMail(mail: ReceivedMail) {
|
|
if (!selectedAccount) return;
|
|
setPendingDeleteMail(mail);
|
|
}
|
|
|
|
async function confirmDeleteMail() {
|
|
if (!selectedAccount || !pendingDeleteMail) return;
|
|
const mail = pendingDeleteMail;
|
|
setPendingDeleteMail(null);
|
|
const seqno = parseInt(mail.id.split("-").pop() || "0");
|
|
try {
|
|
await deleteUserMail(selectedAccount.id, seqno);
|
|
setMailsMap((prev) => {
|
|
const next = new Map(prev);
|
|
const existing = next.get(selectedAccount.id) || [];
|
|
next.set(selectedAccount.id, existing.filter((m) => m.id !== mail.id));
|
|
return next;
|
|
});
|
|
if (selectedMail?.id === mail.id) setSelectedMail(null);
|
|
loadFolders(selectedAccount);
|
|
} catch (e: any) {
|
|
toast.error("메일 삭제 실패: " + e.message);
|
|
}
|
|
}
|
|
|
|
async function handleMove(mail: ReceivedMail, targetFolder: string) {
|
|
if (!selectedAccount) return;
|
|
const seqno = parseInt(mail.id.split("-").pop() || "0");
|
|
try {
|
|
await moveUserMail(selectedAccount.id, seqno, targetFolder);
|
|
setMailsMap((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(selectedAccount.id, (next.get(selectedAccount.id) || []).filter((m) => m.id !== mail.id));
|
|
return next;
|
|
});
|
|
if (selectedMail?.id === mail.id) setSelectedMail(null);
|
|
} catch (e) {
|
|
console.error("메일 이동 실패:", e);
|
|
}
|
|
}
|
|
|
|
function handleReply() {
|
|
if (!selectedMail) return;
|
|
setComposeMode("reply");
|
|
setComposeTo(selectedMail.from);
|
|
setComposeSubject(`Re: ${selectedMail.subject.replace(/^Re:\s*/i, "")}`);
|
|
setComposeInReplyTo(selectedMail.messageId);
|
|
setComposeReferences(selectedMail.messageId);
|
|
const dateStr = new Date(selectedMail.date).toLocaleString("ko-KR");
|
|
setComposeInitialHtml(
|
|
`<br><br><div style="border-left:2px solid #ccc;padding-left:8px;color:#666">
|
|
<div>${dateStr}, ${selectedMail.from} 작성:</div>
|
|
<blockquote>${selectedMail.htmlBody || selectedMail.textBody}</blockquote>
|
|
</div>`
|
|
);
|
|
setComposeOpen(true);
|
|
}
|
|
|
|
function handleForward() {
|
|
if (!selectedMail) return;
|
|
setComposeMode("forward");
|
|
setComposeTo("");
|
|
setComposeSubject(`Fwd: ${selectedMail.subject.replace(/^Fwd:\s*/i, "")}`);
|
|
setComposeInReplyTo("");
|
|
setComposeReferences("");
|
|
setComposeInitialHtml(
|
|
`<br><br><div>---------- 전달된 메일 ----------</div>
|
|
<div>보낸사람: ${selectedMail.from}</div>
|
|
<div>날짜: ${new Date(selectedMail.date).toLocaleString("ko-KR")}</div>
|
|
<div>제목: ${selectedMail.subject}</div>
|
|
<div>받는사람: ${selectedMail.to}</div>
|
|
<br>${selectedMail.htmlBody || selectedMail.textBody}`
|
|
);
|
|
setComposeOpen(true);
|
|
}
|
|
|
|
function openAddDialog() {
|
|
setEditingAccount(null);
|
|
setForm(DEFAULT_FORM);
|
|
setTestResult(null);
|
|
setShowPassword(false);
|
|
setHostPopoverOpen(false);
|
|
setFormErrors({});
|
|
setShowDialog(true);
|
|
}
|
|
|
|
function openEditDialog(account: UserMailAccount) {
|
|
setEditingAccount(account);
|
|
setForm({
|
|
displayName: account.displayName,
|
|
email: account.email,
|
|
protocol: "imap",
|
|
host: account.host,
|
|
port: account.port,
|
|
useTls: account.useTls,
|
|
username: account.username,
|
|
password: "",
|
|
});
|
|
setTestResult(null);
|
|
setShowPassword(false);
|
|
setShowDialog(true);
|
|
}
|
|
|
|
function handleTlsToggle(checked: boolean) {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
useTls: checked,
|
|
port: checked ? 993 : 143,
|
|
}));
|
|
}
|
|
|
|
async function handleSave() {
|
|
const errors: typeof formErrors = {};
|
|
if (!form.displayName.trim()) errors.displayName = "표시 이름을 입력하세요.";
|
|
if (!form.email.trim()) errors.email = "이메일 주소를 입력하세요.";
|
|
if (!form.host.trim()) errors.host = "IMAP 호스트를 입력하세요.";
|
|
if (!form.username.trim()) errors.username = "사용자명을 입력하세요.";
|
|
if (!editingAccount && !form.password.trim()) errors.password = "비밀번호를 입력하세요.";
|
|
if (Object.keys(errors).length > 0) { setFormErrors(errors); return; }
|
|
setFormErrors({});
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
try {
|
|
if (editingAccount) {
|
|
await updateUserMailAccount(editingAccount.id, form);
|
|
} else {
|
|
await createUserMailAccount(form);
|
|
}
|
|
await loadAccounts();
|
|
setShowDialog(false);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || (e instanceof Error ? e.message : "저장 실패");
|
|
setSaveError(msg);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteAccount(account: UserMailAccount) {
|
|
setPendingDeleteAccount(account);
|
|
}
|
|
|
|
async function confirmDeleteAccount() {
|
|
if (!pendingDeleteAccount) return;
|
|
const account = pendingDeleteAccount;
|
|
setPendingDeleteAccount(null);
|
|
try {
|
|
await deleteUserMailAccount(account.id);
|
|
if (selectedAccount?.id === account.id) {
|
|
setSelectedAccount(null);
|
|
setSelectedMail(null);
|
|
}
|
|
setMailsMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
|
|
setMinSeqnoMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
|
|
setLoadingMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
|
|
setLoadingMoreMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
|
|
await loadAccounts();
|
|
} catch (e) {
|
|
console.error("계정 삭제 실패:", e);
|
|
}
|
|
}
|
|
|
|
async function handleTest() {
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const result = await testUserMailConnectionDirect({
|
|
protocol: form.protocol,
|
|
host: form.host,
|
|
port: form.port,
|
|
useTls: form.useTls,
|
|
username: form.username,
|
|
password: form.password,
|
|
});
|
|
setTestResult(result);
|
|
} catch (e: unknown) {
|
|
setTestResult({
|
|
success: false,
|
|
message: e instanceof Error ? e.message : "연결 테스트 실패",
|
|
});
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
const filteredMails = mails.filter(
|
|
(m) =>
|
|
m.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
m.from.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
function formatDate(dateStr: string) {
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const isToday =
|
|
d.getFullYear() === now.getFullYear() &&
|
|
d.getMonth() === now.getMonth() &&
|
|
d.getDate() === now.getDate();
|
|
return isToday
|
|
? d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })
|
|
: d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="h-5 w-5 text-muted-foreground" />
|
|
<h1 className="text-lg font-semibold">메일 관리</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={() => { setComposeMode("new"); setComposeInitialHtml(""); setComposeTo(""); setComposeCc(""); setComposeSubject(""); setComposeOpen(true); }}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
작성
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3단 패널 */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<ResizablePanelGroup direction="horizontal" className="h-full">
|
|
{/* 계정 목록 */}
|
|
<ResizablePanel defaultSize={18} minSize={14}>
|
|
<div className="flex flex-col h-full border-r">
|
|
<div className="px-3 py-2 border-b text-xs font-medium text-muted-foreground flex items-center gap-1">
|
|
<Inbox className="h-3.5 w-3.5" />
|
|
계정 목록
|
|
{loadingAccounts && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
<button
|
|
className="ml-auto p-0.5 hover:text-foreground text-muted-foreground"
|
|
onClick={openAddDialog}
|
|
title="계정 추가"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{imapAccounts.length === 0 && !loadingAccounts ? (
|
|
<div className="p-3 text-xs text-muted-foreground text-center">
|
|
계정이 없습니다
|
|
</div>
|
|
) : (
|
|
imapAccounts.map((account) => (
|
|
<div
|
|
key={account.id}
|
|
className={`group flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 border-b text-sm transition-colors ${
|
|
selectedAccount?.id === account.id ? "bg-muted" : ""
|
|
}`}
|
|
onClick={() => setSelectedAccount(account)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{account.displayName}</div>
|
|
<div className="text-xs text-muted-foreground truncate">{account.email}</div>
|
|
</div>
|
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
|
|
<button
|
|
className="p-0.5 hover:text-foreground text-muted-foreground"
|
|
onClick={(e) => { e.stopPropagation(); openEditDialog(account); }}
|
|
>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
className="p-0.5 hover:text-destructive text-muted-foreground"
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteAccount(account); }}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
{/* 폴더 목록 */}
|
|
{selectedAccount && folders.length > 0 && (
|
|
<div className="mt-4">
|
|
<div className="text-xs font-semibold text-muted-foreground px-2 mb-1 flex items-center gap-1">
|
|
<FolderOpen className="w-3 h-3" /> 폴더
|
|
</div>
|
|
{folders.map((folder) => (
|
|
<div
|
|
key={folder.path}
|
|
className={`px-2 py-1 text-sm cursor-pointer rounded hover:bg-accent flex justify-between items-center ${currentFolder === folder.path ? "bg-accent font-medium" : ""}`}
|
|
onClick={() => handleFolderClick(folder.path)}
|
|
>
|
|
<span>{folder.name}</span>
|
|
{folder.unseen > 0 && <Badge variant="secondary" className="text-xs">{folder.unseen}</Badge>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle />
|
|
|
|
{/* 메일 목록 */}
|
|
<ResizablePanel defaultSize={32} minSize={22}>
|
|
<div className="flex flex-col h-full border-r">
|
|
<div className="px-3 py-2 border-b flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
className="pl-7 h-7 text-xs"
|
|
placeholder="제목, 발신자 검색"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
{selectedAccount && (
|
|
<button
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
if (selectedAccount) {
|
|
setMailsMap((prev) => { const next = new Map(prev); next.delete(selectedAccount.id); return next; });
|
|
setMinSeqnoMap((prev) => { const next = new Map(prev); next.delete(selectedAccount.id); return next; });
|
|
if (currentFolder === "INBOX") {
|
|
startStream(selectedAccount);
|
|
} else {
|
|
handleFolderClick(currentFolder);
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!selectedAccount ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
|
<Mail className="h-8 w-8 opacity-30" />
|
|
<p className="text-sm">계정을 선택하세요</p>
|
|
</div>
|
|
) : loadingMails && mails.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : filteredMails.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
|
<Inbox className="h-8 w-8 opacity-30" />
|
|
<p className="text-sm">{searchTerm ? `"${searchTerm}" 검색 결과가 없습니다` : "메일이 없습니다"}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{filteredMails.map((mail) => (
|
|
<div
|
|
key={mail.id}
|
|
className={`group flex items-start gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 border-b transition-colors ${
|
|
selectedMail?.id === mail.id ? "bg-muted" : ""
|
|
}`}
|
|
onClick={() => handleMailClick(mail)}
|
|
onMouseEnter={() => {
|
|
if (!selectedAccount || detailCacheRef.current.has(mail.id)) return;
|
|
hoverTimerRef.current = setTimeout(() => {
|
|
prefetchDetail(selectedAccount, mail);
|
|
}, 300);
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
|
|
}}
|
|
>
|
|
<div className="mt-1">
|
|
{!mail.isRead ? (
|
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
|
) : (
|
|
<div className="h-2 w-2 rounded-full bg-transparent border border-muted-foreground/30" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-1">
|
|
<span className={`text-xs truncate ${!mail.isRead ? "font-semibold" : "text-muted-foreground"}`}>
|
|
{mail.from}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
{formatDate(mail.date)}
|
|
</span>
|
|
</div>
|
|
<div className={`text-sm truncate ${!mail.isRead ? "font-medium" : ""}`}>
|
|
{mail.subject || "(제목 없음)"}
|
|
</div>
|
|
<div className="flex items-center gap-1 mt-0.5">
|
|
<span className="text-xs text-muted-foreground truncate flex-1">
|
|
{mail.preview}
|
|
</span>
|
|
{mail.hasAttachments && (
|
|
<Paperclip className="h-3 w-3 text-muted-foreground shrink-0" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive text-muted-foreground mt-1"
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteMail(mail); }}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{loadingMore ? (
|
|
<div className="flex items-center justify-center py-3">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : !loadingMore && mails.length >= 20 ? (
|
|
<button
|
|
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
onClick={() => {
|
|
if (selectedAccount) {
|
|
setLoadingMoreMap((prev) => new Map(prev).set(selectedAccount.id, true));
|
|
startStream(selectedAccount, minSeqno, true);
|
|
}
|
|
}}
|
|
>
|
|
이전 메일 더 보기
|
|
</button>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle />
|
|
|
|
{/* 메일 상세 */}
|
|
<ResizablePanel defaultSize={50} minSize={30}>
|
|
<div className="flex flex-col h-full">
|
|
{loadingDetail ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : !selectedMail ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
|
<ChevronRight className="h-8 w-8 opacity-30" />
|
|
<p className="text-sm">메일을 선택하세요</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="px-4 py-3 border-b space-y-1">
|
|
<h2 className="font-semibold text-base">
|
|
{selectedMail.subject || "(제목 없음)"}
|
|
</h2>
|
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
|
<div><span className="font-medium">From:</span> {selectedMail.from}</div>
|
|
<div><span className="font-medium">To:</span> {selectedMail.to}</div>
|
|
{selectedMail.cc && (
|
|
<div><span className="font-medium">CC:</span> {selectedMail.cc}</div>
|
|
)}
|
|
<div><span className="font-medium">Date:</span> {new Date(selectedMail.date).toLocaleString("ko-KR")}</div>
|
|
</div>
|
|
{selectedMail.attachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 pt-1">
|
|
{selectedMail.attachments.map((att, i) => {
|
|
const seqno = selectedMail ? parseInt(selectedMail.id.split("-").pop() || "0") : 0;
|
|
const accountId = selectedAccount?.id || 0;
|
|
const progress = downloadProgress[i];
|
|
const isDownloading = progress !== undefined;
|
|
return (
|
|
<button key={i}
|
|
disabled={isDownloading}
|
|
onClick={async () => {
|
|
setDownloadProgress(p => ({ ...p, [i]: 0 }));
|
|
try {
|
|
const list = await getUserMailAttachments(accountId, seqno, currentFolder);
|
|
const matched = list.find(a => a.filename === att.filename) || list[i];
|
|
if (!matched) { toast.error('첨부파일 정보를 불러올 수 없습니다'); return; }
|
|
await downloadAttachment(
|
|
accountId, seqno, matched.partId, matched.filename, currentFolder,
|
|
(pct) => setDownloadProgress(p => ({ ...p, [i]: pct })),
|
|
matched.size
|
|
);
|
|
} catch (e: any) { toast.error(e.message); }
|
|
finally { setDownloadProgress(p => { const next = { ...p }; delete next[i]; return next; }); }
|
|
}}
|
|
className="relative inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-secondary hover:bg-accent border disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer min-w-[80px] overflow-hidden">
|
|
{isDownloading ? (
|
|
<>
|
|
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
|
|
<span className="flex-1 truncate">{att.filename}</span>
|
|
<span className="shrink-0 font-mono">{progress}%</span>
|
|
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-primary/30 overflow-hidden">
|
|
<span className="block h-full bg-primary transition-all duration-200" style={{ width: `${progress}%` }} />
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{att.filename}</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{/* 답장/전달/이동/삭제 버튼 */}
|
|
<div className="flex gap-2 mt-2 flex-wrap">
|
|
<Button size="sm" variant="outline" onClick={handleReply}>
|
|
<Reply className="w-3 h-3 mr-1" /> 답장
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={handleForward}>
|
|
<Forward className="w-3 h-3 mr-1" /> 전달
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button size="sm" variant="outline">
|
|
<FolderOpen className="w-3 h-3 mr-1" /> 이동
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
{folders.filter((f) => f.path !== currentFolder).map((f) => (
|
|
<DropdownMenuItem key={f.path} onClick={() => handleMove(selectedMail, f.path)}>
|
|
{f.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button size="sm" variant="destructive" onClick={() => handleDeleteMail(selectedMail)}>
|
|
<Trash2 className="w-3 h-3 mr-1" /> 삭제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-hidden">
|
|
{selectedMail.htmlBody ? (
|
|
<iframe
|
|
srcDoc={DOMPurify.sanitize(selectedMail.htmlBody)}
|
|
className="w-full h-full border-0"
|
|
sandbox="allow-same-origin"
|
|
title="메일 본문"
|
|
/>
|
|
) : (
|
|
<div className="p-4 overflow-y-auto h-full">
|
|
<pre className="text-sm whitespace-pre-wrap font-sans">
|
|
{selectedMail.textBody}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
{/* ComposeDialog */}
|
|
<ComposeDialogDynamic
|
|
open={composeOpen}
|
|
onOpenChange={setComposeOpen}
|
|
mode={composeMode}
|
|
to={composeTo} setTo={setComposeTo}
|
|
cc={composeCc} setCc={setComposeCc}
|
|
subject={composeSubject} setSubject={setComposeSubject}
|
|
initialHtml={composeInitialHtml} setInitialHtml={setComposeInitialHtml}
|
|
inReplyTo={composeInReplyTo}
|
|
references={composeReferences}
|
|
sending={composeSending} setSending={setComposeSending}
|
|
accountId={selectedAccount?.id ?? null}
|
|
accounts={imapAccounts}
|
|
/>
|
|
|
|
{/* 계정 추가/편집 다이얼로그 */}
|
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingAccount ? "IMAP 계정 편집" : "IMAP 계정 추가"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">표시 이름</Label>
|
|
<Input
|
|
value={form.displayName}
|
|
onChange={(e) => { setForm((p) => ({ ...p, displayName: e.target.value })); setFormErrors((p) => ({ ...p, displayName: undefined })); }}
|
|
placeholder="내 Gmail"
|
|
className={formErrors.displayName ? "border-destructive" : ""}
|
|
/>
|
|
{formErrors.displayName && <p className="text-xs text-destructive">{formErrors.displayName}</p>}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">이메일 주소</Label>
|
|
<Input
|
|
type="email"
|
|
value={form.email}
|
|
onChange={(e) => { setForm((p) => ({ ...p, email: e.target.value, username: e.target.value })); setFormErrors((p) => ({ ...p, email: undefined })); }}
|
|
placeholder="user@example.com"
|
|
className={formErrors.email ? "border-destructive" : ""}
|
|
/>
|
|
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">IMAP 호스트</Label>
|
|
<Popover open={hostPopoverOpen} onOpenChange={setHostPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" role="combobox" className="w-full justify-between font-normal">
|
|
<span className={cn("truncate", !form.host && "text-muted-foreground")}>
|
|
{form.host || "imap.gmail.com"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[220px] p-0 z-[10002]" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="호스트 직접 입력..."
|
|
value={form.host}
|
|
onValueChange={(v) => { setForm((p) => ({ ...p, host: v })); setFormErrors((p) => ({ ...p, host: undefined })); }}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>직접 입력한 값을 사용합니다</CommandEmpty>
|
|
<CommandGroup>
|
|
{IMAP_PRESETS.filter((p) => p.value !== "custom").map((preset) => (
|
|
<CommandItem
|
|
key={preset.value}
|
|
value={preset.host}
|
|
onSelect={() => {
|
|
setForm((p) => ({ ...p, host: preset.host, port: preset.port, useTls: preset.useTls }));
|
|
setHostPopoverOpen(false);
|
|
}}
|
|
>
|
|
<Check className={cn("mr-2 h-4 w-4", form.host === preset.host ? "opacity-100" : "opacity-0")} />
|
|
<div>
|
|
<div className="text-sm font-medium">{preset.label}</div>
|
|
<div className="text-xs text-muted-foreground">{preset.host}</div>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{formErrors.host && <p className="text-xs text-destructive">{formErrors.host}</p>}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">포트</Label>
|
|
<Input
|
|
type="number"
|
|
value={form.port}
|
|
onChange={(e) => setForm((p) => ({ ...p, port: parseInt(e.target.value) || 993 }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">TLS/SSL 사용</Label>
|
|
<Switch checked={form.useTls} onCheckedChange={handleTlsToggle} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">사용자명</Label>
|
|
<Input
|
|
value={form.username}
|
|
onChange={(e) => { setForm((p) => ({ ...p, username: e.target.value })); setFormErrors((p) => ({ ...p, username: undefined })); }}
|
|
placeholder="user@example.com"
|
|
className={formErrors.username ? "border-destructive" : ""}
|
|
/>
|
|
{formErrors.username && <p className="text-xs text-destructive">{formErrors.username}</p>}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">비밀번호 {editingAccount && "(변경 시에만 입력)"}</Label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showPassword ? "text" : "password"}
|
|
value={form.password}
|
|
onChange={(e) => { setForm((p) => ({ ...p, password: e.target.value })); setFormErrors((p) => ({ ...p, password: undefined })); }}
|
|
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
|
|
placeholder="••••••••"
|
|
className={`pr-9 ${formErrors.password ? "border-destructive" : ""}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
{formErrors.password && <p className="text-xs text-destructive">{formErrors.password}</p>}
|
|
</div>
|
|
{editingAccount && (
|
|
<div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleTest}
|
|
disabled={testing}
|
|
>
|
|
{testing ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
|
) : null}
|
|
연결 테스트
|
|
</Button>
|
|
{testResult && (
|
|
<div className={`mt-2 text-xs flex items-center gap-1 ${testResult.success ? "text-green-600" : "text-red-600"}`}>
|
|
{testResult.success ? (
|
|
<CheckCircle className="h-3.5 w-3.5" />
|
|
) : (
|
|
<AlertCircle className="h-3.5 w-3.5" />
|
|
)}
|
|
{testResult.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{saveError && (
|
|
<div className="px-6 pb-2 text-sm text-red-600">{saveError}</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowDialog(false)}>
|
|
<X className="h-4 w-4 mr-1" />
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 메일 삭제 확인 */}
|
|
<AlertDialog open={!!pendingDeleteMail} onOpenChange={(open) => { if (!open) setPendingDeleteMail(null); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>메일 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription>메일을 삭제하시겠습니까?</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDeleteMail} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">삭제</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 계정 삭제 확인 */}
|
|
<AlertDialog open={!!pendingDeleteAccount} onOpenChange={(open) => { if (!open) setPendingDeleteAccount(null); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>계정 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription>"{pendingDeleteAccount?.displayName}" 계정을 삭제하시겠습니까?</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDeleteAccount} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">삭제</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|