|
|
// frontend/components/notify/NotifyClient.tsx — 알림 페이지 오케스트레이터 (SubRail 3뷰)
|
|
|
"use client";
|
|
|
import { useState } from "react";
|
|
|
import { Icon } from "@/components/Icon";
|
|
|
import { SubRail, type RailItem } from "@/components/SubRail";
|
|
|
import { useNotify } from "@/lib/hooks/useNotify";
|
|
|
import { notifyApi } from "@/lib/notify/api";
|
|
|
import type { Notification, NotifyBucket } from "@/lib/types";
|
|
|
import { TriageStream } from "./TriageStream";
|
|
|
import { GuardPanel } from "./GuardPanel";
|
|
|
import { SenderRules } from "./SenderRules";
|
|
|
|
|
|
const HEAD: Record<string, { title: string; em: string }> = {
|
|
|
triage: { title: "알림", em: "중요한 것만 울려요" },
|
|
|
guard: { title: "집중 보호", em: "조용히, 그래도 놓치지 않게" },
|
|
|
senders: { title: "발신자 규칙", em: "누구는 바로, 누구는 모아서" },
|
|
|
};
|
|
|
|
|
|
export function NotifyClient() {
|
|
|
const [view, setView] = useState<string>("triage");
|
|
|
const [tab, setTab] = useState<NotifyBucket>("now");
|
|
|
const { triage, guard, digests, senders, error, mutate, mGuard } = useNotify();
|
|
|
|
|
|
if (error && !triage) {
|
|
|
return (
|
|
|
<div className="notifypage" data-screen-label="알림">
|
|
|
<div className="dash-error" role="alert">
|
|
|
<p>알림을 불러오지 못했어요.</p>
|
|
|
<button className="mini-cta" onClick={() => mutate()}>
|
|
|
<Icon name="swap" /> 다시 시도
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (!triage) {
|
|
|
return (
|
|
|
<div className="notifypage" data-screen-label="알림">
|
|
|
<div className="pagehead">
|
|
|
<div>
|
|
|
<h1 className="ph-title">알림</h1>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const counts = triage.counts;
|
|
|
const stats = triage.stats;
|
|
|
const h = HEAD[view];
|
|
|
|
|
|
const NT_RAIL: RailItem[] = [
|
|
|
{ id: "triage", icon: "bell", label: "트리아지", dot: counts.now > 0 },
|
|
|
{ sep: true },
|
|
|
{ id: "guard", icon: "shield", label: "집중 보호 · 묶음" },
|
|
|
{ id: "senders", icon: "users", label: "발신자 규칙" },
|
|
|
];
|
|
|
|
|
|
const onLater = async (it: Notification) => {
|
|
|
// 낙관적: now 에서 제거 + later 맨 앞에 추가(why=내가 미룸/muted)
|
|
|
mutate(
|
|
|
(cur) =>
|
|
|
cur
|
|
|
? {
|
|
|
...cur,
|
|
|
now: cur.now.filter((x) => x.id !== it.id),
|
|
|
later: [
|
|
|
{ ...it, bucket: "later", why: "내가 미룸", tone: "muted", act: "" },
|
|
|
...cur.later,
|
|
|
],
|
|
|
counts: { ...cur.counts, now: cur.counts.now - 1, later: cur.counts.later + 1 },
|
|
|
}
|
|
|
: cur,
|
|
|
false,
|
|
|
);
|
|
|
try {
|
|
|
await notifyApi.reclassify(it.id, "later");
|
|
|
} finally {
|
|
|
mutate();
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const onUndo = async (it: Notification) => {
|
|
|
// 낙관적: held 에서 제거 + now 맨 앞에 추가(why=되돌림/amber)
|
|
|
mutate(
|
|
|
(cur) =>
|
|
|
cur
|
|
|
? {
|
|
|
...cur,
|
|
|
held: cur.held.filter((x) => x.id !== it.id),
|
|
|
now: [{ ...it, bucket: "now", why: "되돌림", tone: "amber" }, ...cur.now],
|
|
|
counts: { ...cur.counts, held: cur.counts.held - 1, now: cur.counts.now + 1 },
|
|
|
}
|
|
|
: cur,
|
|
|
false,
|
|
|
);
|
|
|
try {
|
|
|
await notifyApi.undo(it.id);
|
|
|
} finally {
|
|
|
mutate();
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const onToggleGuard = async () => {
|
|
|
if (!guard) return;
|
|
|
const next = !guard.on;
|
|
|
mGuard({ ...guard, on: next }, false); // 낙관적
|
|
|
try {
|
|
|
await notifyApi.setGuard(next);
|
|
|
} finally {
|
|
|
mGuard();
|
|
|
}
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<div className="notifypage" data-screen-label="알림">
|
|
|
<div className="pagehead">
|
|
|
<div>
|
|
|
<div className="ph-eyebrow">
|
|
|
<span>오늘 {stats.total}건 도착</span>
|
|
|
<span className="sep" />
|
|
|
<span>직접 본 건 {stats.seen}건</span>
|
|
|
<span className="sep" />
|
|
|
<span>{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">
|
|
|
<TriageStream
|
|
|
triage={triage}
|
|
|
tab={tab}
|
|
|
setTab={setTab}
|
|
|
onLater={onLater}
|
|
|
onUndo={onUndo}
|
|
|
/>
|
|
|
|
|
|
<section className="card sp2">
|
|
|
<div className="ch">
|
|
|
<div className="ico">
|
|
|
<Icon name="spark" />
|
|
|
</div>
|
|
|
<div className="htext">
|
|
|
<h3>오늘의 알림 요약</h3>
|
|
|
<div className="sub">아리가 정리한 하루</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="nt-sumgrid">
|
|
|
<div className="nt-sumcell">
|
|
|
<b>{stats.total}</b>
|
|
|
<span>도착</span>
|
|
|
</div>
|
|
|
<div className="nt-sumcell hi">
|
|
|
<b>{counts.now}</b>
|
|
|
<span>지금 볼 것</span>
|
|
|
</div>
|
|
|
<div className="nt-sumcell">
|
|
|
<b>{counts.held}</b>
|
|
|
<span>아리가 처리</span>
|
|
|
</div>
|
|
|
<div className="nt-sumcell">
|
|
|
<b>{stats.filtered}</b>
|
|
|
<span>걸러냄</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div className="sd-note">
|
|
|
<Icon name="spark" />
|
|
|
‘지금 볼 것’만 알림으로 울리고, 나머지는 묶음 배달 시간에 모아서 보여드려요.
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{view === "guard" && guard && digests && (
|
|
|
<GuardPanel guard={guard} digests={digests} onToggle={onToggleGuard} />
|
|
|
)}
|
|
|
|
|
|
{view === "senders" && senders && <SenderRules senders={senders} />}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|