v1 review pass: fixed some minor bugs

This commit is contained in:
khannurien
2026-03-16 11:08:39 +00:00
parent e88fed4e98
commit 867e64cb5b
37 changed files with 1228 additions and 400 deletions

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useParams } from "react-router";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PublicUser } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { relativeTime } from "../utils/relativeTime.ts";
@@ -13,6 +14,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
import { VoteButton } from "../components/VoteButton.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
type DumpState =
| { status: "loading" }
@@ -22,6 +24,7 @@ type DumpState =
export function Dump() {
const { selectedDump } = useParams();
const location = useLocation();
const navigate = useNavigate();
const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null;
const [dumpState, setDumpState] = useState<DumpState>(
@@ -38,7 +41,7 @@ export function Dump() {
if (preloaded) {
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`)
.then((r) => r.json())
.then((r) => r.success && setOp(r.data))
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
.catch(() => {});
return;
}
@@ -48,16 +51,18 @@ export function Dump() {
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
const dump: Dump = apiResponse.data;
const dump: Dump = deserializeDump(apiResponse.data);
setDumpState({ status: "loaded", dump });
fetch(`${API_URL}/api/users/by-id/${dump.userId}`)
.then((r) => r.json())
.then((r) => r.success && setOp(r.data))
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
.catch(() => {});
} catch (err) {
setDumpState({
@@ -66,22 +71,39 @@ export function Dump() {
});
}
})();
}, [selectedDump]);
}, [selectedDump, preloaded]);
if (dumpState.status === "loading") {
return <PageShell><p className="page-loading">Loading dump</p></PageShell>;
return (
<PageShell>
<p className="page-loading">Loading dump</p>
</PageShell>
);
}
if (dumpState.status === "error") {
return (
<PageShell>
<div className="page-error">
<h2>Error</h2>
<p>{dumpState.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
<Link to="/"> Back to all dumps</Link>
</div>
</PageShell>
<PageError
message={dumpState.error}
actions={
<>
<button
className="logout-btn"
type="button"
onClick={() => globalThis.location.reload()}
>
Retry
</button>
<button
className="logout-btn"
type="button"
onClick={() => navigate("/")}
>
Back to all dumps
</button>
</>
}
/>
);
}
@@ -112,9 +134,17 @@ export function Dump() {
size={22}
/>
{op
? <Link to={`/users/${op.username}`} className="dump-op-link">{op.username}</Link>
? (
<Link to={`/users/${op.username}`} className="dump-op-link">
{op.username}
</Link>
)
: <span className="dump-op-link"></span>}
<time className="dump-card-date" dateTime={dump.createdAt} title={new Date(dump.createdAt).toLocaleString()}>
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
title={dump.createdAt.toLocaleString()}
>
{relativeTime(dump.createdAt)}
</time>
</div>
@@ -133,7 +163,12 @@ export function Dump() {
: dump.richContent
? <RichContentCard richContent={dump.richContent} />
: (
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
<a
href={dump.url}
target="_blank"
rel="noopener noreferrer"
className="dump-url-link"
>
{dump.url}
</a>
)}

View File

@@ -24,19 +24,25 @@ type UrlPreview =
| { status: "done"; richContent: RichContent | null };
function LocalFilePreview({ file }: { file: File }) {
const src = URL.createObjectURL(file);
const [src, setSrc] = useState<string | null>(null);
const mime = file.type;
useEffect(() => () => URL.revokeObjectURL(src), [src]);
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return null;
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
}
if (mime.startsWith("video/")) {
return <MediaPlayer src={src} kind="video" mime={mime} />;
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
return <MediaPlayer src={src} kind="audio" />;
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
return (
<div className="local-preview-generic">
@@ -78,15 +84,22 @@ export function DumpCreate() {
setUrlPreview({ status: "loading" });
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`);
const res = await fetch(
`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`,
);
const body = await res.json();
setUrlPreview({ status: "done", richContent: body.success ? body.data : null });
setUrlPreview({
status: "done",
richContent: body.success ? body.data : null,
});
} catch {
setUrlPreview({ status: "done", richContent: null });
}
}, 600);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [url]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
@@ -173,8 +186,8 @@ export function DumpCreate() {
} catch { /* not a URL */ }
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler);
}, []);
return (
@@ -186,7 +199,11 @@ export function DumpCreate() {
<button
type="button"
className={mode === "url" ? "active" : ""}
onClick={() => { setMode("url"); setFile(null); setState({ status: "idle" }); }}
onClick={() => {
setMode("url");
setFile(null);
setState({ status: "idle" });
}}
disabled={submitting}
>
🔗 URL
@@ -194,7 +211,12 @@ export function DumpCreate() {
<button
type="button"
className={mode === "file" ? "active" : ""}
onClick={() => { setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); setState({ status: "idle" }); }}
onClick={() => {
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setState({ status: "idle" });
}}
disabled={submitting}
>
📎 File
@@ -202,83 +224,89 @@ export function DumpCreate() {
</div>
</div>
<form onSubmit={handleSubmit} className="dump-create-form dump-form">
{state.status === "error" && (
<p className="form-error">{state.error}</p>
)}
{mode === "url"
? (
<>
<div className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
setState({ status: "idle" });
}
}}
disabled={submitting}
placeholder="https://..."
required
autoFocus
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</p>
)}
{urlPreview.status === "done" && urlPreview.richContent && (
<RichContentCard richContent={urlPreview.richContent} />
)}
</>
)
: (
<>
<div className="form-group">
<label htmlFor="file">File</label>
<input
id="file"
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={submitting}
required
/>
</div>
{file && <LocalFilePreview file={file} />}
</>
<form onSubmit={handleSubmit} className="dump-create-form dump-form">
{state.status === "error" && (
<p className="form-error">{state.error}</p>
)}
<div className="form-group">
<label htmlFor="comment">Why are you dumping this?</label>
<textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
</div>
{mode === "url"
? (
<>
<div key="url-field" className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setUrl("");
setUrlPreview({ status: "idle" });
setFile(pastedFile);
setState({ status: "idle" });
}
}}
disabled={submitting}
placeholder="https://..."
required
autoFocus
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">Fetching preview</p>
)}
{urlPreview.status === "done" && urlPreview.richContent && (
<RichContentCard richContent={urlPreview.richContent} />
)}
</>
)
: (
<>
<div key="file-field" className="form-group">
<label htmlFor="file">File</label>
<input
id="file"
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={submitting}
required
/>
</div>
{file && <LocalFilePreview file={file} />}
</>
)}
<div className="form-actions">
<div className="form-actions-right">
<Link to="/" className="form-cancel">Cancel</Link>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? (mode === "url" ? "Fetching…" : "Uploading…") : "Dump it"}
</button>
<div className="form-group">
<label htmlFor="comment">Why are you dumping this?</label>
<textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={submitting}
placeholder="Tell the community what makes this worth their time..."
rows={3}
/>
</div>
</div>
</form>
<div className="form-actions">
<div className="form-actions-right">
<Link to="/" className="form-cancel">Cancel</Link>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting
? (mode === "url" ? "Fetching…" : "Uploading…")
: "Dump it"}
</button>
</div>
</div>
</form>
</div>
</PageShell>
);

View File

@@ -3,9 +3,11 @@ import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
@@ -31,13 +33,15 @@ export function DumpEdit() {
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
const dump: Dump = apiResponse.data;
const dump: Dump = deserializeDump(apiResponse.data);
setUrl(dump.url ?? "");
setComment(dump.comment ?? "");
setState({ status: "loaded", dump });
@@ -83,11 +87,14 @@ export function DumpEdit() {
const apiResponse = await res.json();
if (!apiResponse.success) {
setState({ status: "error", error: apiResponse.error?.message ?? "Update failed" });
setState({
status: "error",
error: apiResponse.error?.message ?? "Update failed",
});
return;
}
const updatedDump: Dump = apiResponse.data;
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
setNewFile(null);
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
@@ -109,19 +116,36 @@ export function DumpEdit() {
};
if (state.status === "loading") {
return <PageShell><p className="page-loading">Loading dump</p></PageShell>;
return (
<PageShell>
<p className="page-loading">Loading dump</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageShell>
<div className="page-error">
<h2>Error</h2>
<p>{state.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
<Link to="/"> Back to all dumps</Link>
</div>
</PageShell>
<PageError
message={state.error}
actions={
<>
<button
className="logout-btn"
type="button"
onClick={() => globalThis.location.reload()}
>
Retry
</button>
<button
className="logout-btn"
type="button"
onClick={() => navigate("/")}
>
Back to all dumps
</button>
</>
}
/>
);
}
@@ -141,7 +165,12 @@ export function DumpEdit() {
: dump.richContent
? <RichContentCard richContent={dump.richContent} />
: dump.url && (
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
<a
href={dump.url}
target="_blank"
rel="noopener noreferrer"
className="dump-url-link"
>
{dump.url}
</a>
)}
@@ -149,7 +178,10 @@ export function DumpEdit() {
<form
className="dump-form"
onSubmit={(e) => { e.preventDefault(); handleSave(); }}
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
{dump.kind === "url"
? (
@@ -178,7 +210,9 @@ export function DumpEdit() {
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
/>
{newFile && (
<p className="file-input-info">{newFile.name} {formatBytes(newFile.size)}</p>
<p className="file-input-info">
{newFile.name} {formatBytes(newFile.size)}
</p>
)}
</div>
)}
@@ -199,7 +233,9 @@ export function DumpEdit() {
Delete dump
</button>
<div className="form-actions-right">
<Link to={`/dumps/${dump.id}`} className="form-cancel">Cancel</Link>
<Link to={`/dumps/${dump.id}`} className="form-cancel">
Cancel
</Link>
<button type="submit" className="btn-primary">Save</button>
</div>
</div>

View File

@@ -1,14 +1,17 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { type Dump } from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { AppHeader } from "../components/AppHeader.tsx";
import { API_URL } from "../config/api.ts";
import { deserializeDump, type Dump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
@@ -17,18 +20,29 @@ type DumpsState =
type SortMode = "new" | "hot";
function hotScore(dump: Dump): number {
const ageHours = (Date.now() - new Date(dump.createdAt).getTime()) / 3_600_000;
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
}
export function Index() {
const location = useLocation();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)?.deletedDumpId;
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
const { user } = useAuth();
const { onlineUsers, voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote } = useWS();
const {
onlineUsers,
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
castVote,
removeVote,
} = useWS();
const [dumpsState, setDumpsState] = useState<DumpsState>({ status: "loading" });
const [dumpsState, setDumpsState] = useState<DumpsState>({
status: "loading",
});
const [sort, setSort] = useState<SortMode>("hot");
useEffect(() => {
@@ -37,9 +51,15 @@ export function Index() {
const res = await fetch(`${API_URL}/api/dumps/`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
setDumpsState({ status: "loaded", dumps: body.data });
setDumpsState({
status: "loaded",
dumps: body.data.map(deserializeDump),
});
} catch (err) {
setDumpsState({ status: "error", error: err instanceof Error ? err.message : "Failed to load" });
setDumpsState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
});
}
})();
}, []);
@@ -54,14 +74,24 @@ export function Index() {
const sortedDumps = [...combined].sort(
sort === "hot"
? (a, b) => hotScore(b) - hotScore(a)
: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
const presenceRow = (
<div className="index-presence">
{onlineUsers.map((u) => (
<Link key={u.userId} to={`/users/${u.username}`} title={u.username} className="index-presence-avatar">
<Avatar userId={u.userId} username={u.username} hasAvatar={u.hasAvatar} size={32} />
<Link
key={u.userId}
to={`/users/${u.username}`}
title={u.username}
className="index-presence-avatar"
>
<Avatar
userId={u.userId}
username={u.username}
hasAvatar={u.hasAvatar}
size={32}
/>
</Link>
))}
</div>
@@ -69,19 +99,33 @@ export function Index() {
const sortButtons = !loading && !error && combined.length > 0 && (
<div className="feed-sort">
<button className={`feed-sort-btn${sort === "hot" ? " active" : ""}`} onClick={() => setSort("hot")}>Hot</button>
<button className={`feed-sort-btn${sort === "new" ? " active" : ""}`} onClick={() => setSort("new")}>New</button>
<button
type="button"
className={`feed-sort-btn${sort === "hot" ? " active" : ""}`}
onClick={() => setSort("hot")}
>
Hot
</button>
<button
type="button"
className={`feed-sort-btn${sort === "new" ? " active" : ""}`}
onClick={() => setSort("new")}
>
New
</button>
</div>
);
return (
<div className="index-page">
<AppHeader centerSlot={
<div className="header-center-slot">
{presenceRow}
{sortButtons}
</div>
} />
<AppHeader
centerSlot={
<div className="header-center-slot">
{presenceRow}
{sortButtons}
</div>
}
/>
{/* Shown only on narrow viewports */}
<div className="index-below-header">
@@ -98,7 +142,6 @@ export function Index() {
{!loading && !error && combined.length > 0 && (
<>
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard

View File

@@ -3,6 +3,7 @@ import type { SubmitEvent } from "react";
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
@@ -38,7 +39,7 @@ export function UserLogin() {
const apiResponse = await res.json();
if (apiResponse.success) {
login(apiResponse.data);
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({ status: "error", error: apiResponse.error.message });
@@ -76,7 +77,11 @@ export function UserLogin() {
required
disabled={state.status === "submitting"}
/>
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Logging in…" : "Log in"}
</button>
</form>

View File

@@ -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

View File

@@ -3,6 +3,7 @@ import type { SubmitEvent } from "react";
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
@@ -38,7 +39,7 @@ export function UserRegister() {
const apiResponse = await res.json();
if (apiResponse.success) {
login(apiResponse.data);
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({ status: "error", error: apiResponse.error.message });
@@ -76,7 +77,11 @@ export function UserRegister() {
required
disabled={state.status === "submitting"}
/>
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Registering…" : "Register"}
</button>
</form>