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(null); const [copied, setCopied] = useState(false); const [error, setError] = useState(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 (
{inviteUrl}
); } return (
{error &&

{error}

}
); } const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump); const hydratePlaylist = (raw: Playlist): Playlist => deserializePlaylist(raw as unknown as RawPlaylist); interface PaginatedList { items: T[]; hasMore: boolean; page: number; loadingMore: boolean; } function initialList(items: T[], hasMore: boolean): PaginatedList { return { items, hasMore, page: 1, loadingMore: false }; } type ProfileState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; user: PublicUser; dumps: PaginatedList; votes: PaginatedList; playlists: PaginatedList; }; 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( `feed:profile-dumps:${username ?? ""}`, hydrateDump, ); const { cached: cachedVotes, saveState: saveVotes } = useFeedCache( `feed:profile-votes:${username ?? ""}`, hydrateDump, ); const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< Playlist >( `feed:profile-playlists:${username ?? ""}`, hydratePlaylist, ); const [state, setState] = useState({ status: "loading" }); const [uploading, setUploading] = useState(false); const [avatarError, setAvatarError] = useState(null); const [profileVotedIds, setProfileVotedIds] = useState>( new Set(), ); const fileInputRef = useRef(null); const prevMyVotesRef = useRef | 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 = votesBody.success ? votesBody.data : { items: [], total: 0, hasMore: false }; const playlistsData: PaginatedData = playlistsBody.success ? playlistsBody.data : { items: [], total: 0, hasMore: false }; const dumpsData: PaginatedData = 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; 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) => { 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 (

Loading profile…

); } if (state.status === "error") { return ( {me && ( )} } /> ); } const { user: profileUser, dumps, votes, playlists } = state; const isOwnProfile = me?.username === profileUser.username; return (
{isOwnProfile && ( )}

{profileUser.username}

{profileUser.invitedByUsername ? (

invited by{" "} @{profileUser.invitedByUsername}

) : (

O.G.

)} {avatarError &&

{avatarError}

} {!isOwnProfile && ( )} {isOwnProfile && (
)}

Playlists ({playlists.items.length} {playlists.hasMore ? "+" : ""})

{isOwnProfile && ( 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], }, }; })} /> )}
{playlists.items.length === 0 ?

No playlists yet.

: (
    {playlists.items.map((p) => ( ))}
)} {playlists.items.length > 0 && ( View all → )}
); } // ── Plain dump list ────────────────────────────────────────────────────────── function DumpList( { title, dumps, voteCounts, myVotes, canVote, castVote, removeVote, isOwnProfile, viewAllHref, }: { title: string; dumps: Dump[]; voteCounts: Record; myVotes: Set; canVote: boolean; castVote: (id: string) => void; removeVote: (id: string) => void; isOwnProfile?: boolean; viewAllHref: string; }, ) { const [createModalOpen, setCreateModalOpen] = useState(false); return (

{title}

{isOwnProfile && ( )}
{createModalOpen && ( setCreateModalOpen(false)} /> )} {dumps.length === 0 ?

Nothing here yet.

: (
    {dumps.map((dump) => ( ))}
)} {dumps.length > 0 && ( View all → )}
); } // ── 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; voteCounts: Record; myVotes: Set; canVote: boolean; castVote: (id: string) => void; removeVote: (id: string) => void; viewAllHref: string; }, ) { const [fading, setFading] = useState< Record >({}); const cancels = useRef void>>(new Map()); const prevVotedIds = useRef | 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 (

{title}

{visibleDumps.length === 0 ?

Nothing here yet.

: (
    {visibleDumps.map((dump) => { const phase = fading[dump.id]; const extraCls = phase === "cooldown" ? "dump-card--fading" : phase === "dismissing" ? "dump-card--dismissing" : undefined; return ( ); })}
)} {visibleDumps.length > 0 && ( View all → )}
); }