feat(notifications): Add notifications page with real-time updates and UI
- Implement notifications page component with loading and error states - Add notification fetching from `/api/notifications` endpoint - Create notification type interfaces (follow, like, repost, mention) - Add "Mark all read" functionality to batch update notification status - Implement NotificationItem component to display individual notifications - Add relative time formatting (e.g., "5m", "2h", "3d") - Display unread notifications with distinct background styling - Add user avatar and profile links for notification actors - Show post content preview for post-related notifications - Handle authentication errors with appropriate user messaging - Display empty state with icon when no notifications exist
This commit is contained in:
@@ -1,8 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BellIcon } from '@/components/Icons';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface NotificationActor {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
interface NotificationPost {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'follow' | 'like' | 'repost' | 'mention';
|
||||
createdAt: string;
|
||||
readAt: string | null;
|
||||
actor: NotificationActor | null;
|
||||
post: NotificationPost | null;
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notifications');
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError('Please log in to view notifications');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to fetch notifications');
|
||||
}
|
||||
const data = await res.json();
|
||||
setNotifications(data.notifications || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load notifications');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch('/api/notifications', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ all: true }),
|
||||
});
|
||||
setNotifications(prev => prev.map(n => ({ ...n, readAt: new Date().toISOString() })));
|
||||
} catch (err) {
|
||||
console.error('Failed to mark notifications as read:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationText = (notification: Notification) => {
|
||||
switch (notification.type) {
|
||||
case 'follow':
|
||||
return 'followed you';
|
||||
case 'like':
|
||||
return 'liked your post';
|
||||
case 'repost':
|
||||
return 'reposted your post';
|
||||
case 'mention':
|
||||
return 'mentioned you';
|
||||
default:
|
||||
return 'interacted with you';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notifications-page">
|
||||
<header style={{
|
||||
@@ -13,10 +107,36 @@ export default function NotificationsPage() {
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Notifications</h1>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--accent)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, background: 'var(--background-secondary)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -24,8 +144,126 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px', color: 'var(--foreground)' }}>No notifications yet</h3>
|
||||
<p style={{ fontSize: '14px' }}>When you get interactions, they'll show up here.</p>
|
||||
<p style={{ fontSize: '14px' }}>When you get interactions, they'll show up here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
getNotificationText={getNotificationText}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
getNotificationText,
|
||||
formatTime,
|
||||
}: {
|
||||
notification: Notification;
|
||||
getNotificationText: (n: Notification) => string;
|
||||
formatTime: (d: string) => string;
|
||||
}) {
|
||||
const isUnread = !notification.readAt;
|
||||
const actor = notification.actor;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: isUnread ? 'var(--background-secondary)' : 'transparent',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
|
||||
{actor?.avatarUrl ? (
|
||||
<Image
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName || actor.handle}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--foreground-secondary)',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{(actor?.displayName || actor?.handle || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Link
|
||||
href={actor ? `/@${actor.handle}` : '#'}
|
||||
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
||||
>
|
||||
{actor?.displayName || actor?.handle || 'Someone'}
|
||||
</Link>
|
||||
<span style={{ color: 'var(--foreground-secondary)' }}>
|
||||
{getNotificationText(notification)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>
|
||||
· {formatTime(notification.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{notification.post && (
|
||||
<Link
|
||||
href={`/posts/${notification.post.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: '8px',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--foreground-secondary)',
|
||||
fontSize: '14px',
|
||||
textDecoration: 'none',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{notification.post.content}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUnread && (
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)',
|
||||
flexShrink: 0,
|
||||
marginTop: '6px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user