Files
gerbeur/src/pages/UserPublicProfile.tsx

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>
);
}