feat: implement authentication context, admin node settings, and core layout components

This commit is contained in:
Christopher
2026-01-22 04:26:23 -08:00
parent c5f2d9c128
commit 7213f08b64
17 changed files with 698 additions and 462 deletions
+20 -156
View File
@@ -1,167 +1,31 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
type Notification = {
id: string;
type: 'follow' | 'like' | 'repost' | 'mention';
createdAt: string;
readAt?: string | null;
actor?: {
id: string;
handle: string;
displayName?: string | null;
avatarUrl?: string | null;
} | null;
post?: {
id: string;
content: string;
} | null;
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
};
const buildMessage = (item: Notification) => {
const actorName = item.actor?.displayName || item.actor?.handle || 'Someone';
switch (item.type) {
case 'follow':
return `${actorName} followed you`;
case 'like':
return `${actorName} liked your post`;
case 'repost':
return `${actorName} reposted your post`;
case 'mention':
return `${actorName} mentioned you`;
default:
return `${actorName} sent a notification`;
}
};
import { BellIcon } from '@/components/Icons';
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [polling, setPolling] = useState(true);
const loadNotifications = async () => {
try {
const res = await fetch('/api/notifications?limit=50', { cache: 'no-store' });
const data = await res.json();
setNotifications(data.notifications || []);
} catch {
setNotifications([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadNotifications();
}, []);
useEffect(() => {
if (!polling) return;
const interval = setInterval(loadNotifications, 15000);
return () => clearInterval(interval);
}, [polling]);
const markAllRead = async () => {
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true }),
});
loadNotifications();
};
const markRead = async (id: string) => {
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [id] }),
});
setNotifications((prev) =>
prev.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item))
);
};
const unreadCount = notifications.filter((item) => !item.readAt).length;
return (
<div className="notifications-shell">
<header className="notifications-header">
<div>
<h1>Notifications</h1>
<p>{unreadCount} unread</p>
</div>
<div className="notifications-actions">
<button className="btn btn-ghost btn-sm" onClick={() => setPolling(!polling)}>
{polling ? 'Pause polling' : 'Resume polling'}
</button>
<button className="btn btn-primary btn-sm" onClick={markAllRead} disabled={notifications.length === 0}>
Mark all read
</button>
</div>
<div className="notifications-page">
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
background: 'var(--background)',
position: 'sticky',
top: 0,
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Notifications</h1>
</header>
{loading ? (
<div className="notifications-empty">Loading notifications...</div>
) : notifications.length === 0 ? (
<div className="notifications-empty">No notifications yet.</div>
) : (
<div className="notifications-list">
{notifications.map((item) => (
<div
key={item.id}
className={`notification-row ${item.readAt ? 'read' : 'unread'}`}
role="button"
tabIndex={0}
onClick={() => markRead(item.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
markRead(item.id);
}
}}
>
<div className="notification-avatar">
{item.actor?.avatarUrl ? (
<img src={item.actor.avatarUrl} alt={item.actor.handle} />
) : (
(item.actor?.displayName || item.actor?.handle || '?').charAt(0).toUpperCase()
)}
</div>
<div className="notification-content">
<div className="notification-message">{buildMessage(item)}</div>
{item.post?.content && (
<div className="notification-post">{item.post.content.slice(0, 120)}</div>
)}
<div className="notification-meta">
<span>{formatTime(item.createdAt)}</span>
{item.actor?.handle && (
<Link href={`/@${item.actor.handle}`} className="notification-link">
@{item.actor.handle}
</Link>
)}
</div>
</div>
</div>
))}
<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' }}>
<BellIcon />
</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>
</div>
</div>
);
}