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.

648 lines
34 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 아리 — 메일(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 (
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={w || 1.9} strokeLinecap="round" strokeLinejoin="round">
{P[d].split("|").map((s, i) => <path key={i} d={s} />)}
</svg>
);
}
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 (
<div className={"mrow" + (on ? " on" : "") + (m.read ? "" : " unread")} onClick={onClick}>
<div className="mr-ava" style={{ background: p.c }}>
{p.initial}
<span className="acc-pip" style={{ background: `var(--${a.tone})` }} />
</div>
<div className="mr-body">
<div className="mr-top">
<span className="mr-from"><b>{p.name}</b></span>
<span className="mr-time">{m.time}</span>
</div>
<div className="mr-subj">{m.subject}</div>
<div className="mr-prev">{m.preview}</div>
{m.ai && (m.ai.priority === "높음" || aiCount(m) > 0) && (
<div className="mr-tags">
{m.ai.priority === "높음" && <span className="ai-pill prio"><I d="flag" />중요</span>}
{m.ai.tasks.length > 0 && <span className="ai-pill task"><I d="check" /> {m.ai.tasks.length}</span>}
{m.ai.events.length > 0 && <span className="ai-pill event"><I d="cal" />일정 {m.ai.events.length}</span>}
{m.ai.replies.length > 0 && <span className="ai-pill reply"><I d="reply" />답장 추천</span>}
</div>
)}
</div>
<div className="mr-flags">
<button className={"mr-star" + (m.starred ? " on" : "")} onClick={(e) => { e.stopPropagation(); onStar(m.id); }} aria-label="별표">
<I d="star" w={m.starred ? 0 : 1.9} />
</button>
{m.hasAttach && <span className="mr-attach"><I d="clip" /></span>}
</div>
</div>
);
}
/* ================= 제안 항목 (할 일 / 일정) ================= */
function Sug({ kind, text, meta, added, onAdd }) {
return (
<div className={"sug " + kind + (added ? " added" : "")}>
<div className="sug-ico"><I d={kind === "task" ? "check" : "cal"} /></div>
<div className="sug-main">
<div className="sug-text">{text}</div>
<div className="sug-meta">{meta}</div>
</div>
<button className={"sug-add" + (added ? " done" : "")} onClick={onAdd}>
<I d={added ? "tick" : "plus"} w={added ? 3 : 1.9} />{added ? "추가됨" : (kind === "task" ? "작업" : "일정")}
</button>
</div>
);
}
/* ================= 아리 분석 패널 ================= */
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 (
<div className="ari-panel">
<div className="ap-head">
<div className="ap-avatar"><I d="spark" /></div>
<div className="ap-h">
<b>아리가 분석했어요</b>
<span>{ai.category} · 메일을 읽고 필요한 일을 정리했어요</span>
</div>
<span className={"ap-prio " + ai.priority}>{ai.priority}</span>
</div>
<div className="ap-summary" dangerouslySetInnerHTML={{ __html: "<b>요약 — </b>" + ai.summary }} />
{ai.tasks.length > 0 && (
<div className="ap-section">
<div className="ap-sec-title"><I d="check" /> 일로 추가<span className="n">{ai.tasks.length}</span></div>
{ai.tasks.map((t, i) => {
const k = "t:" + m.id + ":" + i;
return (
<Sug key={k} kind="task" text={t.text} added={!!added[k]} onAdd={() => onAdd(k, "작업", t.text)}
meta={<>
<span className="chip2"><I d="folder" />{t.project}</span>
<span className="dotsep" />
<span className="chip2"><I d="cal" />{t.due}</span>
<span className="dotsep" />
<span className="chip2"><I d="flag" />{t.prio}</span>
</>} />
);
})}
</div>
)}
{ai.events.length > 0 && (
<div className="ap-section">
<div className="ap-sec-title"><I d="cal" />일정에 추가<span className="n">{ai.events.length}</span></div>
{ai.events.map((ev, i) => {
const k = "e:" + m.id + ":" + i;
return (
<Sug key={k} kind="event" text={ev.title} added={!!added[k]} onAdd={() => onAdd(k, "일정", ev.title)}
meta={<>
<span className="chip2"><I d="cal" />{ev.date} ({ev.day})</span>
<span className="dotsep" />
<span className="chip2"><I d="clock" />{ev.time}{ev.dur ? " · " + ev.dur : ""}</span>
{ev.place && <><span className="dotsep" /><span className="chip2">{ev.place}</span></>}
</>} />
);
})}
</div>
)}
{ai.replies.length > 0 && (
<div className="ap-section">
<div className="ap-sec-title"><I d="reply" />답장 추천<span className="n">{ai.replies.length} 초안</span></div>
<div className="replies">
{ai.replies.map((r, i) => (
<button className="rep" key={i} onClick={() => onReply(m, r)}>
<div className="rep-top"><span className="rep-tone">{r.tone}</span><span className="rep-go"><I d="arrow" /></span></div>
<div className="rep-prev">{r.preview}</div>
</button>
))}
</div>
</div>
)}
{ai.file && (() => {
const k = "f:" + m.id;
return (
<div className={"ap-file" + (added[k] ? " added" : "")}>
<div className="fi-ico"><I d="layers" /></div>
<div className="fi-main"><b>{ai.file.project} 정리</b><span>{ai.file.reason}</span></div>
<button className={"sug-add" + (added[k] ? " done" : "")} onClick={() => onAdd(k, "프로젝트", ai.file.project)}>
<I d={added[k] ? "tick" : "plus"} w={added[k] ? 3 : 1.9} />{added[k] ? "정리됨" : "정리"}
</button>
</div>
);
})()}
{keys.length > 1 && (
<div className="ap-allbar">
<span className="txt">아리가 준비한 {keys.length} 작업을 번에 적용할 있어요</span>
<button className={"ap-all-btn" + (allDone ? " done" : "")} onClick={() => onAddAll(m)}>
<I d={allDone ? "tick" : "zap"} w={allDone ? 3 : 1.9} />{allDone ? "모두 적용됨" : "모두 적용"}
</button>
</div>
)}
</div>
);
}
/* ================= 리딩 패널 ================= */
function Reader({ m, added, onAdd, onReply, onAddAll, onCompose, onArchive, onStar }) {
if (!m) {
return (
<div className="reader">
<div className="rd-empty">
<div className="e-ico"><I d="mail" /></div>
<h3>읽을 메일을 선택하세요</h3>
<p>메일을 열면 아리가 내용을 분석해 ·일정·답장을 미리 준비해 드려요.</p>
</div>
</div>
);
}
const p = PPL[m.from], a = ACC[m.account];
return (
<div className="reader">
<div className="rd-toolbar">
<button className="rd-tool" onClick={() => onReply(m, null)}><I d="reply" />답장</button>
<button className="rd-tool icon-only" aria-label="전체 답장"><I d="replyAll" /></button>
<button className="rd-tool icon-only" aria-label="전달"><I d="forward" /></button>
<div className="rd-spacer" />
<button className="rd-tool icon-only" aria-label="별표" onClick={() => onStar(m.id)}><I d="star" w={m.starred ? 0 : 1.9} /></button>
<button className="rd-tool icon-only" onClick={() => onArchive(m.id)} aria-label="보관"><I d="archive" /></button>
<button className="rd-tool icon-only danger" aria-label="삭제"><I d="trash" /></button>
</div>
<div className="rd-scroll">
<div className="rd-inner">
<h1 className="rd-subject">{m.subject}</h1>
<div className="rd-sender">
<div className="av-lg" style={{ background: p.c }}>{p.initial}</div>
<div className="who">
<b>{p.name}</b> {p.role && <span className="em">· {p.role}</span>}
<div className="em">{p.email}</div>
</div>
<div className="when">{m.date}</div>
</div>
<div className="rd-to">받는 사람: {m.to}</div>
<div className="rd-acctag"><span className="acc-dot" style={{ background: `var(--${a.tone})` }} />{a.name} · {a.email}</div>
<div className="rd-divider" />
<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) => (
<div className="att-chip" key={i}>
<I d="file" />
<div className="at-meta"><b>{at.name}</b><span>{at.size}</span></div>
</div>
))}
</div>
)}
{m.ai && <AriPanel m={m} added={added} onAdd={onAdd} onReply={onReply} onAddAll={onAddAll} />}
</div>
</div>
</div>
);
}
/* ================= 아리 정리 (트리아지) ================= */
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 (
<div className="reader">
<div className="rd-scroll">
<div className="triage">
<div className="tr-hero">
<div className="ap-avatar"><I d="spark" /></div>
<div className="th-main">
<div className="th-eyebrow">아리 정리 · 오늘 아침</div>
<h2> 메일 {fresh.length}통을 읽고 일을 정리해뒀어요</h2>
<p> 계정에 새로 메일을 분석했어요. 중요도 순으로 보면, <b>분기 전략 미팅</b> <b> </b> . · .</p>
<div className="tr-stats">
<div className="tr-stat task"><I d="check" /><b>{tasks.length}</b><span> </span></div>
<div className="tr-stat event"><I d="cal" /><b>{events.length}</b><span> </span></div>
<div className="tr-stat reply"><I d="reply" /><b>{replies.length}</b><span> </span></div>
</div>
</div>
</div>
<div className="tr-cols">
<div className="tr-card task">
<div className="tr-card-head"><div className="h-ico"><I d="check" /></div><b> </b><span className="n">{tasks.length}</span></div>
{tasks.length === 0 && <div className="tr-empty">정리할 일이 없어요</div>}
{tasks.map((t, i) => (
<div className="tr-item" key={i} onClick={() => onOpen(t.m.id)} style={{ cursor: "pointer" }}>
<div className="ti-src">{t.src} 메일에서</div>
<div className="ti-text">{t.text}</div>
<div className="ti-meta">
<span className="chip2"><I d="folder" />{t.project}</span>
<span className="chip2"><I d="cal" />{t.due}</span>
</div>
</div>
))}
</div>
<div className="tr-card event">
<div className="tr-card-head"><div className="h-ico"><I d="cal" /></div><b> </b><span className="n">{events.length}</span></div>
{events.length === 0 && <div className="tr-empty">감지된 일정이 없어요</div>}
{events.map((e, i) => (
<div className="tr-item" key={i} onClick={() => onOpen(e.m.id)} style={{ cursor: "pointer" }}>
<div className="ti-src">{e.src} 메일에서</div>
<div className="ti-text">{e.title}</div>
<div className="ti-meta">
<span className="chip2"><I d="cal" />{e.date} ({e.day})</span>
<span className="chip2"><I d="clock" />{e.time}</span>
</div>
</div>
))}
</div>
<div className="tr-card reply">
<div className="tr-card-head"><div className="h-ico"><I d="reply" /></div><b> </b><span className="n">{replies.length}</span></div>
{replies.length === 0 && <div className="tr-empty">추천할 답장이 없어요</div>}
{replies.map((rp, i) => (
<div className="tr-item" key={i} onClick={() => onReply(rp.m, rp.r)} style={{ cursor: "pointer" }}>
<div className="ti-src">{rp.src}님께 · {rp.subj}</div>
<div className="ti-text" style={{ fontWeight: 600 }}>{rp.r.tone}</div>
<div className="ti-meta"><span className="chip2"><I d="pen" />{rp.r.preview}</span></div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
/* ================= 작성 (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 (
<>
<div className="cp-backdrop" onClick={onClose} />
<aside className="compose" role="dialog" aria-label="메일 작성">
<div className="cp-head">
<div className="h-ic"><I d="pen" /></div>
<b>{init.subject ? "답장 작성" : "새 메일"}</b>
<button className="cp-close" onClick={onClose} aria-label="닫기"><I d="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(--${a.tone})` }} />{a.name} · {a.email}<I d="chev" />
</button>
</div>
<div className="cp-field"><label>받는</label><input value={to} onChange={(e) => setTo(e.target.value)} placeholder="이름 또는 이메일" /></div>
<div className="cp-field"><label>제목</label><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="제목" /></div>
<div className="cp-ai">
<div className="cp-ai-head"><div className="a-ic"><I d="spark" /></div><b> </b></div>
<div className="cp-ai-actions">
<button className="cp-ai-btn" disabled={busy} onClick={() => aiAction("초안", () => AI_DRAFTS.default)}><I d="wand" />초안 작성</button>
<button className="cp-ai-btn" disabled={busy || !body} onClick={() => aiAction("정중", (b) => "안녕하세요,\n\n" + b.replace(/^안녕하세요,?\n*/, "") + "\n\n늘 신경 써 주셔서 감사합니다.\n지우 드림")}><I d="wand" /> 정중하게</button>
<button className="cp-ai-btn" disabled={busy || !body} onClick={() => aiAction("짧게", (b) => b.split("\n").filter(Boolean).slice(0, 2).join("\n"))}><I d="wand" />짧게</button>
<button className="cp-ai-btn" disabled={busy || !body} onClick={() => aiAction("정리", (b) => b.trim())}><I d="wand" />다듬기</button>
</div>
</div>
<textarea className="cp-text" value={busy ? "아리가 작성 중…" : body} onChange={(e) => setBody(e.target.value)}
placeholder="내용을 입력하거나, 위에서 ‘초안 작성’을 눌러 아리에게 맡겨보세요…" />
</div>
<div className="cp-foot">
<button className="cp-send" onClick={() => onSend({ from, to, subject })}><I d="send" />보내기</button>
<button className="cp-tool" aria-label="첨부"><I d="clip" /></button>
<div className="cp-spacer" />
<button className="cp-discard" onClick={onClose}>임시저장 닫기</button>
</div>
</aside>
</>
);
}
/* ================= 앱 ================= */
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 (
<div className="mpage" ref={shellRef}>
{/* 상단바 */}
<header className="topbar">
<div className="brand">
<div className="brand-mark"><I d="spark" /></div>
<div className="brand-tag"><b>아리</b><span>AI LIFE OS</span></div>
</div>
<nav className="mainnav">
{MAIN.map((m) => (
<a key={m.id} href={m.href} className={m.id === "mail" ? "active" : ""}>
<I d={m.icon} />{m.label}{m.id === "mail" ? (unreadCount > 0 && <span className="badge">{unreadCount}</span>) : (m.badge && <span className="badge">{m.badge}</span>)}
</a>
))}
</nav>
<div className="top-actions">
<button className="t-btn" aria-label="검색"><I d="search" /></button>
<button className="t-btn dot" aria-label="알림"><I d="bell" /></button>
<button className="t-btn" aria-label="테마" onClick={() => setTheme(theme === "light" ? "dark" : "light")}><I d={theme === "light" ? "moon" : "sun"} /></button>
<div className="t-ava">{PPL.me.initial}</div>
</div>
</header>
<div className="mwork">
{/* 사이드바 */}
<aside className="subnav">
<button className="compose-btn" onClick={() => setCompose({ from: acctFilter || "work" })}><I d="pen" /> 메일</button>
<div className="sn-scroll">
<button className={"sn-smart" + (folder === "smart" ? " on" : "")} onClick={() => { setFolder("smart"); setSelId(null); }}>
<div className="sp-ico"><I d="spark" /></div>
<div className="sp-txt"><b>아리 정리</b><span> </span></div>
{smartCount > 0 && <span className="sp-cnt">{smartCount}</span>}
</button>
{MD.folders.map((f) => {
const cnt = f.id === "inbox" ? unreadCount : f.id === "starred" ? emails.filter((m) => m.starred && !archived[m.id]).length : 0;
return (
<button key={f.id} className={"fl-row" + (folder === f.id ? " on" : "")} onClick={() => { setFolder(f.id); setSelId(null); }}>
<I d={f.icon} /><span className="fl-name">{f.name}</span>
{cnt > 0 && <span className="fl-cnt">{cnt}</span>}
</button>
);
})}
<div className="sn-label">계정 <button className="add" aria-label="계정 추가" onClick={() => toast("계정 연결 화면을 여는 중…", "blue")}><I d="plus" /></button></div>
<button className={"acc-row" + (acctFilter === null ? " on" : "")} onClick={() => setAcctFilter(null)}>
<span className="acc-dot" style={{ background: "var(--fill)" }} />
<div className="acc-info"><b>전체 받은편지함</b><span>3 </span></div>
{unreadCount > 0 && <span className="fl-cnt">{unreadCount}</span>}
</button>
{MD.accounts.map((a) => (
<button key={a.id} className={"acc-row" + (acctFilter === a.id ? " on" : "")} onClick={() => setAcctFilter(acctFilter === a.id ? null : a.id)}>
<span className="acc-dot" style={{ background: `var(--${a.tone})` }} />
<div className="acc-info"><b>{a.name}</b><span>{a.email}</span></div>
{accCount(a.id) > 0 && <span className="fl-cnt">{accCount(a.id)}</span>}
</button>
))}
<button className="acc-add" onClick={() => toast("계정 연결 화면을 여는 중…", "blue")}><I d="plus" />계정 수입하기</button>
</div>
<div className="sn-foot">
<div className="av-lg">{PPL.me.initial}</div>
<div className="txt"><b>지우님</b><span>Pro · 3 </span></div>
</div>
</aside>
{/* 목록 */}
<div className={"mlist" + (sel ? " has-sel" : "")}>
<div className="ml-head">
<div className="ml-titlerow">
<span className="ml-title">{folderMeta[folder]}</span>
<span className="ml-meta">{acctFilter ? ACC[acctFilter].name + " · " : ""}{visible.length}</span>
<button className={"ml-sync" + (syncing ? " spin" : "")} onClick={() => { setSyncing(true); setTimeout(() => { setSyncing(false); toast("새 메일을 가져왔어요", "blue"); }, 1100); }}>
<I d="refresh" />{syncing ? "동기화 중" : "동기화"}
</button>
</div>
<div className="ml-search"><I d="search" /><input value={q} onChange={(e) => setQ(e.target.value)} placeholder="메일 검색…" /></div>
</div>
<div className="ml-scroll">
{isSent && MD.sent.map((m) => (
<div className={"mrow"} key={m.id} onClick={() => toast("보낸 메일 보기", "blue")}>
<div className="mr-ava" style={{ background: PPL.me.c }}>{PPL.me.initial}<span className="acc-pip" style={{ background: `var(--${ACC[m.account].tone})` }} /></div>
<div className="mr-body"><div className="mr-top"><span className="mr-from">받는 사람: <b>{m.to}</b></span><span className="mr-time">{m.time}</span></div><div className="mr-subj">{m.subject}</div><div className="mr-prev">{m.preview}</div></div>
</div>
))}
{isDrafts && MD.drafts.map((m) => (
<div className={"mrow"} key={m.id} onClick={() => setCompose({ from: m.account, to: m.to, subject: m.subject.replace(" (작성 중)", "") })}>
<div className="mr-ava" style={{ background: "var(--muted)" }}><I d="pen" /></div>
<div className="mr-body"><div className="mr-top"><span className="mr-from" style={{ color: "var(--coral)" }}>임시보관</span><span className="mr-time">{m.time}</span></div><div className="mr-subj">{m.subject}</div><div className="mr-prev">{m.preview}</div></div>
</div>
))}
{!isSent && !isDrafts && visible.map((m) => (
<MailRow key={m.id} m={m} on={selId === m.id} onClick={() => openMail(m.id)} onStar={onStar} />
))}
{!isSent && !isDrafts && visible.length === 0 && (
<div className="rd-empty" style={{ height: "auto", padding: "60px 30px" }}>
<div className="e-ico"><I d="inbox" /></div>
<h3>메일이 없어요</h3>
<p> 보기에 표시할 메일이 없습니다.</p>
</div>
)}
</div>
</div>
{/* 리딩 / 트리아지 */}
{folder === "smart" && !sel
? <Triage emails={emails.filter((m) => !archived[m.id])} onOpen={openMail} onReply={onReply} />
: <Reader m={sel} added={added} onAdd={doAdd} onReply={onReply} onAddAll={addAll} onCompose={setCompose} onArchive={onArchive} onStar={onStar} />}
</div>
{compose && <Compose init={compose} onClose={() => setCompose(null)} onSend={onSend} />}
<div className="toast-wrap">
{toasts.map((t) => (
<div className="toast" key={t.id}>
<span className="t-dot" style={{ background: t.tone === "coral" ? "var(--coral)" : t.tone === "violet" ? "var(--violet)" : t.tone === "green" ? "var(--green)" : "var(--blue)" }} />
{t.msg}
</div>
))}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);