/* 아리 — 메일(Mail) · 클린 화이트 글래스
여러 계정 통합 수신 · 읽기 · 작성 + AI 비서가 할 일/일정/답장/정리를 미리 준비 */
const { useState, useEffect, useRef, useMemo } = React;
const MD = window.MailData;
const PPL = MD.people;
const ACC = MD.acc;
/* ---------- 아이콘 ---------- */
const P = {
spark: "M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8z|M19 4v3|M5 17v3",
grid: "M3 3h7v7H3z|M14 3h7v7h-7z|M14 14h7v7h-7z|M3 14h7v7H3z",
cal: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
route: "M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M9 16h6a3 3 0 0 0 3-3V11",
check: "M9 11l3 3L22 4|M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
tick: "M20 6 9 17l-5-5",
mail: "M22 7 13.03 12.7a1.94 1.94 0 0 1-2.06 0L2 7|M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
brain: "M12 5a3 3 0 1 0-5.9.8A3 3 0 0 0 4 9.5 3 3 0 0 0 6 14a3 3 0 0 0 6 1zM12 5a3 3 0 1 1 5.9.8A3 3 0 0 1 20 9.5 3 3 0 0 1 18 14a3 3 0 0 1-6 1z|M12 5v14",
heart: "M19 14c1.5-1.5 3-3.4 3-5.5A4.5 4.5 0 0 0 12 5.5 4.5 4.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7z",
inbox: "M22 12h-6l-2 3h-4l-2-3H2|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
star: "M12 2.5l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.6 9.8l6.5-.9z",
send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z",
file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|M14 2v6h6",
archive: "M21 8v13H3V8|M1 3h22v5H1z|M10 12h4",
search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9|M10.3 21a1.94 1.94 0 0 0 3.4 0",
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
plus: "M12 5v14M5 12h14",
x: "M18 6 6 18M6 6l12 12",
more: "M5 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM11 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM17 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0z",
chev: "M9 6l6 6-6 6",
reply: "M9 17l-5-5 5-5|M4 12h11a5 5 0 0 1 5 5v2",
replyAll: "M7 17l-5-5 5-5|M12 17l-5-5 5-5|M7 12h9a5 5 0 0 1 5 5v2",
forward: "M15 17l5-5-5-5|M20 12H9a5 5 0 0 0-5 5v2",
trash: "M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",
clip: "M21 11.5 12 20a5.5 5.5 0 0 1-8-8l8.5-8a3.7 3.7 0 0 1 5.3 5.3l-8.6 8a1.9 1.9 0 0 1-2.7-2.7l8-7.5",
refresh: "M21 12a9 9 0 1 1-3-6.7|M21 3v5h-5",
pen: "M12 20h9|M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z|M12 7v5l3 2",
user: "M20 21a8 8 0 0 0-16 0|M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
arrow: "M5 12h14|M13 6l6 6-6 6",
calPlus: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v6|M19 16v6M16 19h6",
folder: "M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
flag: "M4 22V4M4 4s1.5-1 5-1 5 2 8 2 4-1 4-1v10s-1.5 1-4 1-5-2-8-2-5 1-5 1",
wand: "M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8 19 13M17.8 6.2 19 5M3 21l9-9M12.2 6.2 11 5",
at: "M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.9 7.9",
layers: "M12 2 2 7l10 5 10-5z|M2 17l10 5 10-5M2 12l10 5 10-5",
zap: "M13 2 3 14h9l-1 8 10-12h-9z",
};
function I({ d, w }) {
return (
);
}
const MAIN = [
{ id: "dash", label: "대시보드", icon: "grid", href: "대시보드.html" },
{ id: "sbox", label: "인박스", icon: "inbox", href: "인박스.html" },
{ id: "appr", label: "결재함", icon: "spark", badge: "3", href: "결재함.html" },
{ id: "auto", label: "자동화", icon: "zap", href: "자동화.html" },
{ id: "journey", label: "여정", icon: "route", href: "여정.html" },
{ id: "cal", label: "일정", icon: "cal", href: "일정.html" },
{ id: "task", label: "작업", icon: "check", badge: "4", href: "작업.html" },
{ id: "mail", label: "메일", icon: "mail", href: "메일.html" },
{ id: "noti", label: "알림", icon: "bell", badge: "6", href: "알림.html" },
{ id: "research", label: "리서치", icon: "brain", href: "리서치.html" },
{ id: "trip", label: "여행", icon: "send", href: "여행.html" },
{ id: "life", label: "라이프", icon: "heart", href: "라이프.html" },
{ id: "wrap", label: "하루 마감", icon: "moon", href: "하루 마감.html" },
];
function load(k, fb) { try { const v = localStorage.getItem("ariM." + k); return v === null ? fb : JSON.parse(v); } catch (e) { return fb; } }
function save(k, v) { try { localStorage.setItem("ariM." + k, JSON.stringify(v)); } catch (e) {} }
const aiCount = (m) => (m.ai ? m.ai.tasks.length + m.ai.events.length + (m.ai.replies.length ? 1 : 0) : 0);
/* ================= 메일 목록 행 ================= */
function MailRow({ m, on, onClick, onStar }) {
const p = PPL[m.from];
const a = ACC[m.account];
return (
{p.initial}
{p.name}
{m.time}
{m.subject}
{m.preview}
{m.ai && (m.ai.priority === "높음" || aiCount(m) > 0) && (
{m.ai.priority === "높음" && 중요}
{m.ai.tasks.length > 0 && 할 일 {m.ai.tasks.length}}
{m.ai.events.length > 0 && 일정 {m.ai.events.length}}
{m.ai.replies.length > 0 && 답장 추천}
)}
{m.hasAttach && }
);
}
/* ================= 제안 항목 (할 일 / 일정) ================= */
function Sug({ kind, text, meta, added, onAdd }) {
return (
);
}
/* ================= 아리 분석 패널 ================= */
function AriPanel({ m, added, onAdd, onReply, onAddAll }) {
const ai = m.ai;
const keys = [];
ai.tasks.forEach((_, i) => keys.push("t:" + m.id + ":" + i));
ai.events.forEach((_, i) => keys.push("e:" + m.id + ":" + i));
if (ai.file) keys.push("f:" + m.id);
const allDone = keys.length > 0 && keys.every((k) => added[k]);
return (
아리가 분석했어요
{ai.category} · 새 메일을 읽고 필요한 일을 정리했어요
{ai.priority}
요약 — " + ai.summary }} />
{ai.tasks.length > 0 && (
할 일로 추가{ai.tasks.length}건
{ai.tasks.map((t, i) => {
const k = "t:" + m.id + ":" + i;
return (
onAdd(k, "작업", t.text)}
meta={<>
{t.project}
{t.due}
{t.prio}
>} />
);
})}
)}
{ai.events.length > 0 && (
일정에 추가{ai.events.length}건
{ai.events.map((ev, i) => {
const k = "e:" + m.id + ":" + i;
return (
onAdd(k, "일정", ev.title)}
meta={<>
{ev.date} ({ev.day})
{ev.time}{ev.dur ? " · " + ev.dur : ""}
{ev.place && <>{ev.place}>}
>} />
);
})}
)}
{ai.replies.length > 0 && (
답장 추천{ai.replies.length}개 초안
{ai.replies.map((r, i) => (
))}
)}
{ai.file && (() => {
const k = "f:" + m.id;
return (
{ai.file.project}에 정리{ai.file.reason}
);
})()}
{keys.length > 1 && (
아리가 준비한 {keys.length}개 작업을 한 번에 적용할 수 있어요
)}
);
}
/* ================= 리딩 패널 ================= */
function Reader({ m, added, onAdd, onReply, onAddAll, onCompose, onArchive, onStar }) {
if (!m) {
return (
읽을 메일을 선택하세요
메일을 열면 아리가 내용을 분석해 할 일·일정·답장을 미리 준비해 드려요.
);
}
const p = PPL[m.from], a = ACC[m.account];
return (
{m.subject}
{p.initial}
{p.name} {p.role &&
· {p.role}}
{p.email}
{m.date}
받는 사람: {m.to}
{a.name} · {a.email}
{m.body.map((para, i) =>
{para}
)}
{m.attachments && m.attachments.length > 0 && (
{m.attachments.map((at, i) => (
))}
)}
{m.ai &&
}
);
}
/* ================= 아리 정리 (트리아지) ================= */
function Triage({ emails, onOpen, onReply }) {
const fresh = emails.filter((m) => !m.read && m.ai);
const tasks = [], events = [], replies = [];
fresh.forEach((m) => {
m.ai.tasks.forEach((t) => tasks.push({ ...t, src: PPL[m.from].name, m }));
m.ai.events.forEach((e) => events.push({ ...e, src: PPL[m.from].name, m }));
if (m.ai.replies.length) replies.push({ src: PPL[m.from].name, subj: m.subject, r: m.ai.replies[0], m });
});
return (
아리 정리 · 오늘 아침
새 메일 {fresh.length}통을 읽고 할 일을 정리해뒀어요
세 계정에 새로 온 메일을 분석했어요. 중요도 순으로 보면, 분기 전략 미팅과 온보딩 시안 피드백이 오늘 가장 급해요. 아래에서 바로 작업·일정으로 옮기거나 답장을 보낼 수 있어요.
{tasks.length}할 일 제안
{events.length}일정 감지
{replies.length}답장 초안
{tasks.length === 0 &&
정리할 할 일이 없어요
}
{tasks.map((t, i) => (
onOpen(t.m.id)} style={{ cursor: "pointer" }}>
{t.src}님 메일에서
{t.text}
{t.project}
{t.due}
))}
{events.length === 0 &&
감지된 일정이 없어요
}
{events.map((e, i) => (
onOpen(e.m.id)} style={{ cursor: "pointer" }}>
{e.src}님 메일에서
{e.title}
{e.date} ({e.day})
{e.time}
))}
답장 초안 준비됨{replies.length}
{replies.length === 0 &&
추천할 답장이 없어요
}
{replies.map((rp, i) => (
onReply(rp.m, rp.r)} style={{ cursor: "pointer" }}>
{rp.src}님께 · {rp.subj}
{rp.r.tone}
“{rp.r.preview}”
))}
);
}
/* ================= 작성 (Compose) ================= */
const AI_DRAFTS = {
default: "안녕하세요,\n\n메일 잘 받았습니다. 내용 확인하고 빠른 시일 내 회신드리겠습니다.\n\n감사합니다.\n지우 드림",
};
function Compose({ init, onClose, onSend }) {
const [from, setFrom] = useState(init.from || "work");
const [to, setTo] = useState(init.to || "");
const [subject, setSubject] = useState(init.subject || "");
const [body, setBody] = useState(init.body || "");
const [busy, setBusy] = useState(false);
const a = ACC[from];
const cycleFrom = () => {
const ids = MD.accounts.map((x) => x.id);
setFrom(ids[(ids.indexOf(from) + 1) % ids.length]);
};
const aiAction = (label, fn) => {
setBusy(true);
setTimeout(() => { setBody(fn(body)); setBusy(false); }, 650);
};
useEffect(() => {
const h = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h);
}, [onClose]);
return (
<>
>
);
}
/* ================= 앱 ================= */
function App() {
const [theme, setTheme] = useState(() => load("theme", "light"));
const [folder, setFolder] = useState(() => load("folder", "smart"));
const [acctFilter, setAcctFilter] = useState(() => load("acct", null));
const [q, setQ] = useState("");
const [selId, setSelId] = useState(null);
const [reads, setReads] = useState(() => load("reads", {}));
const [stars, setStars] = useState(() => load("stars", {}));
const [archived, setArchived] = useState(() => load("archived", {}));
const [added, setAdded] = useState(() => load("added", {}));
const [compose, setCompose] = useState(null);
const [toasts, setToasts] = useState([]);
const [syncing, setSyncing] = useState(false);
const shellRef = useRef(null);
const tid = useRef(0);
useEffect(() => { document.documentElement.setAttribute("data-theme", theme); save("theme", theme); }, [theme]);
useEffect(() => save("folder", folder), [folder]);
useEffect(() => save("acct", acctFilter), [acctFilter]);
useEffect(() => save("reads", reads), [reads]);
useEffect(() => save("stars", stars), [stars]);
useEffect(() => save("archived", archived), [archived]);
useEffect(() => save("added", added), [added]);
useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); return () => cancelAnimationFrame(id); }, []);
/* 메일에 read/star/archive 상태 머지 */
const emails = useMemo(() => MD.emails.map((m) => ({
...m,
read: reads[m.id] !== undefined ? reads[m.id] : m.read,
starred: stars[m.id] !== undefined ? stars[m.id] : m.starred,
})), [reads, stars]);
const toast = (msg, tone) => {
const id = ++tid.current;
setToasts((t) => [...t, { id, msg, tone }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
};
const onStar = (id) => setStars((s) => ({ ...s, [id]: !(s[id] !== undefined ? s[id] : MD.emails.find((m) => m.id === id).starred) }));
const onArchive = (id) => { setArchived((a) => ({ ...a, [id]: true })); setSelId(null); toast("보관함으로 옮겼어요", "green"); };
const openMail = (id) => { setSelId(id); setReads((r) => ({ ...r, [id]: true })); };
const doAdd = (key, kind, label) => {
if (added[key]) return;
setAdded((a) => ({ ...a, [key]: true }));
const dest = kind === "작업" ? "작업" : kind === "일정" ? "일정" : "프로젝트";
toast(kind === "프로젝트" ? `‘${label}’에 정리했어요` : `${dest}에 추가했어요`, kind === "일정" ? "coral" : kind === "프로젝트" ? "violet" : "blue");
};
const addAll = (m) => {
const next = { ...added };
m.ai.tasks.forEach((_, i) => next["t:" + m.id + ":" + i] = true);
m.ai.events.forEach((_, i) => next["e:" + m.id + ":" + i] = true);
if (m.ai.file) next["f:" + m.id] = true;
setAdded(next);
toast("아리가 준비한 작업을 모두 적용했어요", "green");
};
const onReply = (m, r) => {
const p = PPL[m.from];
setCompose({
from: m.account, to: `${p.name} <${p.email}>`,
subject: "Re: " + m.subject,
body: r ? r.body : "",
});
};
const onSend = () => { setCompose(null); toast("메일을 보냈어요", "green"); };
/* 필터링 */
const visible = emails.filter((m) => {
if (archived[m.id] && folder !== "archive") return false;
if (acctFilter && m.account !== acctFilter) return false;
if (folder === "archive" && !archived[m.id]) return false;
if (folder === "starred" && !m.starred) return false;
if (q.trim()) {
const s = (m.subject + " " + PPL[m.from].name + " " + m.preview).toLowerCase();
if (!s.includes(q.trim().toLowerCase())) return false;
}
return true;
});
const sel = visible.find((m) => m.id === selId) || emails.find((m) => m.id === selId);
const unreadCount = emails.filter((m) => !m.read && !archived[m.id]).length;
const smartCount = emails.filter((m) => !m.read && m.ai && !archived[m.id]).length;
const folderMeta = { smart: "아리 정리", inbox: "받은편지함", starred: "별표", sent: "보낸편지함", drafts: "임시보관함", archive: "보관함" };
const isSent = folder === "sent", isDrafts = folder === "drafts";
const accCount = (id) => emails.filter((m) => m.account === id && !m.read && !archived[m.id]).length;
return (
{/* 상단바 */}
{/* 사이드바 */}
{/* 목록 */}
{isSent && MD.sent.map((m) => (
toast("보낸 메일 보기", "blue")}>
{PPL.me.initial}
받는 사람: {m.to}{m.time}
{m.subject}
{m.preview}
))}
{isDrafts && MD.drafts.map((m) => (
setCompose({ from: m.account, to: m.to, subject: m.subject.replace(" (작성 중)", "") })}>
임시보관{m.time}
{m.subject}
{m.preview}
))}
{!isSent && !isDrafts && visible.map((m) => (
openMail(m.id)} onStar={onStar} />
))}
{!isSent && !isDrafts && visible.length === 0 && (
메일이 없어요
이 보기에 표시할 메일이 없습니다.
)}
{/* 리딩 / 트리아지 */}
{folder === "smart" && !sel
?
!archived[m.id])} onOpen={openMail} onReply={onReply} />
: }
{compose &&
setCompose(null)} onSend={onSend} />}
{toasts.map((t) => (
{t.msg}
))}
);
}
ReactDOM.createRoot(document.getElementById("root")).render(
);