|
|
/* 아리 — 메일(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 />);
|