Files
gitlab-instance-0a899031_cn…/app/components/NotificationBell.tsx
2026-06-06 07:49:47 -05:00

170 lines
6.1 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Link } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
interface NotificationPreview {
id: string;
title: string;
body: string;
category: string;
icon?: string;
read: boolean;
muted?: boolean;
created?: string;
actionUrl?: string;
}
const CATEGORY_LABELS: Record<string, string> = {
system: "系统",
meetup: "活动",
gig: "赏金",
match: "匹配",
service: "服务",
community: "社区",
city: "城市",
};
function formatTime(value?: string): string {
if (!value) return "刚刚";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "刚刚";
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return `${date.getMonth() + 1}${date.getDate()}`;
}
export default function NotificationBell() {
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(0);
const [items, setItems] = useState<NotificationPreview[]>([]);
const ref = useRef<HTMLDivElement>(null);
const load = () => {
apiFetch<{ items: NotificationPreview[]; unread: number }>("/api/notifications?status=active&muted=exclude")
.then((data) => {
setItems((data.items || []).slice(0, 5));
setUnread(Number(data.unread || 0));
})
.catch(() => {
setItems([]);
setUnread(0);
});
};
useEffect(() => {
load();
const timer = window.setInterval(load, 30000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!open) return;
const onClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
const markAllRead = async () => {
await apiFetch("/api/notifications/mark-all-read", { method: "POST" });
load();
};
const markRead = (id: string) => {
apiFetch(`/api/notifications/${id}/read`, { method: "POST" })
.then(load)
.catch(() => undefined);
};
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label="站内消息"
title="站内消息"
>
<span className="text-lg">🔔</span>
{unread > 0 && (
<span className="absolute right-1.5 top-1.5 flex min-w-5 items-center justify-center rounded-full bg-[#ff4d4f] px-1 text-[10px] font-bold leading-5 text-white shadow-sm">
{unread > 99 ? "99+" : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-[360px] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900 sm:w-[420px]">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<div>
<p className="font-semibold text-gray-950 dark:text-gray-50"></p>
<p className="text-xs text-gray-400">{unread} </p>
</div>
<button
type="button"
onClick={markAllRead}
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10"
>
</button>
</div>
<div className="max-h-[420px] overflow-y-auto">
{items.map((item) => (
<Link
key={item.id}
href={item.actionUrl || "/messages"}
onClick={() => {
markRead(item.id);
setOpen(false);
}}
className="block border-b border-gray-100 px-4 py-3 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800"
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#ff4d4f]/10 text-lg">
{item.icon || "🔔"}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className={`line-clamp-1 text-sm font-semibold ${item.read ? "text-gray-600 dark:text-gray-300" : "text-gray-950 dark:text-gray-50"}`}>
{item.title}
</p>
{!item.read && <span className="h-2 w-2 shrink-0 rounded-full bg-[#ff4d4f]" />}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{item.body}</p>
<p className="mt-1 text-[11px] text-gray-400">
{CATEGORY_LABELS[item.category] || item.category} · {formatTime(item.created)}
</p>
</div>
</div>
</Link>
))}
</div>
{items.length === 0 && (
<div className="px-4 py-10 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100 text-2xl dark:bg-gray-800">📭</div>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400"></p>
</div>
)}
<Link
href="/messages"
onClick={() => setOpen(false)}
className="block bg-gray-50 px-4 py-3 text-center text-sm font-semibold text-gray-800 hover:bg-gray-100 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-800"
>
</Link>
</div>
)}
</div>
);
}