echos-ocr/src/components/notifications/notification-center.tsx

203 lines
6.8 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import Link from "next/link";
import {
Bell,
CheckCircle2,
AlertCircle,
RefreshCw,
Mail,
Monitor,
LayoutGrid,
Globe,
Clock,
CheckCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
type Notification = {
id: string;
createdAt: string;
read: boolean;
type: string;
title: string;
message: string;
cardId: string | null;
actionUrl: string | null;
};
export function NotificationCenter() {
const [open, setOpen] = React.useState(false);
const [notifications, setNotifications] = React.useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const fetchUnreadCount = React.useCallback(async () => {
try {
const res = await fetch("/api/notifications?unreadOnly=true&limit=1");
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unreadCount ?? 0);
}
} catch { /* ignore */ }
}, []);
React.useEffect(() => {
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 30_000);
return () => clearInterval(interval);
}, [fetchUnreadCount]);
const fetchNotifications = React.useCallback(async () => {
try {
const res = await fetch("/api/notifications?limit=30");
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications ?? []);
setUnreadCount(data.unreadCount ?? 0);
}
} catch { /* ignore */ }
}, []);
React.useEffect(() => {
if (open) fetchNotifications();
}, [open, fetchNotifications]);
const markAllRead = async () => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark_all_read" }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
} catch { /* ignore */ }
};
const markRead = async (id: string) => {
try {
await fetch("/api/notifications", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark_read", id }),
});
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
setUnreadCount((c) => Math.max(0, c - 1));
} catch { /* ignore */ }
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="icon" className="relative size-9 rounded-xl" />
}
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex size-4.5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 p-0">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2.5">
<span className="text-sm font-medium">Notifications</span>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={markAllRead}>
<CheckCheck className="mr-1 size-3" /> Mark all read
</Button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Bell className="size-8 mb-2 opacity-20" />
<p className="text-sm">No notifications</p>
</div>
) : (
notifications.map((n) => (
<NotificationItem key={n.id} notification={n} onRead={markRead} onClose={() => setOpen(false)} />
))
)}
</div>
</PopoverContent>
</Popover>
);
}
function NotificationItem({
notification: n,
onRead,
onClose,
}: {
notification: Notification;
onRead: (id: string) => void;
onClose: () => void;
}) {
const iconMap: Record<string, { icon: React.ReactNode; color: string }> = {
ocr_complete: { icon: <CheckCircle2 className="size-3.5" />, color: "text-emerald-600 bg-emerald-500/10" },
ocr_error: { icon: <AlertCircle className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
card_needs_review: { icon: <RefreshCw className="size-3.5" />, color: "text-amber-600 bg-amber-500/10" },
monday_sync: { icon: <LayoutGrid className="size-3.5" />, color: "text-blue-600 bg-blue-500/10" },
monday_error: { icon: <LayoutGrid className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
webhook_error: { icon: <Globe className="size-3.5" />, color: "text-red-600 bg-red-500/10" },
email_watcher: { icon: <Mail className="size-3.5" />, color: "text-purple-600 bg-purple-500/10" },
system: { icon: <Monitor className="size-3.5" />, color: "text-muted-foreground bg-muted" },
};
const { icon, color } = iconMap[n.type] ?? iconMap.system;
const handleClick = () => {
if (!n.read) onRead(n.id);
if (n.actionUrl) onClose();
};
const content = (
<div
className={cn(
"flex gap-3 px-3 py-2.5 border-b border-border/30 transition-colors hover:bg-muted/50 cursor-pointer",
!n.read && "bg-primary/[0.03]"
)}
onClick={handleClick}
>
<div className={cn("flex size-7 shrink-0 items-center justify-center rounded-full mt-0.5", color)}>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={cn("text-xs font-medium truncate", !n.read && "text-foreground")}>{n.title}</span>
{!n.read && <span className="size-1.5 shrink-0 rounded-full bg-primary" />}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{n.message}</p>
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1 mt-1">
<Clock className="size-2.5" />
{formatTimeAgo(n.createdAt)}
</span>
</div>
</div>
);
if (n.actionUrl) {
return <Link href={n.actionUrl}>{content}</Link>;
}
return content;
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}