v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -4,8 +4,12 @@ import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
import type { Dump, PublicUser } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import type { Comment, Dump, PublicUser, RawComment } from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializePublicUser,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -16,6 +20,8 @@ import FilePreview from "../components/FilePreview.tsx";
import { VoteButton } from "../components/VoteButton.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { CommentThread } from "../components/CommentThread.tsx";
type DumpState =
| { status: "loading" }
@@ -34,8 +40,10 @@ export function Dump() {
const [op, setOp] = useState<PublicUser | null>(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const { user } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
const [comments, setComments] = useState<Comment[]>([]);
const { user, token } = useAuth();
const { voteCounts, myVotes, castVote, removeVote, lastDumpEvent, lastCommentEvent } = useWS();
useEffect(() => {
if (!selectedDump) return;
@@ -55,6 +63,7 @@ export function Dump() {
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -75,6 +84,52 @@ export function Dump() {
})();
}, [selectedDump, preloaded]);
useEffect(() => {
if (!lastDumpEvent) return;
setDumpState((prev) => {
if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) {
return prev;
}
return { status: "loaded", dump: lastDumpEvent };
});
}, [lastDumpEvent]);
// Fetch comments when dump loads
useEffect(() => {
if (!selectedDump) return;
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
if (body.success) {
setComments((body.data as RawComment[]).map(deserializeComment));
}
})
.catch(() => {});
}, [selectedDump, token]);
// React to WS comment events
useEffect(() => {
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
setComments((prev) => {
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) return prev;
return [...prev, lastCommentEvent.comment!];
});
} else if (
lastCommentEvent.type === "deleted" && lastCommentEvent.commentId
) {
setComments((prev) =>
prev.map((c) =>
c.id === lastCommentEvent.commentId
? { ...c, deleted: true, body: "" }
: c
)
);
}
}, [lastCommentEvent, selectedDump]);
if (dumpState.status === "loading") {
return (
<PageShell>
@@ -126,35 +181,45 @@ export function Dump() {
onCast={castVote}
onRemove={removeVote}
/>
<div className="dump-header-info">
<h1 className="dump-title">{dump.title}</h1>
<div className="dump-op">
<Avatar
userId={dump.userId}
username={op?.username ?? "?"}
hasAvatar={!!op?.avatarMime}
size={22}
/>
{op
? (
<Link to={`/users/${op.username}`} className="dump-op-link">
{op.username}
</Link>
)
: <span className="dump-op-link"></span>}
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
title={dump.createdAt.toLocaleString()}
>
{relativeTime(dump.createdAt)}
</time>
</div>
<h1 className="dump-title">{dump.title}</h1>
{user && (
<button
type="button"
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
+ Playlist
</button>
)}
<div className="dump-op">
<Avatar
userId={dump.userId}
username={op?.username ?? "?"}
hasAvatar={!!op?.avatarMime}
size={22}
/>
{op
? (
<Link to={`/users/${op.username}`} className="dump-op-link">
{op.username}
</Link>
)
: <span className="dump-op-link"></span>}
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
title={dump.createdAt.toLocaleString()}
>
{relativeTime(dump.createdAt)}
</time>
{dump.isPrivate && (
<span className="dump-card-private-badge">private</span>
)}
</div>
</div>
{dump.comment && (
<blockquote className="dump-comment">{dump.comment}</blockquote>
<Markdown className="dump-comment">{dump.comment}</Markdown>
)}
</div>
@@ -180,16 +245,25 @@ export function Dump() {
<div className="dump-actions">
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
<Link to="/"> Back to all dumps</Link>
{user && (
<button
type="button"
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
+ Playlist
</button>
)}
</div>
{/* Comments */}
<CommentThread
dumpId={dump.id}
comments={comments}
currentUser={user}
token={token}
onCommentCreated={(c) =>
setComments((prev) =>
prev.some((x) => x.id === c.id) ? prev : [...prev, c]
)}
onCommentDeleted={(id) =>
setComments((prev) =>
prev.map((c) =>
c.id === id ? { ...c, deleted: true, body: "" } : c
)
)}
/>
</div>
{playlistModalOpen && (
<AddToPlaylistModal