|
|
/* 아리 — 알림 트리아지 페이지
|
|
|
"중요한 것만 울려요" — 지금/나중에/아리가 처리 3단 분류 + 집중 보호 + 발신자 규칙 */
|
|
|
const { useState } = React;
|
|
|
const I = window.AriShell.Icon;
|
|
|
const Topbar = window.AriShell.Topbar;
|
|
|
const SubRail = window.AriShell.SubRail;
|
|
|
const N = window.AriNotify;
|
|
|
|
|
|
/* 페이지 전용 아이콘 (각 JSX는 독립 스코프 — 필요한 path만 로컬 정의) */
|
|
|
const NT_P = {
|
|
|
msg: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
|
|
|
card: "M2 5h20v14H2z|M2 10h20",
|
|
|
};
|
|
|
function NI({ d }) {
|
|
|
return (
|
|
|
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
|
|
|
{NT_P[d].split("|").map((s, i) => <path key={i} d={s} />)}
|
|
|
</svg>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const NT_SRC = {
|
|
|
mail: { local: false, d: "mail", tone: "var(--blue)" },
|
|
|
cal: { local: false, d: "cal", tone: "var(--violet)" },
|
|
|
chat: { local: true, d: "msg", tone: "var(--violet)" },
|
|
|
fin: { local: true, d: "card", tone: "var(--green)" },
|
|
|
app: { local: false, d: "grid", tone: "var(--ink-2)" },
|
|
|
};
|
|
|
function SrcIcon({ src }) {
|
|
|
const s = NT_SRC[src] || NT_SRC.app;
|
|
|
return (
|
|
|
<span className="nt-ic" style={{ color: s.tone }}>
|
|
|
{s.local ? <NI d={s.d} /> : <I d={s.d} />}
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function WhyChip({ why, tone }) {
|
|
|
const t = tone === "muted" ? "var(--muted)" : "var(--" + tone + ")";
|
|
|
return (
|
|
|
<span className="nt-why" style={{ color: t, background: "color-mix(in oklab, " + t + " 13%, transparent)" }}>
|
|
|
{why}
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const NT_TABS = [
|
|
|
{ id: "now", label: "지금" },
|
|
|
{ id: "later", label: "나중에" },
|
|
|
{ id: "held", label: "아리가 처리" },
|
|
|
];
|
|
|
const NT_CAP = {
|
|
|
now: "직접 봐야 하는 알림만 남겼어요. 나머지는 아리가 들고 있을게요.",
|
|
|
later: "급하지 않은 알림은 묶어서 다이제스트 시간에 전달돼요.",
|
|
|
held: "아리가 이미 처리한 알림이에요. 전부 되돌릴 수 있어요.",
|
|
|
};
|
|
|
|
|
|
function App() {
|
|
|
const [view, setView] = useState("triage");
|
|
|
const [tab, setTab] = useState("now");
|
|
|
const [buckets, setBuckets] = useState({ now: N.buckets.now, later: N.buckets.later, held: N.buckets.held });
|
|
|
const [guardOn, setGuardOn] = useState(N.guard.on);
|
|
|
|
|
|
const counts = {
|
|
|
now: buckets.now.length,
|
|
|
later: buckets.later.length + N.buckets.laterMore,
|
|
|
held: buckets.held.length + N.buckets.heldMore,
|
|
|
};
|
|
|
|
|
|
const toLater = (it) =>
|
|
|
setBuckets({
|
|
|
...buckets,
|
|
|
now: buckets.now.filter((x) => x.id !== it.id),
|
|
|
later: [{ ...it, why: "내가 미룸", tone: "muted" }, ...buckets.later],
|
|
|
});
|
|
|
|
|
|
const undo = (it) =>
|
|
|
setBuckets({
|
|
|
...buckets,
|
|
|
held: buckets.held.filter((x) => x.id !== it.id),
|
|
|
now: [{ ...it, why: "되돌림", tone: "amber" }, ...buckets.now],
|
|
|
});
|
|
|
|
|
|
const list = buckets[tab];
|
|
|
const more = tab === "later" ? N.buckets.laterMore : tab === "held" ? N.buckets.heldMore : 0;
|
|
|
|
|
|
const NT_RAIL = [
|
|
|
{ id: "triage", icon: "bell", label: "트리아지", dot: counts.now > 0 },
|
|
|
{ sep: true },
|
|
|
{ id: "guard", icon: "shield", label: "집중 보호 · 묶음" },
|
|
|
{ id: "senders", icon: "users", label: "발신자 규칙" },
|
|
|
];
|
|
|
const HEAD = {
|
|
|
triage: { title: "알림", em: "중요한 것만 울려요" },
|
|
|
guard: { title: "집중 보호", em: "조용히, 그래도 놓치지 않게" },
|
|
|
senders: { title: "발신자 규칙", em: "누구는 바로, 누구는 모아서" },
|
|
|
};
|
|
|
const h = HEAD[view];
|
|
|
|
|
|
return (
|
|
|
<div className="dash" data-screen-label="알림">
|
|
|
<Topbar current="noti" />
|
|
|
|
|
|
<div className="pagehead">
|
|
|
<div>
|
|
|
<div className="ph-eyebrow">
|
|
|
<span>오늘 {N.stats.total}건 도착</span>
|
|
|
<span className="sep"></span>
|
|
|
<span>직접 본 건 {N.stats.seen}건</span>
|
|
|
<span className="sep"></span>
|
|
|
<span>{N.stats.filtered}는 아리가 걸렀어요</span>
|
|
|
</div>
|
|
|
<h1 className="ph-title">{h.title} <em>{h.em}</em></h1>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="work">
|
|
|
<SubRail items={NT_RAIL} active={view} onPick={setView} />
|
|
|
|
|
|
{view === "triage" && (
|
|
|
<div className="board">
|
|
|
{/* 트리아지 스트림 */}
|
|
|
<section className="card sp2 r2">
|
|
|
<div className="ch">
|
|
|
<div className="ico"><I d="bell" /></div>
|
|
|
<div className="htext"><h3>트리아지</h3><div className="sub">아리가 분류한 오늘의 알림</div></div>
|
|
|
</div>
|
|
|
<div className="nt-tabs">
|
|
|
{NT_TABS.map((t) => (
|
|
|
<button key={t.id} className={"nt-tab" + (tab === t.id ? " on" : "")} onClick={() => setTab(t.id)}>
|
|
|
{t.label}<span className="n">{counts[t.id]}</span>
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
<div className="nt-cap">{NT_CAP[tab]}</div>
|
|
|
<div className="nt-list">
|
|
|
{list.map((it) => (
|
|
|
<div className="nt-row" key={it.id}>
|
|
|
<SrcIcon src={it.src} />
|
|
|
<div className="nt-body">
|
|
|
<div className="nt-app">
|
|
|
<span>{it.app}</span>
|
|
|
<WhyChip why={it.why} tone={it.tone} />
|
|
|
</div>
|
|
|
<div className="nt-title">{it.title}</div>
|
|
|
<div className="nt-sum">{it.sum}</div>
|
|
|
{tab === "now" && (
|
|
|
<div className="nt-acts">
|
|
|
{it.act && <button className="nt-act">{it.act}</button>}
|
|
|
<button className="nt-act ghost" onClick={() => toLater(it)}>나중에</button>
|
|
|
</div>
|
|
|
)}
|
|
|
{tab === "held" && (
|
|
|
<div className="nt-acts">
|
|
|
<button className="nt-act ghost" onClick={() => undo(it)}>되돌리기</button>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
<span className="nt-time">{it.time}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
{list.length === 0 && (
|
|
|
<div className="nt-empty"><I d="tick" />비어 있어요. 오늘은 조용한 날이네요.</div>
|
|
|
)}
|
|
|
</div>
|
|
|
{more > 0 && <div className="nt-more">비슷한 알림 +{more}건 — 아리가 묶어뒀어요</div>}
|
|
|
</section>
|
|
|
|
|
|
{/* 오늘 요약 */}
|
|
|
<section className="card sp2">
|
|
|
<div className="ch">
|
|
|
<div className="ico"><I d="spark" /></div>
|
|
|
<div className="htext"><h3>오늘의 알림 요약</h3><div className="sub">아리가 정리한 하루</div></div>
|
|
|
</div>
|
|
|
<div className="nt-sumgrid">
|
|
|
<div className="nt-sumcell"><b>{N.stats.total}</b><span>도착</span></div>
|
|
|
<div className="nt-sumcell hi"><b>{counts.now}</b><span>지금 볼 것</span></div>
|
|
|
<div className="nt-sumcell"><b>{N.buckets.held.length + N.buckets.heldMore}</b><span>아리가 처리</span></div>
|
|
|
<div className="nt-sumcell"><b>{N.stats.filtered}</b><span>걸러냄</span></div>
|
|
|
</div>
|
|
|
<div className="sd-note"><I d="spark" />‘지금 볼 것’만 알림으로 울리고, 나머지는 묶음 배달 시간에 모아서 보여드려요.</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{view === "guard" && (
|
|
|
<div className="board">
|
|
|
<section className="card sp2">
|
|
|
<div className="ch">
|
|
|
<div className="ico"><I d="shield" /></div>
|
|
|
<div className="htext"><h3>집중 보호 · 묶음 배달</h3><div className="sub">조용히, 그래도 놓치지 않게</div></div>
|
|
|
</div>
|
|
|
<div className={"gd-hold" + (guardOn ? "" : " off")}>
|
|
|
<div className="gd-ic"><I d="target" /></div>
|
|
|
<div className="gd-body">
|
|
|
{guardOn ? (
|
|
|
<React.Fragment>
|
|
|
<div className="gd-t">딥 워크 보호 중 — {N.guard.until}까지</div>
|
|
|
<div className="gd-s">알림 {N.guard.held}건 보류 중 · 끝나면 한 번에 전달해요</div>
|
|
|
</React.Fragment>
|
|
|
) : (
|
|
|
<React.Fragment>
|
|
|
<div className="gd-t">보호 꺼짐</div>
|
|
|
<div className="gd-s">모든 알림이 바로 도착해요 · 집중 모드를 켜면 다시 막아드려요</div>
|
|
|
</React.Fragment>
|
|
|
)}
|
|
|
</div>
|
|
|
<button className={"sw" + (guardOn ? " on" : "")} onClick={() => setGuardOn(!guardOn)} aria-label="집중 보호 켜기/끄기"></button>
|
|
|
</div>
|
|
|
<div className="gd-quiet">
|
|
|
<I d="moon" /><span>조용한 시간</span><b>{N.guard.quiet}</b>
|
|
|
<span className="hint">긴급·VIP만 통과</span>
|
|
|
</div>
|
|
|
<div className="gd-sec">묶음 배달 시간</div>
|
|
|
<div className="dg2-list">
|
|
|
{N.digests.map((d) => (
|
|
|
<div className={"dg2-row" + (d.done ? " done" : "")} key={d.time}>
|
|
|
<span className="dg2-time">{d.time}</span>
|
|
|
<div className="dg2-body">
|
|
|
<div className="dg2-name">{d.name}{d.done ? " — 전달됨" : ""}</div>
|
|
|
<div className="dg2-desc">{d.desc}</div>
|
|
|
</div>
|
|
|
<span className="dg2-n">{d.count}건</span>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{view === "senders" && (
|
|
|
<div className="board">
|
|
|
<section className="card sp2">
|
|
|
<div className="ch">
|
|
|
<div className="ico"><I d="users" /></div>
|
|
|
<div className="htext"><h3>발신자 규칙</h3><div className="sub">누구는 바로, 누구는 모아서</div></div>
|
|
|
<span className="count">{N.senders.length}개</span>
|
|
|
</div>
|
|
|
<div className="sd-list">
|
|
|
{N.senders.map((s) => (
|
|
|
<div className="sd-row" key={s.who}>
|
|
|
<span className="sd-dot" style={{ background: "var(--" + s.tone + ")" }}></span>
|
|
|
<div className="sd-who">{s.who}<span>{s.from}</span></div>
|
|
|
<span className="sd-rule">{s.rule}</span>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
<div className="sd-note">
|
|
|
<I d="spark" />‘나중에’로 미룬 발신자가 쌓이면 아리가 새 규칙을 제안해요. 자동화 페이지에서 언제든 고칠 수 있어요.
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|