- Add /notifications inbox with mark-read and mark-all-read actions - Respect mutedAll/mutedTypes preferences when creating notifications - Add View all link and Discord request label to the notifications bell
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { BellIcon } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { cn } from "@/lib/utils";
|
|
import { notificationLabel } from "@/lib/notifications";
|
|
import {
|
|
markAllNotificationsRead,
|
|
markNotificationsRead,
|
|
} from "@/app/(frontend)/notifications/actions";
|
|
|
|
interface NotificationItem {
|
|
id: number;
|
|
type?: string | null;
|
|
title: string;
|
|
message?: string | null;
|
|
link?: string | null;
|
|
read: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
function timeAgo(iso: string): string {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
if (minutes < 1) return "just now";
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
const POLL_MS = 30_000;
|
|
|
|
export function NotificationsBell() {
|
|
const router = useRouter();
|
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
|
|
|
const fetchNotifications = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/user-notifications?limit=12&sort=-createdAt", {
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) throw new Error("Failed to fetch");
|
|
const data = (await res.json()) as { docs?: NotificationItem[] };
|
|
setNotifications(data.docs ?? []);
|
|
setError(false);
|
|
} catch {
|
|
setError(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void fetchNotifications();
|
|
intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS);
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [fetchNotifications]);
|
|
|
|
async function handleItemClick(item: NotificationItem) {
|
|
if (!item.read) {
|
|
await markNotificationsRead([item.id]);
|
|
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
|
|
}
|
|
if (item.link) {
|
|
router.push(item.link);
|
|
setOpen(false);
|
|
}
|
|
}
|
|
|
|
async function handleMarkAll() {
|
|
if (unreadCount === 0) return;
|
|
await markAllNotificationsRead();
|
|
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
|
<BellIcon className="size-5" />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-semibold text-white">
|
|
{unreadCount > 9 ? "9+" : unreadCount}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-80 sm:w-96">
|
|
<div className="flex items-center justify-between pr-1">
|
|
<DropdownMenuLabel className="pb-1">Notifications</DropdownMenuLabel>
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={() => void handleMarkAll()}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Mark all read
|
|
</button>
|
|
)}
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{loading ? (
|
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading...</p>
|
|
) : error ? (
|
|
<p className="px-3 py-6 text-center text-sm text-red-400">
|
|
Could not load notifications.
|
|
</p>
|
|
) : notifications.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
No notifications yet.
|
|
</p>
|
|
) : (
|
|
notifications.map((item) => (
|
|
<DropdownMenuItem
|
|
key={item.id}
|
|
className={cn(
|
|
"flex flex-col items-start gap-1 py-2.5",
|
|
!item.read && "bg-accent/50",
|
|
)}
|
|
onClick={() => void handleItemClick(item)}
|
|
>
|
|
<span className="flex w-full items-center justify-between gap-2">
|
|
<span className="text-sm font-medium">
|
|
{!item.read && (
|
|
<span className="mr-1.5 inline-block size-1.5 rounded-full bg-blue-500 align-middle" />
|
|
)}
|
|
{item.title}
|
|
</span>
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
{timeAgo(item.createdAt)}
|
|
</span>
|
|
</span>
|
|
{item.message && (
|
|
<span className="w-full text-xs text-muted-foreground">{item.message}</span>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground/70">
|
|
{notificationLabel(item.type)}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))
|
|
)}
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/notifications" className="justify-center text-sm font-medium">
|
|
View all
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|