v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone

This commit is contained in:
khannurien
2026-03-22 16:06:26 +00:00
parent 39a0cc397e
commit 34e908d1bc
42 changed files with 2170 additions and 628 deletions

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import React, { 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 { Tooltip } from "../components/Tooltip.tsx";
import { useWS } from "../hooks/useWS.ts";
import type {
DumpUpvotedData,
@@ -15,6 +16,7 @@ import type {
RawNotification,
UserDumpPostedData,
UserFollowedData,
UserMentionedData,
} from "../model.ts";
import { deserializeNotification } from "../model.ts";
import { PageShell } from "../components/PageShell.tsx";
@@ -33,7 +35,7 @@ type State =
loadingMore: boolean;
};
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist" | "mention";
function notifIconKind(type: Notification["type"]): NotifIconKind {
switch (type) {
@@ -47,16 +49,31 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
return "dump";
case "playlist_dump_added":
return "playlist";
case "user_mentioned":
return "mention";
}
}
const UpvoteSvg = () => (
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
<polygon points="5,1 9,9 1,9" />
</svg>
);
const FollowSvg = () => (
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
<polygon points="2,1 9,5 2,9" />
</svg>
);
function NotifIcon({ type }: { type: Notification["type"] }) {
const kind = notifIconKind(type);
const glyphs: Record<NotifIconKind, string> = {
upvote: "▲",
follow: "►",
const glyphs: Record<NotifIconKind, React.ReactNode> = {
upvote: <UpvoteSvg />,
follow: <FollowSvg />,
dump: "🚚",
playlist: "📜",
mention: "@",
};
return (
<span className={`notif-icon notif-icon--${kind}`}>
@@ -65,78 +82,56 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
);
}
function notificationLink(n: Notification): string {
const data = n.data as NotificationData;
switch (n.type) {
case "user_followed":
return `/users/${(data as UserFollowedData).followerUsername}`;
case "playlist_followed":
return `/playlists/${(data as PlaylistFollowedData).playlistId}`;
case "user_dump_posted":
return `/dumps/${(data as UserDumpPostedData).dumpId}`;
case "playlist_dump_added":
return `/dumps/${(data as PlaylistDumpAddedData).dumpId}`;
case "dump_upvoted":
return `/dumps/${(data as DumpUpvotedData).dumpId}`;
case "user_mentioned": {
const d = data as UserMentionedData;
if (d.contextType === "comment") return `/dumps/${d.dumpId}#comment-${d.contextId}`;
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
return `/playlists/${d.contextId}`;
}
}
}
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"}
</>
);
return <><strong>{d.followerUsername}</strong>{" 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>
</>
);
return <><strong>{d.followerUsername}</strong>{" followed your playlist "}<strong>{d.playlistTitle}</strong></>;
}
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>
</>
);
return <><strong>{d.dumperUsername}</strong>{" posted "}<strong>{d.dumpTitle}</strong></>;
}
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>
</>
);
return <><strong>{d.dumpTitle}</strong>{" was added to "}<strong>{d.playlistTitle}</strong></>;
}
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>
</>
);
return <><strong>{d.voterUsername}</strong>{" upvoted "}<strong>{d.dumpTitle}</strong></>;
}
case "user_mentioned": {
const d = data as UserMentionedData;
const where = d.contextTitle || (d.contextType === "comment" ? "a comment" : "a post");
return <><strong>{d.mentionerUsername}</strong>{" mentioned you in "}<strong>{where}</strong></>;
}
default:
return "New notification";
}
}
@@ -308,21 +303,28 @@ export function Notifications() {
!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" />
)}
<Link
to={notificationLink(n)}
className="notification-item-link"
>
<NotifIcon type={n.type} />
<div className="notification-body">
<span className="notification-content">
{notificationContent(n)}
</span>
<Tooltip text={n.createdAt.toLocaleString()}>
<time
className="notification-time"
dateTime={n.createdAt.toISOString()}
>
{timeAgo(n.createdAt)}
</time>
</Tooltip>
</div>
{!n.read && (
<span className="notif-dot" aria-hidden="true" />
)}
</Link>
</li>
))}
</ul>