v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

340
src/pages/Notifications.tsx Normal file
View File

@@ -0,0 +1,340 @@
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import type {
DumpUpvotedData,
Notification,
NotificationData,
PaginatedData,
PlaylistDumpAddedData,
PlaylistFollowedData,
RawNotification,
UserDumpPostedData,
UserFollowedData,
} from "../model.ts";
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx";
const PAGE_SIZE = 30;
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
items: Notification[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
function notifIconKind(type: Notification["type"]): NotifIconKind {
switch (type) {
case "dump_upvoted":
return "upvote";
case "playlist_followed":
return "follow";
case "user_followed":
return "follow";
case "user_dump_posted":
return "dump";
case "playlist_dump_added":
return "playlist";
}
}
function NotifIcon({ type }: { type: Notification["type"] }) {
const kind = notifIconKind(type);
const glyphs: Record<NotifIconKind, string> = {
upvote: "▲",
follow: "►",
dump: "🚚",
playlist: "📜",
};
return (
<span className={`notif-icon notif-icon--${kind}`}>
{glyphs[kind]}
</span>
);
}
function notificationContent(n: Notification): React.ReactNode {
const data = n.data as NotificationData;
switch (n.type) {
case "user_followed": {
const d = data as UserFollowedData;
return (
<>
<Link to={`/users/${d.followerUsername}`} className="notif-link">
{d.followerUsername}
</Link>
{" started following you"}
</>
);
}
case "playlist_followed": {
const d = data as PlaylistFollowedData;
return (
<>
<Link to={`/users/${d.followerUsername}`} className="notif-link">
{d.followerUsername}
</Link>
{" followed your playlist "}
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
{d.playlistTitle}
</Link>
</>
);
}
case "user_dump_posted": {
const d = data as UserDumpPostedData;
return (
<>
<Link to={`/users/${d.dumperUsername}`} className="notif-link">
{d.dumperUsername}
</Link>
{" posted "}
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
</>
);
}
case "playlist_dump_added": {
const d = data as PlaylistDumpAddedData;
return (
<>
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
{" was added to "}
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
{d.playlistTitle}
</Link>
</>
);
}
case "dump_upvoted": {
const d = data as DumpUpvotedData;
return (
<>
<Link to={`/users/${d.voterUsername}`} className="notif-link">
{d.voterUsername}
</Link>
{" upvoted "}
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
{d.dumpTitle}
</Link>
</>
);
}
default:
return "New notification";
}
}
function timeAgo(date: Date): string {
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
function groupByDate(
items: Notification[],
): { label: string; items: Notification[] }[] {
const todayTs = startOfDay(new Date());
const yesterdayTs = todayTs - 86_400_000;
const buckets: Record<string, Notification[]> = {};
for (const n of items) {
const ts = startOfDay(n.createdAt);
const key = ts >= todayTs
? "Today"
: ts >= yesterdayTs
? "Yesterday"
: "Earlier";
(buckets[key] ??= []).push(n);
}
return (["Today", "Yesterday", "Earlier"] as const)
.filter((k) => buckets[k]?.length)
.map((label) => ({ label, items: buckets[label] }));
}
export function Notifications() {
const { authFetch } = useAuth();
const { clearUnreadNotifications, lastNotification } = useWS();
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
// 1. Fetch with original read state so unread items are highlighted
// 2. Only after displaying, mark all read on the server
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
const data = body.data as PaginatedData<RawNotification>;
setState({
status: "loaded",
items: data.items.map(deserializeNotification),
hasMore: data.hasMore,
page: 1,
loadingMore: false,
});
// Mark read server-side after we've shown the unread state
return authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
});
})
.then(() => {
clearUnreadNotifications();
setState((s) =>
s.status === "loaded"
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
: s
);
})
.catch((err) => {
if (err instanceof Error && err.message === "Failed to load") {
setState({ status: "error", error: err.message });
}
});
}, []);
useEffect(() => {
if (!lastNotification) return;
setState((s) => {
if (s.status !== "loaded") return s;
if (s.items.some((n) => n.id === lastNotification.id)) return s;
// Keep as unread so it gets highlighted when it arrives
return { ...s, items: [lastNotification, ...s.items] };
});
}, [lastNotification]);
const loadMore = () => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
return;
}
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
authFetch(
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
const data = body.data as PaginatedData<RawNotification>;
setState((s) =>
s.status === "loaded"
? {
...s,
items: [...s.items, ...data.items.map(deserializeNotification)],
hasMore: data.hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
};
const totalUnread = state.status === "loaded"
? state.items.filter((n) => !n.read).length
: 0;
return (
<PageShell>
<div className="notifications-page">
<div className="notifications-header">
<h1 className="notifications-title">
<span className="notifications-title-bell">🔔</span>
Notifications
</h1>
{state.status === "loaded" && totalUnread > 0 && (
<span className="notifications-unread-pill">
{totalUnread} new
</span>
)}
</div>
{state.status === "loading" && <p className="page-loading">Loading</p>}
{state.status === "error" && <p className="form-error">{state.error}
</p>}
{state.status === "loaded" && state.items.length === 0 && (
<div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span>
<p>Nothing here yet.</p>
<p className="notifications-empty-hint">
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
</p>
</div>
)}
{state.status === "loaded" && state.items.length > 0 &&
groupByDate(state.items).map(({ label, items }) => (
<section key={label} className="notif-group">
<h2 className="notif-group-label">{label}</h2>
<ul className="notification-list">
{items.map((n) => (
<li
key={n.id}
className={`notification-item${
!n.read ? " notification-item--unread" : ""
}`}
>
<NotifIcon type={n.type} />
<div className="notification-body">
<span className="notification-content">
{notificationContent(n)}
</span>
<time
className="notification-time"
dateTime={n.createdAt.toISOString()}
>
{timeAgo(n.createdAt)}
</time>
</div>
{!n.read && (
<span className="notif-dot" aria-hidden="true" />
)}
</li>
))}
</ul>
</section>
))}
{state.status === "loaded" && state.hasMore && (
<button
type="button"
className="load-more-btn"
onClick={loadMore}
disabled={state.loadingMore}
>
{state.loadingMore ? "Loading…" : "Load more"}
</button>
)}
</div>
</PageShell>
);
}