346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router";
|
|
|
|
import { API_URL } from "../config/api.ts";
|
|
import { useAuth } from "../hooks/useAuth.ts";
|
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
|
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";
|
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
|
|
|
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 TypeError) {
|
|
setState({ status: "error", error: friendlyFetchError(err) });
|
|
} else 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" && (
|
|
<ErrorCard title="Failed to load" message={state.error} />
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|