170 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|