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

@@ -0,0 +1,485 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Link } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
interface NotificationItem {
id: string;
title: string;
body: string;
category: string;
priority: "low" | "normal" | "high" | string;
actionLabel?: string;
actionUrl?: string;
entityType?: string;
entityId?: string;
icon?: string;
created?: string;
read: boolean;
archived: boolean;
pinned: boolean;
muted?: boolean;
metadata?: Record<string, unknown>;
}
interface PreferenceRecord {
channels: Record<string, boolean>;
categories: Record<string, boolean>;
quietHoursStart: string;
quietHoursEnd: string;
digest: string;
allowMarketing: boolean;
}
const CATEGORY_META: Record<string, { label: string; icon: string; color: string }> = {
all: { label: "全部", icon: "📬", color: "bg-gray-900 text-white" },
system: { label: "系统", icon: "🔔", color: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200" },
meetup: { label: "活动", icon: "🍹", color: "bg-orange-100 text-orange-700 dark:bg-orange-950/40 dark:text-orange-200" },
gig: { label: "赏金", icon: "💰", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200" },
match: { label: "匹配", icon: "💚", color: "bg-pink-100 text-pink-700 dark:bg-pink-950/40 dark:text-pink-200" },
service: { label: "服务", icon: "🧭", color: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200" },
community: { label: "社区", icon: "🔥", color: "bg-violet-100 text-violet-700 dark:bg-violet-950/40 dark:text-violet-200" },
city: { label: "城市", icon: "🏙️", color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-200" },
};
const STATUS_TABS = [
{ key: "active", label: "收件箱" },
{ key: "unread", label: "未读" },
{ key: "read", label: "已读" },
{ key: "archived", label: "归档" },
];
const DEFAULT_PREFERENCES: PreferenceRecord = {
channels: { in_app: true, email: false, wechat: false },
categories: {
system: true,
meetup: true,
gig: true,
match: true,
service: true,
community: true,
city: true,
},
quietHoursStart: "22:00",
quietHoursEnd: "08:00",
digest: "daily",
allowMarketing: false,
};
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()}`;
}
function priorityLabel(priority: string): string {
if (priority === "high") return "重要";
if (priority === "low") return "低优先级";
return "普通";
}
export default function MessagesPage() {
const [items, setItems] = useState<NotificationItem[]>([]);
const [unread, setUnread] = useState(0);
const [categories, setCategories] = useState<Record<string, number>>({});
const [mutedCount, setMutedCount] = useState(0);
const [category, setCategory] = useState("all");
const [status, setStatus] = useState("active");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preferences, setPreferences] = useState<PreferenceRecord>(DEFAULT_PREFERENCES);
const [notice, setNotice] = useState("");
const selected = useMemo(
() => items.find((item) => item.id === selectedId) || items[0],
[items, selectedId]
);
const loadNotifications = () => {
setLoading(true);
const params = new URLSearchParams({ category, status, q: query });
apiFetch<{ items: NotificationItem[]; unread: number; categories: Record<string, number>; muted: number }>(`/api/notifications?${params.toString()}`)
.then((data) => {
setItems(data.items || []);
setUnread(Number(data.unread || 0));
setCategories(data.categories || {});
setMutedCount(Number(data.muted || 0));
if (data.items?.length && !data.items.some((item) => item.id === selectedId)) {
setSelectedId(data.items[0].id);
}
})
.catch(() => {
setItems([]);
setUnread(0);
setCategories({});
setMutedCount(0);
})
.finally(() => setLoading(false));
};
useEffect(() => {
loadNotifications();
apiFetch<{ record: PreferenceRecord }>("/api/notifications/preferences")
.then((data) => setPreferences({ ...DEFAULT_PREFERENCES, ...(data.record || {}) }))
.catch(() => setPreferences(DEFAULT_PREFERENCES));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const timer = window.setTimeout(loadNotifications, 250);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [category, status, query]);
const mutateNotification = async (id: string, action: "read" | "unread" | "archive" | "restore" | "pin") => {
await apiFetch(`/api/notifications/${id}/${action}`, { method: "POST" });
loadNotifications();
};
const markAllRead = async () => {
const data = await apiFetch<{ count: number }>("/api/notifications/mark-all-read", { method: "POST" });
setNotice(`已标记 ${data.count} 条消息为已读`);
loadNotifications();
};
const savePreferences = async () => {
await apiFetch("/api/notifications/preferences", {
method: "PUT",
body: JSON.stringify(preferences),
});
setNotice("通知偏好已保存");
loadNotifications();
};
return (
<div className="min-h-screen bg-[#f6f7f9] dark:bg-gray-950">
<main className="mx-auto max-w-[1420px] px-4 py-8 sm:px-6 sm:py-10">
<header className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-semibold text-[#ff4d4f]">Message Center</p>
<h1 className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50 sm:text-4xl">
</h1>
<p className="mt-2 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
FastAPI / PocketBase
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={markAllRead}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
<Link
href="/settings/notifications"
className="rounded-xl bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-950"
>
</Link>
</div>
</header>
{notice && (
<div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{notice}
</div>
)}
<section className="mb-6 grid gap-4 md:grid-cols-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">{unread}</p>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">{items.length}</p>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">{mutedCount}</p>
</div>
<div className="rounded-2xl bg-gradient-to-br from-[#ff4d4f] to-[#ff7a45] p-5 text-white shadow-sm">
<p className="text-sm text-white/75"></p>
<p className="mt-2 text-xl font-bold">{preferences.digest === "instant" ? "实时提醒" : "每日摘要"}</p>
<p className="mt-1 text-xs text-white/75">{preferences.quietHoursStart} - {preferences.quietHoursEnd} </p>
</div>
</section>
<div className="grid gap-5 lg:grid-cols-[320px_minmax(0,1fr)_320px]">
<aside className="space-y-4">
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<label className="text-xs font-semibold uppercase tracking-wide text-gray-400"></label>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="标题、正文、城市、任务..."
className="mt-2 w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-900 outline-none focus:border-[#ff4d4f] focus:bg-white dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</div>
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<div className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400"></div>
{["all", "system", "meetup", "gig", "match", "service", "community", "city"].map((key) => {
const meta = CATEGORY_META[key] || CATEGORY_META.system;
const active = category === key;
return (
<button
key={key}
type="button"
onClick={() => setCategory(key)}
className={`mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${
active
? "bg-gray-950 text-white dark:bg-gray-100 dark:text-gray-950"
: "text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<span>{meta.icon}</span>
<span className="flex-1 font-medium">{meta.label}</span>
<span className={`rounded-full px-2 py-0.5 text-xs ${active ? "bg-white/20" : "bg-gray-100 dark:bg-gray-800"}`}>
{key === "all" ? Object.values(categories).reduce((sum, value) => sum + value, 0) : categories[key] || 0}
</span>
</button>
);
})}
</div>
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<div className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400"></div>
<div className="grid grid-cols-2 gap-2">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setStatus(tab.key)}
className={`rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
status === tab.key
? "bg-[#ff4d4f] text-white"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</aside>
<section className="min-h-[660px] overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4 dark:border-gray-800">
<div>
<h2 className="font-semibold text-gray-950 dark:text-gray-50"></h2>
<p className="text-xs text-gray-400">{loading ? "正在同步 PocketBase" : "最新消息在前,置顶优先"}</p>
</div>
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{items.length}
</span>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{items.map((item) => {
const meta = CATEGORY_META[item.category] || CATEGORY_META.system;
const active = selected?.id === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
className={`block w-full px-5 py-4 text-left transition-colors ${
active ? "bg-[#fff3f3] dark:bg-[#ff4d4f]/10" : "hover:bg-gray-50 dark:hover:bg-gray-800/60"
}`}
>
<div className="flex items-start gap-3">
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${meta.color}`}>
<span className="text-lg">{item.icon || meta.icon}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<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.pinned && "📌 "}{item.title}
</p>
{!item.read && <span className="mt-1 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>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-gray-400">
<span>{formatTime(item.created)}</span>
<span>·</span>
<span>{meta.label}</span>
<span>·</span>
<span>{priorityLabel(item.priority)}</span>
{item.muted && (
<>
<span>·</span>
<span className="text-amber-600 dark:text-amber-300"></span>
</>
)}
{item.archived && (
<>
<span>·</span>
<span></span>
</>
)}
</div>
</div>
</div>
</button>
);
})}
</div>
{items.length === 0 && (
<div className="flex min-h-[420px] flex-col items-center justify-center px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-gray-100 text-3xl dark:bg-gray-800">📭</div>
<h3 className="mt-4 font-semibold text-gray-900 dark:text-gray-100"></h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"></p>
</div>
)}
</section>
<aside className="space-y-4">
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
{selected ? (
<>
<div className="flex items-start justify-between gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#ff4d4f]/10 text-2xl">
{selected.icon || CATEGORY_META[selected.category]?.icon || "🔔"}
</div>
<span className={`rounded-full px-2 py-1 text-[11px] font-medium ${selected.priority === "high" ? "bg-[#ff4d4f] text-white" : "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-300"}`}>
{priorityLabel(selected.priority)}
</span>
</div>
<h2 className="mt-4 text-xl font-bold text-gray-950 dark:text-gray-50">{selected.title}</h2>
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-300">{selected.body}</p>
<div className="mt-4 rounded-2xl bg-gray-50 p-3 text-xs text-gray-500 dark:bg-gray-950 dark:text-gray-400">
<p>{CATEGORY_META[selected.category]?.label || selected.category}</p>
<p className="mt-1">{formatTime(selected.created)}</p>
<p className="mt-1">{selected.archived ? "已归档" : selected.read ? "已读" : "未读"}{selected.muted ? " · 已静音" : ""}</p>
{selected.entityType && <p className="mt-1">{selected.entityType} / {selected.entityId}</p>}
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => mutateNotification(selected.id, selected.read ? "unread" : "read")}
className="rounded-xl bg-gray-950 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-950"
>
{selected.read ? "标为未读" : "标为已读"}
</button>
<button
type="button"
onClick={() => mutateNotification(selected.id, "pin")}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
{selected.pinned ? "取消置顶" : "置顶"}
</button>
<button
type="button"
onClick={() => mutateNotification(selected.id, selected.archived ? "restore" : "archive")}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
{selected.archived ? "恢复" : "归档"}
</button>
{selected.actionUrl ? (
<Link
href={selected.actionUrl}
onClick={() => mutateNotification(selected.id, "read")}
className="rounded-xl bg-[#ff4d4f] px-3 py-2 text-center text-sm font-medium text-white hover:bg-[#ff3333]"
>
{selected.actionLabel || "打开"}
</Link>
) : (
<span className="rounded-xl bg-gray-100 px-3 py-2 text-center text-sm text-gray-400 dark:bg-gray-800"></span>
)}
</div>
</>
) : (
<p className="text-sm text-gray-500"></p>
)}
</section>
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50"></h2>
<div className="mt-4 space-y-3">
{[
["in_app", "站内"],
["email", "邮件"],
["wechat", "微信"],
].map(([key, label]) => (
<label key={key} className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-200">{label}</span>
<input
type="checkbox"
checked={!!preferences.channels[key]}
onChange={(event) =>
setPreferences((prev) => ({
...prev,
channels: { ...prev.channels, [key]: event.target.checked },
}))
}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{["system", "meetup", "gig", "match", "service", "community", "city"].map((key) => (
<label key={key} className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-200">{CATEGORY_META[key]?.label || key}</span>
<input
type="checkbox"
checked={!!preferences.categories[key]}
onChange={(event) =>
setPreferences((prev) => ({
...prev,
categories: { ...prev.categories, [key]: event.target.checked },
}))
}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-gray-400"></span>
<input
value={preferences.quietHoursStart}
onChange={(event) => setPreferences((prev) => ({ ...prev, quietHoursStart: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
<label className="block">
<span className="text-xs text-gray-400"></span>
<input
value={preferences.quietHoursEnd}
onChange={(event) => setPreferences((prev) => ({ ...prev, quietHoursEnd: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
</div>
<button
type="button"
onClick={savePreferences}
className="mt-4 w-full rounded-xl bg-[#ff4d4f] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#ff3333]"
>
</button>
</section>
</aside>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { Link } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
interface PreferenceRecord {
channels: Record<string, boolean>;
categories: Record<string, boolean>;
quietHoursStart: string;
quietHoursEnd: string;
digest: string;
allowMarketing: boolean;
}
const DEFAULT_PREFERENCES: PreferenceRecord = {
channels: { in_app: true, email: false, wechat: false },
categories: {
system: true,
meetup: true,
gig: true,
match: true,
service: true,
community: true,
city: true,
},
quietHoursStart: "22:00",
quietHoursEnd: "08:00",
digest: "daily",
allowMarketing: false,
};
const CHANNELS = [
["in_app", "站内消息", "始终建议开启,所有业务通知都会进入消息中心。"],
["email", "邮件摘要", "适合离线时收到每日/每周汇总。"],
["wechat", "微信提醒", "后续可接企业微信、公众号或机器人。"],
];
const CATEGORIES = [
["system", "系统公告"],
["meetup", "活动提醒"],
["gig", "赏金任务"],
["match", "社交匹配"],
["service", "服务咨询"],
["community", "社区讨论"],
["city", "城市更新"],
];
export default function NotificationSettingsPage() {
const [prefs, setPrefs] = useState<PreferenceRecord>(DEFAULT_PREFERENCES);
const [saved, setSaved] = useState("");
useEffect(() => {
apiFetch<{ record: PreferenceRecord }>("/api/notifications/preferences")
.then((data) => setPrefs({ ...DEFAULT_PREFERENCES, ...(data.record || {}) }))
.catch(() => setPrefs(DEFAULT_PREFERENCES));
}, []);
const save = async () => {
await apiFetch("/api/notifications/preferences", {
method: "PUT",
body: JSON.stringify(prefs),
});
setSaved("通知偏好已保存");
};
return (
<div className="min-h-screen bg-[#f6f7f9] dark:bg-gray-950">
<main className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<Link href="/messages" className="text-sm font-medium text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
</Link>
<header className="mt-5 mb-8">
<p className="text-sm font-semibold text-[#ff4d4f]">Notification Settings</p>
<h1 className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50"></h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
PocketBase
</p>
</header>
{saved && (
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{saved}
</div>
)}
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<section className="space-y-6">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50"></h2>
<div className="mt-4 space-y-3">
{CHANNELS.map(([key, title, desc]) => (
<label key={key} className="flex items-start justify-between gap-4 rounded-2xl border border-gray-100 p-4 dark:border-gray-800">
<span>
<span className="block font-medium text-gray-900 dark:text-gray-100">{title}</span>
<span className="mt-1 block text-sm text-gray-500 dark:text-gray-400">{desc}</span>
</span>
<input
type="checkbox"
checked={!!prefs.channels[key]}
onChange={(event) =>
setPrefs((prev) => ({
...prev,
channels: { ...prev.channels, [key]: event.target.checked },
}))
}
className="mt-1 h-5 w-5 accent-[#ff4d4f]"
/>
</label>
))}
</div>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50"></h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CATEGORIES.map(([key, label]) => (
<label key={key} className="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-800">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{label}</span>
<input
type="checkbox"
checked={!!prefs.categories[key]}
onChange={(event) =>
setPrefs((prev) => ({
...prev,
categories: { ...prev.categories, [key]: event.target.checked },
}))
}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
))}
</div>
</div>
</section>
<aside className="space-y-6">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50"></h2>
<div className="mt-4 grid grid-cols-2 gap-3">
<label>
<span className="text-xs text-gray-400"></span>
<input
value={prefs.quietHoursStart}
onChange={(event) => setPrefs((prev) => ({ ...prev, quietHoursStart: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
<label>
<span className="text-xs text-gray-400"></span>
<input
value={prefs.quietHoursEnd}
onChange={(event) => setPrefs((prev) => ({ ...prev, quietHoursEnd: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
</div>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50"></h2>
<select
value={prefs.digest}
onChange={(event) => setPrefs((prev) => ({ ...prev, digest: event.target.value }))}
className="mt-4 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
>
<option value="instant"></option>
<option value="daily"></option>
<option value="weekly"></option>
</select>
<label className="mt-4 flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-200"></span>
<input
type="checkbox"
checked={prefs.allowMarketing}
onChange={(event) => setPrefs((prev) => ({ ...prev, allowMarketing: event.target.checked }))}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
</div>
<button
type="button"
onClick={save}
className="w-full rounded-2xl bg-[#ff4d4f] px-5 py-3 font-semibold text-white shadow-sm hover:bg-[#ff3333]"
>
</button>
</aside>
</div>
</main>
<Footer />
</div>
);
}

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