v2: global player, infinite scroll, image picker, threaded comments
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user