The body span was missing its closing brace, which broke typecheck and the web Docker build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useEffect } from 'react';
|
|
import { useSWRConfig } from 'swr';
|
|
import useSWRInfinite from 'swr/infinite';
|
|
import { notificationPath, notificationText, timeAgo, type NotificationListDTO } from '@musicshare/shared';
|
|
import { Avatar } from '@/components/Avatar';
|
|
import { client, useMe } from '@/lib/api';
|
|
|
|
const ADMIN_ACTOR_USERNAME = 'seriouscritter';
|
|
|
|
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
|
|
export default function NotificationsPage() {
|
|
const { me, loggedOut } = useMe();
|
|
const { mutate } = useSWRConfig();
|
|
|
|
// Fetch all notifications for the user
|
|
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
|
|
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
|
|
([, cursor]: [string, string | null]) => client.notifications(cursor),
|
|
{ revalidateOnFocus: false },
|
|
);
|
|
|
|
const allItems = data?.flatMap((p) => p.notifications) ?? [];
|
|
const unread = data?.[0]?.unread ?? 0;
|
|
|
|
// Filter messages specifically from seriouscritter
|
|
const seriousCritterMessages = allItems.filter(n => n.actor?.username === ADMIN_ACTOR_USERNAME);
|
|
|
|
// Seen once you've looked: clear the bell (rows stay highlighted until you come back).
|
|
useEffect(() => {
|
|
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
|
|
}, [unread, mutate]);
|
|
|
|
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
|
|
|
|
return (
|
|
<div className="stack">
|
|
<h1>Notifications</h1>
|
|
|
|
{/* Dedicated section for seriouscritter's messages */}
|
|
{seriousCritterMessages.length > 0 && (
|
|
<div className="admin-section">
|
|
<h2 className="muted">From {ADMIN_ACTOR_USERNAME}</h2>
|
|
<ul className="notifications">
|
|
{seriousCritterMessages.map((n) => {
|
|
const { title, body } = notificationText(n);
|
|
return (
|
|
<li key={n.id} className={n.read ? '' : 'unread'}>
|
|
<Link href={notificationPath(n)} className="notification">
|
|
<Avatar user={n.actor} size={40} />
|
|
<span className="notification-text">
|
|
<strong>{title}</strong>
|
|
<span>{body}</span>
|
|
<span className="muted">{timeAgo(n.createdAt)}</span>
|
|
</span>
|
|
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* General notifications list */}
|
|
{allItems.length === 0 && !isLoading && (
|
|
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
|
|
)}
|
|
<ul className="notifications">
|
|
{allItems.map((n) => {
|
|
const { title, body } = notificationText(n);
|
|
return (
|
|
<li key={n.id} className={n.read ? '' : 'unread'}>
|
|
<Link href={notificationPath(n)} className="notification">
|
|
<Avatar user={n.actor} size={40} />
|
|
<span className="notification-text">
|
|
<strong>{title}</strong>
|
|
<span>{body}</span>
|
|
<span className="muted">{timeAgo(n.createdAt)}</span>
|
|
</span>
|
|
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
|
|
{data?.at(-1)?.nextCursor && (
|
|
<button className="btn" onClick={() => setSize(size + 1)}>
|
|
Show more
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|