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

View File

@@ -20,13 +20,15 @@ type DumpEditState =
export function DumpEdit() {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch } = useRequiredAuth();
const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [url, setUrl] = useState("");
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [newFile, setNewFile] = useState<File | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
if (!selectedDump) return;
@@ -37,6 +39,7 @@ export function DumpEdit() {
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}`);
@@ -46,6 +49,7 @@ export function DumpEdit() {
const dump: Dump = deserializeDump(apiResponse.data);
setUrl(dump.url ?? "");
setComment(dump.comment ?? "");
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
} else {
setState({ status: "error", error: apiResponse.error.message });
@@ -74,8 +78,8 @@ export function DumpEdit() {
});
} else {
const body: UpdateDumpRequest = state.dump.kind === "url"
? { url: url.trim() || undefined, comment: comment.trim() || undefined }
: { comment: comment.trim() || undefined };
? { url: url.trim() || undefined, comment: comment.trim() || undefined, isPrivate }
: { comment: comment.trim() || undefined, isPrivate };
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
@@ -102,6 +106,25 @@ export function DumpEdit() {
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
};
const handleRefreshMetadata = async () => {
if (state.status !== "loaded" || state.dump.kind !== "url") return;
setRefreshing(true);
try {
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
{ method: "POST" },
);
const apiResponse = await res.json();
if (apiResponse.success) {
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
}
} finally {
setRefreshing(false);
}
};
const handleDelete = async () => {
if (state.status !== "loaded") return;
@@ -176,6 +199,16 @@ export function DumpEdit() {
{dump.url}
</a>
)}
{dump.kind === "url" && (
<button
type="button"
className="btn-secondary dump-edit-refresh"
onClick={handleRefreshMetadata}
disabled={refreshing}
>
{refreshing ? "Refreshing…" : "Refresh metadata"}
</button>
)}
</div>
<form
@@ -230,6 +263,21 @@ export function DumpEdit() {
/>
</div>
<label className="toggle-row">
<span className="toggle-label">Public</span>
<span className="toggle-switch">
<input
type="checkbox"
checked={!isPrivate}
onChange={(e) => setIsPrivate(!e.target.checked)}
/>
<span className="toggle-thumb" />
</span>
{isPrivate && (
<span className="toggle-hint">Only visible to you</span>
)}
</label>
<div className="form-actions">
<button
type="button"

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { Avatar } from "../components/Avatar.tsx";
@@ -7,15 +7,24 @@ import { AppHeader } from "../components/AppHeader.tsx";
import { API_URL } from "../config/api.ts";
import { deserializeDump, type Dump } from "../model.ts";
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
const PAGE_SIZE = 20;
// After JSON roundtrip, createdAt is a string — re-parse it
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dumps: Dump[] };
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
type SortMode = "new" | "hot";
@@ -29,7 +38,7 @@ export function Index() {
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
const { user } = useAuth();
const { user, token } = useAuth();
const {
onlineUsers,
voteCounts,
@@ -40,20 +49,31 @@ export function Index() {
removeVote,
} = useWS();
const [dumpsState, setDumpsState] = useState<DumpsState>({
status: "loading",
});
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
cached
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
: { status: "loading" }
);
const [sort, setSort] = useState<SortMode>("hot");
useEffect(() => {
if (cached) return; // restored from cache, skip fetch
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/`);
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setDumpsState({
status: "loaded",
dumps: body.data.map(deserializeDump),
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
} catch (err) {
setDumpsState({
@@ -62,11 +82,82 @@ export function Index() {
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadMore = useCallback(() => {
if (
dumpsState.status !== "loaded" ||
!dumpsState.hasMore ||
dumpsState.loadingMore
) return;
const nextPage = dumpsState.page + 1;
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setDumpsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [dumpsState, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
if (dumpsState.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (dumpsState.status === "loaded") {
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
}
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
}, [dumpsState, saveState]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (dumpsState.status === "loaded") {
window.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
// cached is stable (read once), safe to omit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dumpsState.status]);
const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null;
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const restIds = new Set(dumps.map((d) => d.id));
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
@@ -141,22 +232,24 @@ export function Index() {
)}
{!loading && !error && combined.length > 0 && (
<>
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
/>
))}
</ul>
</>
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</div>
);
}

View File

@@ -1,18 +1,21 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { deserializePlaylist, type PaginatedData } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
const PAGE_SIZE = 20;
type State =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; playlists: Playlist[] };
| { status: "loaded"; playlists: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean };
export function MyPlaylists() {
const { user, authFetch, token } = useAuth();
@@ -22,27 +25,62 @@ export function MyPlaylists() {
useEffect(() => {
if (!user) return;
fetch(`${API_URL}/api/users/${user.username}/playlists`, {
fetch(`${API_URL}/api/users/${user.username}/playlists?page=1&limit=${PAGE_SIZE}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("Failed to load");
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState({
status: "loaded",
playlists: (body.data as RawPlaylist[]).map(deserializePlaylist),
playlists: items.map(deserializePlaylist),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error
? err.message
: "Failed to load playlists",
error: err instanceof Error ? err.message : "Failed to load playlists",
})
);
}, [user?.username]);
const loadMore = useCallback(() => {
if (state.status !== "loaded" || !state.hasMore || state.loadingMore || !user) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${user.username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: [...s.playlists, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s)
);
}, [state, user, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
// Real-time WS updates
useEffect(() => {
if (!lastPlaylistEvent || !user) return;
@@ -133,6 +171,11 @@ export function MyPlaylists() {
)
)}
<div ref={sentinelRef} />
{state.status === "loaded" && state.loadingMore && (
<p className="feed-loading-more">Loading more</p>
)}
{confirmDeleteId && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."

View File

@@ -9,6 +9,9 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
type LoadState =
| { status: "loading" }
@@ -48,12 +51,13 @@ export function PlaylistDetail() {
const [editIsPublic, setEditIsPublic] = useState(true);
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => () => {
cancels.current.forEach((c) => c());
@@ -205,20 +209,22 @@ export function PlaylistDetail() {
}
}
// Reorder active dumps in state to match the new dumpIds order
// Reorder active dumps to match the new server order,
// keeping fading dumps at their current visual positions.
setState((prev) => {
if (prev.status !== "loaded") return prev;
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
const reordered = ev.dumpIds!
const activeQueue = ev.dumpIds!
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!);
// Keep fading dumps appended at the end so they stay visible
const fadingDumps = prev.playlist.dumps.filter(
(d) => !newIds.has(d.id) && dumpMap.has(d.id),
);
let qi = 0;
const result = prev.playlist.dumps
.filter((d) => dumpMap.has(d.id))
.map((d) => newIds.has(d.id) ? activeQueue[qi++] : d);
while (qi < activeQueue.length) result.push(activeQueue[qi++]);
return {
...prev,
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
playlist: { ...prev.playlist, dumps: result },
};
});
@@ -332,6 +338,13 @@ export function PlaylistDetail() {
}).catch(() => {});
};
useEffect(() => {
const el = descriptionRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [editDescription, editOpen]);
const openEdit = () => {
if (state.status !== "loaded") return;
setEditTitle(state.playlist.title);
@@ -343,16 +356,8 @@ export function PlaylistDetail() {
setEditOpen(true);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
const url = URL.createObjectURL(file);
setImagePreview(url);
};
const handleEditSave = async (e: React.FormEvent) => {
e.preventDefault();
const handleEditSave = async () => {
if (!playlistId || state.status !== "loaded") return;
setEditSaving(true);
setEditError(null);
@@ -385,6 +390,12 @@ export function PlaylistDetail() {
}
};
const handleDelete = async () => {
if (!playlistId) return;
await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE" });
navigate("/");
};
if (state.status === "loading") {
return (
<PageShell>
@@ -423,136 +434,141 @@ export function PlaylistDetail() {
<PageShell>
<div className="playlist-detail-header">
<div className="playlist-detail-header-top">
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div>
<h1 className="playlist-detail-title">{playlist.title}</h1>
{playlist.description && (
<p className="playlist-detail-description">
{playlist.description}
</p>
{editOpen
? (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
alt="Cover"
size={72}
onChange={(file) => {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</div>
</div>
{isOwner && !editOpen && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
{isOwner && editOpen && (
<form className="playlist-edit-form" onSubmit={handleEditSave}>
<div className="playlist-edit-fields">
<input
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) =>
setEditTitle(e.target.value)}
placeholder="Title"
required
/>
<textarea
className="playlist-edit-textarea"
value={editDescription}
onChange={(e) =>
setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
/>
<div className="dump-mode-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
</button>
</div>
<div className="playlist-edit-image-row">
{imagePreview
? (
<img
src={imagePreview}
alt="Preview"
className="playlist-edit-img-preview"
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt="Current"
className="playlist-edit-img-preview"
/>
)}
<button
type="button"
className="btn-secondary"
onClick={() => imageInputRef.current?.click()}
>
{playlist.imageMime || imageFile
? "Change image"
: "Add image"}
</button>
<div className="playlist-detail-content">
{editOpen
? (
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: "none" }}
onChange={handleImageChange}
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
/>
</div>
)
: <h1 className="playlist-detail-title">{playlist.title}</h1>}
{editOpen
? (
<textarea
ref={descriptionRef}
className="playlist-edit-textarea"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={1}
/>
)
: playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
{editOpen
? (
<div className="dump-mode-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
</button>
</div>
)
: (
<>
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</>
)}
</div>
{editError && <p className="form-error">{editError}</p>}
<div className="playlist-edit-actions">
<button
type="submit"
className="btn-primary"
disabled={editSaving}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
</div>
{isOwner && (
<div className="playlist-header-actions">
{editOpen
? (
<>
<button
type="button"
className="btn-primary"
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
</>
)
: (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
</form>
)}
)}
</div>
</div>
{visibleDumps.length === 0
@@ -598,6 +614,7 @@ export function PlaylistDetail() {
castVote={castVote}
removeVote={removeVote}
className={cardCls}
isOwner={!!user && user.id === dump.userId}
/>
{isOwner && (isActive
? (
@@ -625,6 +642,14 @@ export function PlaylistDetail() {
})}
</div>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
)}
</PageShell>
);
}

View File

@@ -1,13 +1,14 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PublicUser } from "../model.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import {
deserializeAuthResponse,
deserializeDump,
deserializePublicUser,
deserializeUser,
type RawDump,
type RawUser,
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
@@ -18,8 +19,28 @@ import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
interface PaginatedList<T> {
items: T[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}
function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
return { items, hasMore, page: 1, loadingMore: false };
}
type ProfileState =
| { status: "loading" }
@@ -27,9 +48,9 @@ type ProfileState =
| {
status: "loaded";
user: PublicUser;
dumps: Dump[];
votes: Dump[];
playlists: Playlist[];
dumps: PaginatedList<Dump>;
votes: PaginatedList<Dump>;
playlists: PaginatedList<Playlist>;
};
export function UserPublicProfile() {
@@ -46,11 +67,22 @@ export function UserPublicProfile() {
deletedPlaylistIds,
} = useWS();
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
`feed:profile-dumps:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedVotes, saveState: saveVotes } = useFeedCache<Dump>(
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<ProfileState>({ status: "loading" });
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
// Tracks which dumps the profile user currently has voted on (real-time).
// For own profile this mirrors myVotes; for others it's maintained separately.
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
new Set(),
);
@@ -61,22 +93,42 @@ export function UserPublicProfile() {
if (!username) return;
setState({ status: "loading" });
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
if (allCached) {
// Only fetch the user object (lightweight, always fresh)
fetch(`${API_URL}/api/users/${username}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
user: deserializePublicUser(body.data),
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
});
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
})
.catch((err) =>
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
);
return;
}
(async () => {
try {
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}/dumps`),
fetch(`${API_URL}/api/users/${username}/votes`),
fetch(`${API_URL}/api/users/${username}/playlists`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}),
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
]);
if (!userRes.ok) {
throw new Error(
userRes.status === 404
? "User not found"
: `HTTP ${userRes.status}`,
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
);
}
@@ -88,20 +140,28 @@ export function UserPublicProfile() {
playlistsRes.json(),
]);
const votes: Dump[] = votesBody.success
? votesBody.data.map(deserializeDump)
: [];
const playlists: Playlist[] = playlistsBody.success
? (playlistsBody.data as RawPlaylist[]).map(deserializePlaylist)
: [];
const votesData: PaginatedData<RawDump> = votesBody.success
? votesBody.data
: { items: [], total: 0, hasMore: false };
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
? playlistsBody.data
: { items: [], total: 0, hasMore: false };
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
? dumpsBody.data
: { items: [], total: 0, hasMore: false };
const voteItems = votesData.items.map(deserializeDump);
setState({
status: "loaded",
user: deserializePublicUser(userBody.data),
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
votes,
playlists,
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
votes: initialList(voteItems, votesData.hasMore),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
playlistsData.hasMore,
),
});
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
} catch (err) {
setState({
status: "error",
@@ -111,17 +171,12 @@ export function UserPublicProfile() {
})();
}, [username]);
// Stable primitive derived from state — only changes when navigating to a different profile.
// Using this instead of `state` directly avoids re-running effects on every vote update.
const profileUserId = state.status === "loaded" ? state.user.id : null;
// Own profile: keep profileVotedIds in sync with myVotes, and add newly-voted
// dumps (that belong to this user) to the votes list without a fetch.
// Own profile: keep profileVotedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
setProfileVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) {
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -129,17 +184,17 @@ export function UserPublicProfile() {
const prev = prevMyVotesRef.current;
setState((s) => {
if (s.status !== "loaded") return s;
const voteIds = new Set(s.votes.map((d) => d.id));
const toAdd = s.dumps.filter((d) =>
const voteIds = new Set(s.votes.items.map((d) => d.id));
const toAdd = s.dumps.items.filter((d) =>
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
);
if (toAdd.length === 0) return s;
return { ...s, votes: [...toAdd, ...s.votes] };
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]);
// Real-time upvoted list sync for any profile via WS vote events.
// Real-time upvoted list sync via WS vote events
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
@@ -158,17 +213,16 @@ export function UserPublicProfile() {
if (!isOwnProfile) {
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
}
// Always fetch on cast; the setState callback below deduplicates.
fetch(`${API_URL}/api/dumps/${dumpId}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
return s;
}
return { ...s, votes: [dump, ...s.votes] };
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
});
})
.catch(() => {});
@@ -182,34 +236,39 @@ export function UserPublicProfile() {
const isOwnProfile = me?.id === profileUserId;
const ev = lastPlaylistEvent;
if (
ev.type === "created" && ev.playlist &&
ev.playlist.userId === profileUserId
) {
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
if (ev.playlist.isPublic || isOwnProfile) {
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
};
});
}
} else if (
ev.type === "updated" && ev.playlist &&
ev.playlist.userId === profileUserId
) {
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
setState((s) => {
if (s.status !== "loaded") return s;
const updated = s.playlists.map((p) =>
p.id === ev.playlist!.id ? ev.playlist! : p
).filter((p) => p.isPublic || isOwnProfile);
return { ...s, playlists: updated };
return {
...s,
playlists: {
...s.playlists,
items: s.playlists.items
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
.filter((p) => p.isPublic || isOwnProfile),
},
};
});
} else if (ev.type === "deleted") {
setState((s) => {
if (s.status !== "loaded") return s;
return {
...s,
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
playlists: {
...s.playlists,
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
},
};
});
}
@@ -219,12 +278,124 @@ export function UserPublicProfile() {
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
setState((s) => {
if (s.status !== "loaded") return s;
const filtered = s.playlists.filter((p) => !deletedPlaylistIds.has(p.id));
if (filtered.length === s.playlists.length) return s;
return { ...s, playlists: filtered };
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
if (filtered.length === s.playlists.items.length) return s;
return { ...s, playlists: { ...s.playlists, items: filtered } };
});
}, [deletedPlaylistIds]);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = window.scrollY;
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
}, [state, saveDumps, saveVotes, savePlaylists]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
window.scrollTo(0, cachedDumps.scrollY);
scrollRestored.current = true;
}
// cachedDumps is stable (read once), safe to omit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status]);
const loadMoreDumps = useCallback(() => {
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
const nextPage = state.dumps.page + 1;
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: {
items: [...s.dumps.items, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
}, [state, username, token]);
const loadMoreVotes = useCallback(() => {
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
const nextPage = state.votes.page + 1;
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
votes: {
items: [...s.votes.items, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
}, [state, username, token]);
const loadMorePlaylists = useCallback(() => {
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
const nextPage = state.playlists.page + 1;
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
playlists: {
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
}, [state, username, token]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || state.status !== "loaded") return;
@@ -261,10 +432,7 @@ export function UserPublicProfile() {
setState((prev) =>
prev.status === "loaded"
? {
...prev,
user: { ...prev.user, avatarMime: body.data?.avatarMime },
}
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
: prev
);
} catch {
@@ -347,57 +515,91 @@ export function UserPublicProfile() {
<div className="profile-columns">
<DumpList
title={`Dumps (${dumps.length})`}
dumps={dumps}
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
hasMore={dumps.hasMore}
loadingMore={dumps.loadingMore}
onLoadMore={loadMoreDumps}
/>
<UpvotedDumpList
title={`Upvoted (${profileVotedIds.size})`}
dumps={votes}
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`}
dumps={votes.items}
votedIds={profileVotedIds}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
hasMore={votes.hasMore}
loadingMore={votes.loadingMore}
onLoadMore={loadMoreVotes}
/>
</div>
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
Playlists ({playlists.length})
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
</h2>
{isOwnProfile && (
<NewPlaylistForm
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlists.some((pl) => pl.id === p.id)) return s;
return { ...s, playlists: [p, ...s.playlists] };
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
};
})}
/>
)}
</div>
{playlists.length === 0
{playlists.items.length === 0
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="dump-feed">
{playlists.map((p) => <PlaylistCard key={p.id} playlist={p} />)}
{playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
)}
<PlaylistSentinel
hasMore={playlists.hasMore}
loadingMore={playlists.loadingMore}
onLoadMore={loadMorePlaylists}
/>
</section>
</PageShell>
);
}
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
function PlaylistSentinel(
{ hasMore, loadingMore, onLoadMore }: {
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── Plain dump list ──────────────────────────────────────────────────────────
function DumpList(
{
@@ -409,6 +611,9 @@ function DumpList(
castVote,
removeVote,
isOwnProfile,
hasMore,
loadingMore,
onLoadMore,
}: {
title: string;
dumps: Dump[];
@@ -418,9 +623,13 @@ function DumpList(
castVote: (id: string) => void;
removeVote: (id: string) => void;
isOwnProfile?: boolean;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
const navigate = useNavigate();
const [createModalOpen, setCreateModalOpen] = useState(false);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
return (
<section className="profile-section">
<div className="profile-section-header">
@@ -429,12 +638,15 @@ function DumpList(
<button
type="button"
className="new-playlist-toggle"
onClick={() => navigate("/dumps/new")}
onClick={() => setCreateModalOpen(true)}
>
+ New dump
</button>
)}
</div>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
: (
@@ -448,15 +660,18 @@ function DumpList(
canVote={canVote}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</section>
);
}
// ── Upvoted list: fades items out when votes are removed ────────────────────
// ── Upvoted list: fades items out when votes are removed ────────────────────
function UpvotedDumpList(
{
@@ -468,36 +683,33 @@ function UpvotedDumpList(
canVote,
castVote,
removeVote,
hasMore,
loadingMore,
onLoadMore,
}: {
title: string;
dumps: Dump[];
/** Which dumps the profile user currently has voted on. Drives visibility and animation. */
votedIds: Set<string>;
voteCounts: Record<string, number>;
/** Logged-in user's votes — used only for the vote button state on each card. */
myVotes: Set<string>;
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
},
) {
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
// cancels: id → function that aborts the pending removal sequence
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
const cancels = useRef<Map<string, () => void>>(new Map());
// prevVotedIds: null on first render (skip initial diff), then previous votedIds snapshot
const prevVotedIds = useRef<Set<string> | null>(null);
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
useEffect(() => {
// First run: capture baseline without triggering any fades
if (prevVotedIds.current === null) {
prevVotedIds.current = new Set(votedIds);
return;
@@ -505,7 +717,6 @@ function UpvotedDumpList(
const prev = prevVotedIds.current;
// Newly unvoted → start fade (idempotent: skip if already running)
for (const id of prev) {
if (!votedIds.has(id) && !cancels.current.has(id)) {
let dead = false;
@@ -554,7 +765,6 @@ function UpvotedDumpList(
}
}
// Newly re-voted while fading → cancel removal
for (const id of votedIds) {
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
@@ -564,7 +774,6 @@ function UpvotedDumpList(
prevVotedIds.current = new Set(votedIds);
}, [votedIds]);
// Visible = currently voted OR within the fade-out animation window
const visibleDumps = dumps.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
@@ -600,6 +809,8 @@ function UpvotedDumpList(
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</section>
);
}