'd'
This commit is contained in:
@@ -9,6 +9,7 @@ import AuthModal from "./AuthModal";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import SiteMenu from "./SiteMenu";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
export default function NavbarComponent() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -153,6 +154,7 @@ export default function NavbarComponent() {
|
||||
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
|
||||
</div>
|
||||
) : null}
|
||||
<NotificationBell />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
@@ -204,6 +206,18 @@ export default function NavbarComponent() {
|
||||
<span>{tNav(link.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/messages"
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
|
||||
isActive("/messages")
|
||||
? "text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<span className="text-lg">🔔</span>
|
||||
<span>{tNav("messages")}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
|
||||
169
app/components/NotificationBell.tsx
Normal file
169
app/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export default memo(function SiteMenu({
|
||||
{
|
||||
titleKey: "community",
|
||||
items: [
|
||||
{ labelKey: "messages", href: "/messages", icon: "🔔", new: true },
|
||||
{ labelKey: "chat", href: "/join", icon: "💬" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
|
||||
{ labelKey: "membersMap", href: "/map", icon: "🌐" },
|
||||
|
||||
Reference in New Issue
Block a user