This commit is contained in:
eric
2026-06-06 07:49:47 -05:00
parent 41493c35ca
commit f5c5819bdf
9 changed files with 1398 additions and 7 deletions

View File

@@ -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" })}

View 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>
);
}

View File

@@ -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: "🌐" },