133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import Link from 'next/link';
|
|
import { apiFetch } from '@/lib/auth';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
interface Notification {
|
|
id: number;
|
|
type: 'like' | 'comment' | 'follow' | 'system';
|
|
title: string;
|
|
content?: string;
|
|
link?: string;
|
|
relatedId?: number;
|
|
isRead: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
function NotificationIcon({ type }: { type: string }) {
|
|
const icons: Record<string, string> = {
|
|
like: '❤️',
|
|
comment: '💬',
|
|
follow: '👤',
|
|
system: '🔔',
|
|
};
|
|
return <span className="text-lg">{icons[type] || '🔔'}</span>;
|
|
}
|
|
|
|
export default function NotificationsPage() {
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/notifications');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setNotifications(data.items || []);
|
|
setUnreadCount(data.unread || 0);
|
|
}
|
|
} catch (e) { console.error(e) }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
async function markRead(id: number) {
|
|
try {
|
|
await apiFetch(`/notifications/${id}/read`, { method: 'PATCH' });
|
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
} catch (e) { console.error(e) }
|
|
}
|
|
|
|
async function markAllRead() {
|
|
try {
|
|
await apiFetch('/notifications/read-all', { method: 'PATCH' });
|
|
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
|
setUnreadCount(0);
|
|
} catch (e) { console.error(e) }
|
|
}
|
|
|
|
if (loading) return (
|
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
<Skeleton className="h-8 w-48 mb-6" />
|
|
<Skeleton className="h-4 w-32 mb-8" />
|
|
<Skeleton className="h-64 w-full mb-4" />
|
|
<Skeleton className="h-4 w-full mb-2" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-foreground">通知</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
{unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知'}
|
|
</p>
|
|
</div>
|
|
{unreadCount > 0 && (
|
|
<Button variant="outline" size="sm" onClick={markAllRead}>
|
|
全部已读
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{notifications.map(n => (
|
|
<div key={n.id}
|
|
className={`flex items-start gap-4 p-4 rounded-xl border transition-colors ${
|
|
n.isRead
|
|
? 'bg-card border-border'
|
|
: 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'
|
|
}`}>
|
|
<div className="mt-1"><NotificationIcon type={n.type} /></div>
|
|
<div className="flex-1 min-w-0">
|
|
{n.link ? (
|
|
<Link href={n.link} onClick={() => { if (!n.isRead) markRead(n.id); }}
|
|
className="text-sm font-medium text-foreground hover:text-brand-600">
|
|
{n.title}
|
|
</Link>
|
|
) : (
|
|
<p className="text-sm font-medium text-foreground">{n.title}</p>
|
|
)}
|
|
{n.content && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{n.content}</p>}
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{new Date(n.createdAt).toLocaleString('zh-CN')}
|
|
</p>
|
|
</div>
|
|
{!n.isRead && (
|
|
<button onClick={() => markRead(n.id)}
|
|
className="text-xs text-muted-foreground hover:text-foreground shrink-0 px-2 py-1 rounded hover:bg-accent">
|
|
已读
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{notifications.length === 0 && (
|
|
<div className="text-center py-20 text-muted-foreground">
|
|
<p className="text-4xl mb-4">🔔</p>
|
|
<p>暂无通知</p>
|
|
<p className="text-sm mt-1">点赞、评论或关注你的人会出现在这里</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|