852 lines
25 KiB
TypeScript
852 lines
25 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { Link, useNavigate, useParams } from "react-router";
|
|
|
|
import { API_URL } from "../config/api.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";
|
|
import { DumpCard } from "../components/DumpCard.tsx";
|
|
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
|
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
|
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 type { Playlist, RawPlaylist } from "../model.ts";
|
|
import { deserializePlaylist } from "../model.ts";
|
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
|
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
|
import { FollowUserButton } from "../components/FollowButton.tsx";
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
function InviteButton() {
|
|
const { authFetch } = useAuth();
|
|
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function generate() {
|
|
try {
|
|
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
|
|
const body = await res.json();
|
|
if (body.success) {
|
|
const url =
|
|
`${globalThis.location.origin}/register?token=${body.data.token}`;
|
|
setInviteUrl(url);
|
|
} else {
|
|
setError("Failed to generate invite");
|
|
}
|
|
} catch {
|
|
setError("Failed to generate invite");
|
|
}
|
|
}
|
|
|
|
async function copy() {
|
|
if (!inviteUrl) return;
|
|
await navigator.clipboard.writeText(inviteUrl);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
|
|
if (inviteUrl) {
|
|
return (
|
|
<div className="invite-result">
|
|
<span className="invite-url">{inviteUrl}</span>
|
|
<button type="button" className="invite-copy-btn" onClick={copy}>
|
|
{copied ? "Copied!" : "Copy"}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="invite-generate">
|
|
<button type="button" className="invite-btn" onClick={generate}>
|
|
+ Invite someone
|
|
</button>
|
|
{error && <p className="form-error">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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" }
|
|
| { status: "error"; error: string }
|
|
| {
|
|
status: "loaded";
|
|
user: PublicUser;
|
|
dumps: PaginatedList<Dump>;
|
|
votes: PaginatedList<Dump>;
|
|
playlists: PaginatedList<Playlist>;
|
|
};
|
|
|
|
export function UserPublicProfile() {
|
|
const { username } = useParams();
|
|
const navigate = useNavigate();
|
|
const { user: me, authFetch, login, logout, token } = useAuth();
|
|
const {
|
|
voteCounts,
|
|
myVotes,
|
|
lastVoteEvent,
|
|
castVote,
|
|
removeVote,
|
|
lastPlaylistEvent,
|
|
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);
|
|
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
|
|
|
useEffect(() => {
|
|
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: HeadersInit = 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?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}`,
|
|
);
|
|
}
|
|
|
|
const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise
|
|
.all([
|
|
userRes.json(),
|
|
dumpsRes.json(),
|
|
votesRes.json(),
|
|
playlistsRes.json(),
|
|
]);
|
|
|
|
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: initialList(
|
|
dumpsData.items.map(deserializeDump),
|
|
dumpsData.hasMore,
|
|
),
|
|
votes: initialList(voteItems, votesData.hasMore),
|
|
playlists: initialList(
|
|
playlistsData.items.map(deserializePlaylist),
|
|
playlistsData.hasMore,
|
|
),
|
|
});
|
|
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
|
|
} catch (err) {
|
|
setState({
|
|
status: "error",
|
|
error: err instanceof Error ? err.message : "Failed to load profile",
|
|
});
|
|
}
|
|
})();
|
|
}, [username]);
|
|
|
|
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
|
|
|
// 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;
|
|
}
|
|
const prev = prevMyVotesRef.current;
|
|
setState((s) => {
|
|
if (s.status !== "loaded") return s;
|
|
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: { ...s.votes, items: [...toAdd, ...s.votes.items] },
|
|
};
|
|
});
|
|
prevMyVotesRef.current = new Set(myVotes);
|
|
}, [myVotes, me, profileUserId]);
|
|
|
|
// Real-time upvoted list sync via WS vote events
|
|
useEffect(() => {
|
|
if (!lastVoteEvent || !profileUserId) return;
|
|
const { dumpId, voterId, action } = lastVoteEvent;
|
|
if (voterId !== profileUserId) return;
|
|
const isOwnProfile = me?.id === profileUserId;
|
|
|
|
if (action === "remove") {
|
|
if (!isOwnProfile) {
|
|
setProfileVotedIds((prev) => {
|
|
const n = new Set(prev);
|
|
n.delete(dumpId);
|
|
return n;
|
|
});
|
|
}
|
|
} else {
|
|
if (!isOwnProfile) {
|
|
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
|
}
|
|
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.items.some((d) => d.id === dumpId)
|
|
) {
|
|
return s;
|
|
}
|
|
return {
|
|
...s,
|
|
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
|
};
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}, [lastVoteEvent, me, profileUserId]);
|
|
|
|
// Real-time playlist updates
|
|
useEffect(() => {
|
|
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
|
const profileUserId = state.user.id;
|
|
const isOwnProfile = me?.id === profileUserId;
|
|
const ev = lastPlaylistEvent;
|
|
|
|
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
|
|
if (ev.playlist.isPublic || isOwnProfile) {
|
|
setState((s) => {
|
|
if (s.status !== "loaded") return s;
|
|
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?.userId === profileUserId) {
|
|
setState((s) => {
|
|
if (s.status !== "loaded") return s;
|
|
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,
|
|
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
}, [lastPlaylistEvent, me]);
|
|
|
|
useEffect(() => {
|
|
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
|
setState((s) => {
|
|
if (s.status !== "loaded") return s;
|
|
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 = globalThis.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);
|
|
};
|
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => {
|
|
globalThis.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") {
|
|
globalThis.scrollTo(0, cachedDumps.scrollY);
|
|
scrollRestored.current = true;
|
|
}
|
|
}, [state.status, cachedDumps]);
|
|
|
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file || state.status !== "loaded") return;
|
|
|
|
setAvatarError(null);
|
|
setUploading(true);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const res = await authFetch(`${API_URL}/api/avatars/me`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
const body = await res.json() as {
|
|
success: boolean;
|
|
data?: RawUser;
|
|
error?: { message: string };
|
|
};
|
|
|
|
if (!res.ok || !body.success) {
|
|
setAvatarError(body.error?.message ?? "Upload failed");
|
|
return;
|
|
}
|
|
|
|
const storedRaw = localStorage.getItem("authResponse");
|
|
if (storedRaw && body.data) {
|
|
login({
|
|
...deserializeAuthResponse(JSON.parse(storedRaw)),
|
|
user: deserializeUser(body.data),
|
|
});
|
|
}
|
|
|
|
setState((prev) =>
|
|
prev.status === "loaded"
|
|
? {
|
|
...prev,
|
|
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
|
}
|
|
: prev
|
|
);
|
|
} catch {
|
|
setAvatarError("Upload failed");
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
if (state.status === "loading") {
|
|
return (
|
|
<PageShell>
|
|
<p className="page-loading">Loading profile…</p>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
if (state.status === "error") {
|
|
return (
|
|
<PageError
|
|
message={state.error}
|
|
actions={
|
|
<>
|
|
<button
|
|
className="logout-btn"
|
|
type="button"
|
|
onClick={() => navigate("/")}
|
|
>
|
|
← Back
|
|
</button>
|
|
{me && (
|
|
<button className="logout-btn" type="button" onClick={logout}>
|
|
Log out
|
|
</button>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const { user: profileUser, dumps, votes, playlists } = state;
|
|
const isOwnProfile = me?.username === profileUser.username;
|
|
|
|
return (
|
|
<PageShell>
|
|
<div className="profile-header">
|
|
<div className="profile-avatar-wrapper">
|
|
<Avatar
|
|
userId={profileUser.id}
|
|
username={profileUser.username}
|
|
hasAvatar={!!profileUser.avatarMime}
|
|
size={72}
|
|
/>
|
|
{isOwnProfile && (
|
|
<label className="avatar-change-overlay" title="Change avatar">
|
|
{uploading ? "…" : "✎"}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
onChange={handleAvatarUpload}
|
|
disabled={uploading}
|
|
style={{ display: "none" }}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h1 className="profile-username">{profileUser.username}</h1>
|
|
{profileUser.invitedByUsername
|
|
? (
|
|
<p className="profile-invited-by">
|
|
invited by{" "}
|
|
<Link
|
|
to={`/users/${profileUser.invitedByUsername}`}
|
|
className="profile-invited-by-link"
|
|
>
|
|
@{profileUser.invitedByUsername}
|
|
</Link>
|
|
</p>
|
|
)
|
|
: (
|
|
<p className="profile-invited-by profile-invited-by--founding">
|
|
O.G.
|
|
</p>
|
|
)}
|
|
{avatarError && <p className="form-error">{avatarError}</p>}
|
|
{!isOwnProfile && (
|
|
<FollowUserButton
|
|
targetUserId={profileUser.id}
|
|
targetUsername={profileUser.username}
|
|
/>
|
|
)}
|
|
{isOwnProfile && (
|
|
<div className="profile-own-actions">
|
|
<InviteButton />
|
|
<button type="button" className="logout-btn" onClick={logout}>
|
|
Log out
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="profile-columns">
|
|
<DumpList
|
|
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
|
dumps={dumps.items}
|
|
voteCounts={voteCounts}
|
|
myVotes={myVotes}
|
|
canVote={!!me}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
isOwnProfile={isOwnProfile}
|
|
viewAllHref={`/users/${profileUser.username}/dumps`}
|
|
/>
|
|
|
|
<UpvotedDumpList
|
|
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`}
|
|
dumps={votes.items}
|
|
votedIds={profileVotedIds}
|
|
voteCounts={voteCounts}
|
|
myVotes={myVotes}
|
|
canVote={!!me}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
viewAllHref={`/users/${profileUser.username}/upvoted`}
|
|
/>
|
|
</div>
|
|
|
|
<section className="profile-section" id="playlists">
|
|
<div className="profile-section-header">
|
|
<h2 className="profile-section-title">
|
|
Playlists ({playlists.items.length}
|
|
{playlists.hasMore ? "+" : ""})
|
|
</h2>
|
|
{isOwnProfile && (
|
|
<NewPlaylistForm
|
|
onCreated={(p) =>
|
|
setState((s) => {
|
|
if (s.status !== "loaded") return s;
|
|
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
|
|
return {
|
|
...s,
|
|
playlists: {
|
|
...s.playlists,
|
|
items: [p, ...s.playlists.items],
|
|
},
|
|
};
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
{playlists.items.length === 0
|
|
? <p className="empty-state">No playlists yet.</p>
|
|
: (
|
|
<ul className="dump-feed">
|
|
{playlists.items.map((p) => (
|
|
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
{playlists.items.length > 0 && (
|
|
<Link
|
|
to={`/users/${profileUser.username}/playlists`}
|
|
className="profile-view-all"
|
|
>
|
|
View all →
|
|
</Link>
|
|
)}
|
|
</section>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
// ── Plain dump list ──────────────────────────────────────────────────────────
|
|
|
|
function DumpList(
|
|
{
|
|
title,
|
|
dumps,
|
|
voteCounts,
|
|
myVotes,
|
|
canVote,
|
|
castVote,
|
|
removeVote,
|
|
isOwnProfile,
|
|
viewAllHref,
|
|
}: {
|
|
title: string;
|
|
dumps: Dump[];
|
|
voteCounts: Record<string, number>;
|
|
myVotes: Set<string>;
|
|
canVote: boolean;
|
|
castVote: (id: string) => void;
|
|
removeVote: (id: string) => void;
|
|
isOwnProfile?: boolean;
|
|
viewAllHref: string;
|
|
},
|
|
) {
|
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
return (
|
|
<section className="profile-section">
|
|
<div className="profile-section-header">
|
|
<h2 className="profile-section-title">{title}</h2>
|
|
{isOwnProfile && (
|
|
<button
|
|
type="button"
|
|
className="new-playlist-toggle"
|
|
onClick={() => setCreateModalOpen(true)}
|
|
>
|
|
+ New dump
|
|
</button>
|
|
)}
|
|
</div>
|
|
{createModalOpen && (
|
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
|
)}
|
|
{dumps.length === 0
|
|
? <p className="empty-state">Nothing here yet.</p>
|
|
: (
|
|
<ul className="dump-feed">
|
|
{dumps.map((dump) => (
|
|
<DumpCard
|
|
key={dump.id}
|
|
dump={dump}
|
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
|
voted={myVotes.has(dump.id)}
|
|
canVote={canVote}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
isOwner={isOwnProfile}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{dumps.length > 0 && (
|
|
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ── Upvoted list: fades items out when votes are removed ─────────────────────
|
|
|
|
function UpvotedDumpList(
|
|
{
|
|
title,
|
|
dumps,
|
|
votedIds,
|
|
voteCounts,
|
|
myVotes,
|
|
canVote,
|
|
castVote,
|
|
removeVote,
|
|
viewAllHref,
|
|
}: {
|
|
title: string;
|
|
dumps: Dump[];
|
|
votedIds: Set<string>;
|
|
voteCounts: Record<string, number>;
|
|
myVotes: Set<string>;
|
|
canVote: boolean;
|
|
castVote: (id: string) => void;
|
|
removeVote: (id: string) => void;
|
|
viewAllHref: string;
|
|
},
|
|
) {
|
|
const [fading, setFading] = useState<
|
|
Record<string, "cooldown" | "dismissing">
|
|
>({});
|
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
|
const prevVotedIds = useRef<Set<string> | null>(null);
|
|
|
|
useEffect(() => () => {
|
|
cancels.current.forEach((c) => c());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (prevVotedIds.current === null) {
|
|
prevVotedIds.current = new Set(votedIds);
|
|
return;
|
|
}
|
|
|
|
const prev = prevVotedIds.current;
|
|
|
|
for (const id of prev) {
|
|
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
|
let dead = false;
|
|
let kill = () => {};
|
|
kill = () => {
|
|
dead = true;
|
|
setFading((f) => {
|
|
const n = { ...f };
|
|
delete n[id];
|
|
return n;
|
|
});
|
|
cancels.current.delete(id);
|
|
};
|
|
cancels.current.set(id, () => kill());
|
|
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
|
|
|
const t1 = setTimeout(() => {
|
|
if (dead) return;
|
|
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
|
const t2 = setTimeout(() => {
|
|
if (!dead) kill();
|
|
}, 350);
|
|
kill = () => {
|
|
dead = true;
|
|
clearTimeout(t2);
|
|
setFading((f) => {
|
|
const n = { ...f };
|
|
delete n[id];
|
|
return n;
|
|
});
|
|
cancels.current.delete(id);
|
|
};
|
|
}, 2000);
|
|
|
|
kill = () => {
|
|
dead = true;
|
|
clearTimeout(t1);
|
|
setFading((f) => {
|
|
const n = { ...f };
|
|
delete n[id];
|
|
return n;
|
|
});
|
|
cancels.current.delete(id);
|
|
};
|
|
cancels.current.set(id, () => kill());
|
|
}
|
|
}
|
|
|
|
for (const id of votedIds) {
|
|
if (!prev.has(id) && cancels.current.has(id)) {
|
|
cancels.current.get(id)!();
|
|
}
|
|
}
|
|
|
|
prevVotedIds.current = new Set(votedIds);
|
|
}, [votedIds]);
|
|
|
|
const visibleDumps = dumps.filter((d) =>
|
|
votedIds.has(d.id) || d.id in fading
|
|
);
|
|
|
|
return (
|
|
<section className="profile-section">
|
|
<div className="profile-section-header">
|
|
<h2 className="profile-section-title">{title}</h2>
|
|
</div>
|
|
{visibleDumps.length === 0
|
|
? <p className="empty-state">Nothing here yet.</p>
|
|
: (
|
|
<ul className="dump-feed">
|
|
{visibleDumps.map((dump) => {
|
|
const phase = fading[dump.id];
|
|
const extraCls = phase === "cooldown"
|
|
? "dump-card--fading"
|
|
: phase === "dismissing"
|
|
? "dump-card--dismissing"
|
|
: undefined;
|
|
return (
|
|
<DumpCard
|
|
key={dump.id}
|
|
dump={dump}
|
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
|
voted={myVotes.has(dump.id)}
|
|
canVote={canVote}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
className={extraCls}
|
|
/>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
{visibleDumps.length > 0 && (
|
|
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|