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