v1 review pass: fixed some minor bugs
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { AuthResponse, Dump, PublicUser } from "../model.ts";
|
||||
import type { Dump, PublicUser } from "../model.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
deserializeDump,
|
||||
deserializePublicUser,
|
||||
deserializeUser,
|
||||
type RawUser,
|
||||
} from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.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";
|
||||
|
||||
@@ -16,12 +24,18 @@ type ProfileState =
|
||||
|
||||
export function UserPublicProfile() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, login } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const { user: me, authFetch, login, logout } = useAuth();
|
||||
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||
|
||||
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(),
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
@@ -38,7 +52,11 @@ export function UserPublicProfile() {
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error(userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`);
|
||||
throw new Error(
|
||||
userRes.status === 404
|
||||
? "User not found"
|
||||
: `HTTP ${userRes.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [userBody, dumpsBody, votesBody] = await Promise.all([
|
||||
@@ -47,37 +65,88 @@ export function UserPublicProfile() {
|
||||
votesRes.json(),
|
||||
]);
|
||||
|
||||
const votes: Dump[] = votesBody.success
|
||||
? votesBody.data.map(deserializeDump)
|
||||
: [];
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: userBody.data,
|
||||
dumps: dumpsBody.success ? dumpsBody.data : [],
|
||||
votes: votesBody.success ? votesBody.data : [],
|
||||
user: deserializePublicUser(userBody.data),
|
||||
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
|
||||
votes,
|
||||
});
|
||||
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
|
||||
} catch (err) {
|
||||
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" });
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load profile",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [username]);
|
||||
|
||||
// Add newly-voted own dumps to the Upvoted list.
|
||||
// Removals are handled inside UpvotedDumpList (with fade animation).
|
||||
// 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.
|
||||
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.map((d) => d.id));
|
||||
const toAdd = s.dumps.filter((d) => myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id));
|
||||
const toAdd = s.dumps.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] };
|
||||
});
|
||||
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes]);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
|
||||
// Real-time upvoted list sync for any profile 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]));
|
||||
}
|
||||
// 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)) {
|
||||
return s;
|
||||
}
|
||||
return { ...s, votes: [dump, ...s.votes] };
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [lastVoteEvent, me, profileUserId]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -90,8 +159,15 @@ export function UserPublicProfile() {
|
||||
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?: AuthResponse["user"]; error?: { message: string } };
|
||||
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");
|
||||
@@ -100,12 +176,20 @@ export function UserPublicProfile() {
|
||||
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw && body.data) {
|
||||
login({ ...(JSON.parse(storedRaw) as AuthResponse), user: 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);
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} catch {
|
||||
setAvatarError("Upload failed");
|
||||
} finally {
|
||||
@@ -115,18 +199,34 @@ export function UserPublicProfile() {
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <PageShell><p className="page-loading">Loading profile…</p></PageShell>;
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading profile…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{state.error}</p>
|
||||
<Link to="/">← Back</Link>
|
||||
</div>
|
||||
</PageShell>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,6 +260,11 @@ export function UserPublicProfile() {
|
||||
<div>
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||
{isOwnProfile && (
|
||||
<button type="button" className="logout-btn" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,8 +280,9 @@ export function UserPublicProfile() {
|
||||
/>
|
||||
|
||||
<UpvotedDumpList
|
||||
title={`Upvoted (${votes.filter((d) => myVotes.has(d.id)).length})`}
|
||||
title={`Upvoted (${profileVotedIds.size})`}
|
||||
dumps={votes}
|
||||
votedIds={profileVotedIds}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
canVote={!!me}
|
||||
@@ -190,15 +296,17 @@ export function UserPublicProfile() {
|
||||
|
||||
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
|
||||
|
||||
function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
}) {
|
||||
function DumpList(
|
||||
{ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<h2>{title}</h2>
|
||||
@@ -225,44 +333,65 @@ function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, remove
|
||||
|
||||
// ── Upvoted list: fades items out when votes are removed ────────────────────
|
||||
|
||||
function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
}) {
|
||||
function UpvotedDumpList(
|
||||
{
|
||||
title,
|
||||
dumps,
|
||||
votedIds,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
canVote,
|
||||
castVote,
|
||||
removeVote,
|
||||
}: {
|
||||
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;
|
||||
},
|
||||
) {
|
||||
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
|
||||
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
|
||||
// cancels: id → function that aborts the pending removal sequence
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
// prevVotes: null on first render (skip initial diff), then previous myVotes snapshot
|
||||
const prevVotes = useRef<Set<string> | null>(null);
|
||||
// prevVotedIds: null on first render (skip initial diff), then previous votedIds snapshot
|
||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => () => { cancels.current.forEach((c) => c()); }, []);
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// First run: capture baseline without triggering any fades
|
||||
if (prevVotes.current === null) {
|
||||
prevVotes.current = new Set(myVotes);
|
||||
if (prevVotedIds.current === null) {
|
||||
prevVotedIds.current = new Set(votedIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevVotes.current;
|
||||
const prev = prevVotedIds.current;
|
||||
|
||||
// Newly unvoted → start fade (idempotent: skip if already running)
|
||||
for (const id of prev) {
|
||||
if (!myVotes.has(id) && !cancels.current.has(id)) {
|
||||
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
||||
let dead = false;
|
||||
// We update `kill` in-place so the cancel ref always points to the right cleanup
|
||||
let kill = () => {};
|
||||
kill = () => {
|
||||
dead = true;
|
||||
setFading((f) => { const n = { ...f }; delete n[id]; return n; });
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
@@ -271,30 +400,49 @@ function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote,
|
||||
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); };
|
||||
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);
|
||||
|
||||
// Override kill so cancelling before t1 fires clears t1
|
||||
const killT1 = kill;
|
||||
void killT1; // used below
|
||||
kill = () => { dead = true; clearTimeout(t1); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); };
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t1);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
}
|
||||
|
||||
// Newly re-voted while fading → cancel removal
|
||||
for (const id of myVotes) {
|
||||
for (const id of votedIds) {
|
||||
if (!prev.has(id) && cancels.current.has(id)) {
|
||||
cancels.current.get(id)!();
|
||||
}
|
||||
}
|
||||
|
||||
prevVotes.current = new Set(myVotes);
|
||||
}, [myVotes]);
|
||||
prevVotedIds.current = new Set(votedIds);
|
||||
}, [votedIds]);
|
||||
|
||||
// Visible = currently voted OR within the fade-out animation window
|
||||
const visibleDumps = dumps.filter((d) => myVotes.has(d.id) || d.id in fading);
|
||||
const visibleDumps = dumps.filter((d) =>
|
||||
votedIds.has(d.id) || d.id in fading
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="profile-section">
|
||||
@@ -305,8 +453,10 @@ function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote,
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const extraCls = phase === "cooldown" ? "dump-card--fading"
|
||||
: phase === "dismissing" ? "dump-card--dismissing"
|
||||
const extraCls = phase === "cooldown"
|
||||
? "dump-card--fading"
|
||||
: phase === "dismissing"
|
||||
? "dump-card--dismissing"
|
||||
: undefined;
|
||||
return (
|
||||
<DumpCard
|
||||
|
||||
Reference in New Issue
Block a user