"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import {
Archive,
AtSign,
Bell,
BellOff,
Check,
CheckCheck,
CreditCard,
GitBranch,
ListFilter,
MailOpen,
MessageSquare,
Pause,
Play,
Search,
Settings2,
UserPlus,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type NotificationType = "mention" | "comment" | "deploy" | "billing" | "invite" | "assign";
export type InboxNotification = {
id: string;
type: NotificationType;
actor: string;
color: string;
action: string;
target: string;
preview?: string;
time: string;
/** 0 = today, 1 = yesterday, 2+ = earlier */
day: number;
read: boolean;
};
export interface NotificationsCenterProps {
brand?: string;
initial?: InboxNotification[];
/** Items that "arrive" in real time (demo). Pass [] to disable. */
incoming?: Omit<InboxNotification, "time" | "day" | "read">[];
/** Milliseconds between simulated arrivals. */
arrivalEvery?: number;
onPreferences?: () => void;
onOpen?: (n: InboxNotification) => void;
className?: string;
}
const TYPE_META: Record<NotificationType, { label: string; icon: React.ComponentType<{ className?: string }>; tone: string }> = {
mention: { label: "Mentions", icon: AtSign, tone: "bg-violet-500" },
comment: { label: "Comments", icon: MessageSquare, tone: "bg-sky-500" },
deploy: { label: "Deployments", icon: GitBranch, tone: "bg-emerald-500" },
billing: { label: "Billing", icon: CreditCard, tone: "bg-amber-500" },
invite: { label: "Invites", icon: UserPlus, tone: "bg-pink-500" },
assign: { label: "Assignments", icon: Check, tone: "bg-indigo-500" },
};
const DEFAULT_ITEMS: InboxNotification[] = [
{ id: "n1", type: "mention", actor: "Maya Okafor", color: "#8b5cf6", action: "mentioned you in", target: "Q4 launch plan", preview: "@nora can you sanity-check the rollout dates before Friday?", time: "12m", day: 0, read: false },
{ id: "n2", type: "deploy", actor: "Build bot", color: "#10b981", action: "deployed", target: "web-app #482 to production", time: "38m", day: 0, read: false },
{ id: "n3", type: "comment", actor: "Jonas Weber", color: "#0ea5e9", action: "replied on", target: "Onboarding checklist v2", preview: "Agree — let's drop step 4 and merge it into the welcome email.", time: "1h", day: 0, read: false },
{ id: "n4", type: "invite", actor: "Priya Raman", color: "#ec4899", action: "invited you to", target: "Design Systems workspace", time: "3h", day: 0, read: true },
{ id: "n5", type: "assign", actor: "Leo Martins", color: "#6366f1", action: "assigned you", target: "BUG-2193 · Checkout button overlaps on iOS", time: "Yesterday", day: 1, read: false },
{ id: "n6", type: "mention", actor: "Ada Kim", color: "#f97316", action: "mentioned you in", target: "Weekly sync notes", preview: "Thanks @nora for unblocking the migration 🙌", time: "Yesterday", day: 1, read: true },
{ id: "n7", type: "billing", actor: "Billing", color: "#f59e0b", action: "your invoice is ready for", target: "September 2026 · $240.00", time: "Sep 12", day: 3, read: true },
{ id: "n8", type: "comment", actor: "Sam Rivera", color: "#14b8a6", action: "commented on", target: "Pricing page copy", preview: "Could we A/B the headline? I have two variants ready.", time: "Sep 10", day: 5, read: true },
];
const DEFAULT_INCOMING: Omit<InboxNotification, "time" | "day" | "read">[] = [
{ id: "live1", type: "mention", actor: "Jonas Weber", color: "#0ea5e9", action: "mentioned you in", target: "Incident review", preview: "@nora the postmortem draft is ready for your notes." },
{ id: "live2", type: "deploy", actor: "Build bot", color: "#10b981", action: "finished preview for", target: "feat/billing-v2 #483" },
{ id: "live3", type: "comment", actor: "Maya Okafor", color: "#8b5cf6", action: "replied on", target: "Q4 launch plan", preview: "Dates look good. Shipping it!" },
{ id: "live4", type: "assign", actor: "Leo Martins", color: "#6366f1", action: "assigned you", target: "DOC-88 · Update API changelog" },
];
const focusRing = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const initials = (n: string) =>
n
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
const dayLabel = (d: number) => (d === 0 ? "Today" : d === 1 ? "Yesterday" : "Earlier");
type Tab = "all" | "mentions" | "unread";
function useClickOutside(ref: React.RefObject<HTMLElement | null>, open: boolean, close: () => void) {
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) close();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [ref, open, close]);
}
export function NotificationsCenter({
brand = "Northwind",
initial = DEFAULT_ITEMS,
incoming = DEFAULT_INCOMING,
arrivalEvery = 6000,
onPreferences,
onOpen,
className,
}: NotificationsCenterProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const [items, setItems] = React.useState(initial);
const [tab, setTab] = React.useState<Tab>("all");
const [types, setTypes] = React.useState<NotificationType[]>([]);
const [filterOpen, setFilterOpen] = React.useState(false);
const [popOpen, setPopOpen] = React.useState(false);
const [live, setLive] = React.useState(true);
const [queue, setQueue] = React.useState(incoming);
const [fresh, setFresh] = React.useState<string[]>([]);
const [archived, setArchived] = React.useState<{ item: InboxNotification; index: number } | null>(null);
const [announce, setAnnounce] = React.useState("");
const filterRef = React.useRef<HTMLDivElement>(null);
const popRef = React.useRef<HTMLDivElement>(null);
const closeFilter = React.useCallback(() => setFilterOpen(false), []);
const closePop = React.useCallback(() => setPopOpen(false), []);
useClickOutside(filterRef, filterOpen, closeFilter);
useClickOutside(popRef, popOpen, closePop);
// Simulated real-time arrivals.
React.useEffect(() => {
if (!live || !queue.length) return;
const t = setTimeout(() => {
const [next, ...rest] = queue;
const n: InboxNotification = { ...next, id: `${next.id}-${items.length}`, time: "now", day: 0, read: false };
setItems((l) => [n, ...l]);
setQueue(rest);
setFresh((f) => [...f, n.id]);
setAnnounce(`New notification: ${n.actor} ${n.action} ${n.target}`);
setTimeout(() => setFresh((f) => f.filter((x) => x !== n.id)), 2600);
}, arrivalEvery);
return () => clearTimeout(t);
}, [live, queue, arrivalEvery, items.length]);
React.useEffect(() => {
if (!archived) return;
const t = setTimeout(() => setArchived(null), 5000);
return () => clearTimeout(t);
}, [archived]);
const unread = items.filter((i) => !i.read).length;
const counts: Record<Tab, number> = { all: items.length, mentions: items.filter((i) => i.type === "mention").length, unread };
const visible = items.filter((i) => (tab === "mentions" ? i.type === "mention" : tab === "unread" ? !i.read : true)).filter((i) => !types.length || types.includes(i.type));
const groups = [0, 1, 2].map((g) => ({ g, list: visible.filter((i) => (g === 2 ? i.day >= 2 : i.day === g)) })).filter((x) => x.list.length);
const setRead = (id: string, read: boolean) => setItems((l) => l.map((i) => (i.id === id ? { ...i, read } : i)));
const markAll = () => {
setItems((l) => l.map((i) => ({ ...i, read: true })));
setAnnounce("All notifications marked as read");
};
const archive = (id: string) => {
const index = items.findIndex((i) => i.id === id);
if (index < 0) return;
setArchived({ item: items[index], index });
setItems((l) => l.filter((i) => i.id !== id));
};
const undo = () => {
if (!archived) return;
setItems((l) => [...l.slice(0, archived.index), archived.item, ...l.slice(archived.index)]);
setArchived(null);
};
const open = (n: InboxNotification) => {
setRead(n.id, true);
onOpen?.(n);
};
const tabs: { id: Tab; label: string }[] = [
{ id: "all", label: "All" },
{ id: "mentions", label: "Mentions" },
{ id: "unread", label: "Unread" },
];
return (
<section className={cn("relative flex h-[780px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{/* App bar */}
<header className="relative z-30 flex h-14 shrink-0 items-center gap-3 border-b bg-background/80 px-4 backdrop-blur sm:px-6">
<span className="grid size-7 place-items-center rounded-lg bg-foreground text-xs font-bold text-background">{brand[0]}</span>
<span className="hidden text-sm font-semibold sm:inline">{brand}</span>
<div className="relative ml-2 hidden max-w-xs flex-1 md:block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input aria-label="Search" placeholder="Search…" className="h-8 w-full rounded-md border bg-muted/40 pl-8 pr-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20" />
</div>
<div className="ml-auto flex items-center gap-2" ref={popRef}>
<button
type="button"
onClick={() => setPopOpen((o) => !o)}
aria-expanded={popOpen}
aria-haspopup="dialog"
aria-label={`Notifications, ${unread} unread`}
className={cn("relative grid size-9 place-items-center rounded-lg transition hover:bg-accent", focusRing, popOpen && "bg-accent")}
>
<motion.span key={items.length} animate={reduce || !fresh.length ? undefined : { rotate: [0, 16, -12, 8, 0] }} transition={{ duration: 0.6 }} style={{ originY: 0.1 }}>
<Bell className="size-[18px]" />
</motion.span>
<AnimatePresence>
{unread > 0 && (
<motion.span
key={unread}
initial={{ scale: 0.4 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 18 }}
className="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-rose-500 px-1 text-[10px] font-semibold tabular-nums text-white ring-2 ring-background"
>
{unread > 9 ? "9+" : unread}
</motion.span>
)}
</AnimatePresence>
</button>
<span className="grid size-8 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-fuchsia-500 text-xs font-semibold text-white">NL</span>
<AnimatePresence>
{popOpen && (
<motion.div
role="dialog"
aria-label="Notifications preview"
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.16 }}
style={{ originX: 1, originY: 0 }}
className="absolute right-3 top-12 w-[min(380px,calc(100vw-24px))] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-2xl shadow-black/15 sm:right-6"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<p className="text-sm font-semibold">Notifications</p>
<button type="button" onClick={markAll} disabled={!unread} className={cn("rounded text-xs font-medium text-primary disabled:text-muted-foreground", focusRing)}>
Mark all read
</button>
</div>
<ul className="max-h-80 divide-y overflow-y-auto">
{items.slice(0, 5).map((n) => (
<li key={n.id}>
<button type="button" onClick={() => open(n)} className={cn("flex w-full gap-3 px-4 py-3 text-left transition hover:bg-accent/60", "focus-visible:bg-accent focus-visible:outline-none")}>
<Avatar n={n} size="sm" />
<span className="min-w-0 flex-1 text-sm">
<span className="line-clamp-2">
<span className="font-medium">{n.actor}</span> <span className="text-muted-foreground">{n.action}</span> <span className="font-medium">{n.target}</span>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{n.time}</span>
</span>
{!n.read && <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" aria-label="Unread" />}
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => {
setPopOpen(false);
setTab("all");
}}
className={cn("block w-full border-t bg-muted/40 py-2.5 text-center text-sm font-medium transition hover:bg-accent", "focus-visible:bg-accent focus-visible:outline-none")}
>
View all notifications
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
{/* Inbox page */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-4 pb-20 pt-6 sm:px-6 sm:pt-8">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Inbox</h1>
<button
type="button"
onClick={() => setLive((l) => !l)}
aria-pressed={live}
className={cn("inline-flex h-6 items-center gap-1.5 rounded-full border px-2 text-xs font-medium transition hover:bg-accent", focusRing, live ? "text-emerald-700 dark:text-emerald-300" : "text-muted-foreground")}
title={live ? "Pause live updates" : "Resume live updates"}
>
{live ? (
<span className="relative flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-70" />
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
</span>
) : (
<Pause className="size-3" />
)}
{live ? "Live" : "Paused"}
{live ? <Pause className="size-3 opacity-60" /> : <Play className="size-3 opacity-60" />}
</button>
<div className="ml-auto flex items-center gap-1.5">
<div className="relative" ref={filterRef}>
<button
type="button"
onClick={() => setFilterOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={filterOpen}
className={cn("inline-flex h-8 items-center gap-1.5 rounded-lg border bg-background px-2.5 text-sm font-medium shadow-xs transition hover:bg-accent", focusRing)}
>
<ListFilter className="size-3.5" /> Filter
{types.length > 0 && <span className="grid size-4 place-items-center rounded-full bg-primary text-[10px] text-primary-foreground">{types.length}</span>}
</button>
<AnimatePresence>
{filterOpen && (
<motion.div
role="menu"
aria-label="Filter by type"
initial={{ opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.97 }}
transition={{ duration: 0.14 }}
style={{ originX: 1, originY: 0 }}
className="absolute right-0 top-10 z-20 w-52 rounded-xl border bg-popover p-1 text-popover-foreground shadow-xl"
>
{(Object.keys(TYPE_META) as NotificationType[]).map((t) => {
const on = types.includes(t);
const Icon = TYPE_META[t].icon;
return (
<button
key={t}
type="button"
role="menuitemcheckbox"
aria-checked={on}
onClick={() => setTypes((l) => (on ? l.filter((x) => x !== t) : [...l, t]))}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
>
<span className={cn("grid size-4 place-items-center rounded border", on && "border-primary bg-primary text-primary-foreground")}>{on && <Check className="size-3" strokeWidth={3} />}</span>
<Icon className="size-3.5 text-muted-foreground" />
{TYPE_META[t].label}
</button>
);
})}
{types.length > 0 && (
<button type="button" onClick={() => setTypes([])} className="mt-1 w-full rounded-md border-t px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-accent focus-visible:bg-accent focus-visible:outline-none">
Clear filters
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<button type="button" onClick={markAll} disabled={!unread} aria-label="Mark all read" className={cn("inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50", focusRing)}>
<CheckCheck className="size-4" /> <span className="hidden sm:inline">Mark all read</span>
</button>
<button type="button" onClick={onPreferences} aria-label="Notification preferences" className={cn("grid size-8 place-items-center rounded-lg text-muted-foreground transition hover:bg-accent hover:text-foreground", focusRing)}>
<Settings2 className="size-4" />
</button>
</div>
</div>
<div role="tablist" aria-label="Notification views" className="mt-5 flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={tab === t.id}
aria-controls={`${uid}-list`}
onClick={() => setTab(t.id)}
className={cn("relative -mb-px inline-flex h-10 items-center gap-2 px-3 text-sm font-medium transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring rounded-t-md", tab === t.id ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{t.label}
<span className={cn("rounded-full px-1.5 text-[11px] tabular-nums", tab === t.id ? "bg-foreground text-background" : "bg-muted text-muted-foreground")}>{counts[t.id]}</span>
{tab === t.id && <motion.span layoutId={`${uid}-tab`} className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-foreground" transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }} />}
</button>
))}
</div>
<div id={`${uid}-list`} role="tabpanel" className="mt-2">
<LayoutGroup>
<AnimatePresence initial={false}>
{groups.length === 0 && (
<motion.div key="empty" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex flex-col items-center py-20 text-center">
<span className="grid size-12 place-items-center rounded-full bg-muted">
<BellOff className="size-5 text-muted-foreground" />
</span>
<p className="mt-4 font-medium">You're all caught up</p>
<p className="mt-1 text-sm text-muted-foreground">{types.length ? "No notifications match these filters." : "New activity will show up here in real time."}</p>
</motion.div>
)}
{groups.map(({ g, list }) => (
<motion.section key={g} layout={!reduce} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} aria-labelledby={`${uid}-g${g}`}>
<h2 id={`${uid}-g${g}`} className="sticky top-0 z-10 bg-background/90 py-2.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur">
{dayLabel(g)}
</h2>
<ul className="space-y-1">
<AnimatePresence initial={false} mode="popLayout">
{list.map((n) => (
<Row key={n.id} n={n} fresh={fresh.includes(n.id)} reduce={!!reduce} onOpen={() => open(n)} onToggleRead={() => setRead(n.id, !n.read)} onArchive={() => archive(n.id)} />
))}
</AnimatePresence>
</ul>
</motion.section>
))}
</AnimatePresence>
</LayoutGroup>
</div>
</div>
</div>
{/* Undo toast */}
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex justify-center px-4" aria-live="polite">
<AnimatePresence>
{archived && (
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 16 }} className="pointer-events-auto flex items-center gap-3 rounded-xl bg-foreground py-2 pl-4 pr-2 text-sm text-background shadow-xl">
<Archive className="size-4 opacity-70" />
<span>Notification archived</span>
<button type="button" onClick={undo} className="rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-background">
Undo
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}
function Avatar({ n, size = "md" }: { n: InboxNotification; size?: "sm" | "md" }) {
const meta = TYPE_META[n.type];
const Icon = meta.icon;
return (
<span className={cn("relative shrink-0", size === "sm" ? "size-8" : "size-10")} aria-hidden>
<span className="grid size-full place-items-center rounded-full text-xs font-semibold text-white" style={{ background: `linear-gradient(135deg, ${n.color}, color-mix(in oklab, ${n.color} 60%, black))` }}>
{initials(n.actor)}
</span>
<span className={cn("absolute -bottom-0.5 -right-0.5 grid place-items-center rounded-full text-white ring-2 ring-background", meta.tone, size === "sm" ? "size-3.5" : "size-4")}>
<Icon className={size === "sm" ? "size-2" : "size-2.5"} />
</span>
</span>
);
}
const Row = React.forwardRef<HTMLLIElement, { n: InboxNotification; fresh: boolean; reduce: boolean; onOpen: () => void; onToggleRead: () => void; onArchive: () => void }>(function Row(
{ n, fresh, reduce, onOpen, onToggleRead, onArchive },
ref,
) {
const [invite, setInvite] = React.useState<"pending" | "accepted" | "declined">("pending");
return (
<motion.li
ref={ref}
layout={!reduce}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, x: 60, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className={cn("group relative rounded-xl border border-transparent transition-colors hover:border-border hover:bg-muted/40 focus-within:border-border focus-within:bg-muted/40", !n.read && "bg-primary/[0.035]")}
>
{fresh && !reduce && (
<motion.span aria-hidden className="pointer-events-none absolute inset-0 rounded-xl ring-2 ring-primary/50" initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: 2.4, ease: "easeOut" }} />
)}
<div className="flex gap-3 p-3">
<Avatar n={n} />
<div className="min-w-0 flex-1">
<button type="button" onClick={onOpen} className="block w-full rounded text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="block pr-16 sm:pr-20">
<span className="font-medium">{n.actor}</span> <span className="text-muted-foreground">{n.action}</span> <span className="font-medium">{n.target}</span>
</span>
{n.preview && <span className="mt-1.5 block rounded-lg border-l-2 border-border bg-muted/50 px-2.5 py-1.5 text-[13px] text-muted-foreground">{n.preview}</span>}
<span className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{fresh && <span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">New</span>}
{n.time === "now" ? "Just now" : n.time} · {TYPE_META[n.type].label.replace(/s$/, "")}
</span>
</button>
{n.type === "invite" && (
<div className="mt-2.5 flex items-center gap-2">
{invite === "pending" ? (
<>
<button type="button" onClick={() => setInvite("accepted")} className={cn("h-7 rounded-md bg-foreground px-3 text-xs font-medium text-background hover:opacity-90", focusRing)}>
Accept
</button>
<button type="button" onClick={() => setInvite("declined")} className={cn("h-7 rounded-md border bg-background px-3 text-xs font-medium hover:bg-accent", focusRing)}>
Decline
</button>
</>
) : (
<span className={cn("inline-flex items-center gap-1 text-xs font-medium", invite === "accepted" ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")} role="status">
{invite === "accepted" ? <Check className="size-3.5" /> : null}
{invite === "accepted" ? "Joined workspace" : "Invite declined"}
</span>
)}
</div>
)}
</div>
</div>
{!n.read && <span className="absolute right-3 top-4 size-2 rounded-full bg-primary transition-opacity group-hover:opacity-0 group-focus-within:opacity-0 max-sm:hidden" aria-label="Unread" />}
<div className="absolute right-2 top-2 flex gap-0.5 rounded-lg border bg-popover p-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 max-sm:opacity-100 max-sm:border-transparent max-sm:bg-transparent max-sm:shadow-none">
<button type="button" onClick={onToggleRead} aria-label={n.read ? "Mark as unread" : "Mark as read"} title={n.read ? "Mark as unread" : "Mark as read"} className="grid size-7 place-items-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{n.read ? <MailOpen className="size-3.5" /> : <Check className="size-3.5" />}
</button>
<button type="button" onClick={onArchive} aria-label="Archive" title="Archive" className="grid size-7 place-items-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Archive className="size-3.5" />
</button>
</div>
</motion.li>
);
});