vibe coded v1

This commit is contained in:
khannurien
2026-03-16 07:34:49 +00:00
parent 6207a7549f
commit e88fed4e98
48 changed files with 4303 additions and 595 deletions

View File

@@ -0,0 +1,328 @@
import React, { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { AuthResponse, Dump, PublicUser } from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
type ProfileState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; user: PublicUser; dumps: Dump[]; votes: Dump[] };
export function UserPublicProfile() {
const { username } = useParams();
const { user: me, authFetch, login } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
const [state, setState] = useState<ProfileState>({ status: "loading" });
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
(async () => {
try {
const [userRes, dumpsRes, votesRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}/dumps`),
fetch(`${API_URL}/api/users/${username}/votes`),
]);
if (!userRes.ok) {
throw new Error(userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`);
}
const [userBody, dumpsBody, votesBody] = await Promise.all([
userRes.json(),
dumpsRes.json(),
votesRes.json(),
]);
setState({
status: "loaded",
user: userBody.data,
dumps: dumpsBody.success ? dumpsBody.data : [],
votes: votesBody.success ? votesBody.data : [],
});
} catch (err) {
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).
useEffect(() => {
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));
if (toAdd.length === 0) return s;
return { ...s, votes: [...toAdd, ...s.votes] };
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes]);
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?: AuthResponse["user"]; 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({ ...(JSON.parse(storedRaw) as AuthResponse), user: 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 (
<PageShell>
<div className="page-error">
<h2>Error</h2>
<p>{state.error}</p>
<Link to="/"> Back</Link>
</div>
</PageShell>
);
}
const { user: profileUser, dumps, votes } = 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>
{avatarError && <p className="form-error">{avatarError}</p>}
</div>
</div>
<div className="profile-columns">
<DumpList
title={`Dumps (${dumps.length})`}
dumps={dumps}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
/>
<UpvotedDumpList
title={`Upvoted (${votes.filter((d) => myVotes.has(d.id)).length})`}
dumps={votes}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
/>
</div>
</PageShell>
);
}
// ── 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;
}) {
return (
<section className="profile-section">
<h2>{title}</h2>
{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}
/>
))}
</ul>
)}
</section>
);
}
// ── 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;
}) {
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
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);
useEffect(() => () => { cancels.current.forEach((c) => c()); }, []);
useEffect(() => {
// First run: capture baseline without triggering any fades
if (prevVotes.current === null) {
prevVotes.current = new Set(myVotes);
return;
}
const prev = prevVotes.current;
// Newly unvoted → start fade (idempotent: skip if already running)
for (const id of prev) {
if (!myVotes.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; });
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);
// 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); };
cancels.current.set(id, () => kill());
}
}
// Newly re-voted while fading → cancel removal
for (const id of myVotes) {
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
}
}
prevVotes.current = new Set(myVotes);
}, [myVotes]);
// Visible = currently voted OR within the fade-out animation window
const visibleDumps = dumps.filter((d) => myVotes.has(d.id) || d.id in fading);
return (
<section className="profile-section">
<h2>{title}</h2>
{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>
)}
</section>
);
}