"use client"; import { AlertTriangleIcon, BellIcon, CheckCheckIcon, CheckCircle2Icon, InfoIcon, RotateCcwIcon, XIcon, type LucideIcon, } from "lucide-react"; import { AnimatePresence, motion, type PanInfo, useReducedMotion } from "motion/react"; import { type KeyboardEvent, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type NoticeTone = "info" | "success" | "warning" | "destructive"; export interface Notice { id: string; title: string; body?: string; time?: string; tone?: NoticeTone; action?: string; } export interface CardPileProps { notices: Notice[]; visible?: number; onDismiss?: (notice: Notice) => void; onAction?: (notice: Notice) => void; className?: string; } const toneStyle: Record = { info: { icon: InfoIcon, chip: "bg-info/12 text-info-foreground" }, success: { icon: CheckCircle2Icon, chip: "bg-success/12 text-success-foreground" }, warning: { icon: AlertTriangleIcon, chip: "bg-warning/14 text-warning-foreground" }, destructive: { icon: AlertTriangleIcon, chip: "bg-destructive/10 text-destructive-foreground" }, }; const SWIPE = 90; const press = "transition-[scale,background-color,box-shadow] duration-150 ease-out active:scale-[0.97]"; export function CardPile({ notices, visible = 3, onDismiss, onAction, className }: CardPileProps) { const reduce = useReducedMotion(); const [queue, setQueue] = useState(notices); const [exitDir, setExitDir] = useState<1 | -1>(1); // Exiting cards keep stale closures; always act on the live queue. const queueRef = useRef(queue); queueRef.current = queue; const stackRef = useRef(null); const dismiss = (dir: 1 | -1 = 1) => { const [front, next] = queueRef.current; if (!front) return; const hadFocus = stackRef.current?.contains(document.activeElement) ?? false; queueRef.current = queueRef.current.slice(1); setExitDir(dir); setQueue((q) => q.filter((n) => n.id !== front.id)); onDismiss?.(front); if (hadFocus && next) { requestAnimationFrame(() => stackRef.current?.querySelector(`[data-notice="${next.id}"]`)?.focus()); } }; const onDragEnd = (_: unknown, info: PanInfo) => { if (Math.abs(info.offset.x) > SWIPE || Math.abs(info.velocity.x) > 600) dismiss(info.offset.x > 0 ? 1 : -1); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Delete" || e.key === "Backspace" || e.key === "ArrowRight") { e.preventDefault(); dismiss(1); } else if (e.key === "ArrowLeft") { e.preventDefault(); dismiss(-1); } }; const stack = queue.slice(0, visible + 1); return (

Notifications

{queue.length ? `${queue.length} unread` : "All read"}
{stack .map((n, i) => { const front = i === 0; const hidden = i >= visible; const tone = toneStyle[n.tone ?? "info"]; const Icon = tone.icon; return ( ); }) .reverse()} {queue.length === 0 && (

You're all caught up

)}
); }