v3: code quality pass

This commit is contained in:
khannurien
2026-03-24 18:47:05 +00:00
parent cd4076343b
commit c293f3e706
39 changed files with 1464 additions and 1555 deletions

View File

@@ -5,11 +5,18 @@ import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
import type { Comment, Dump, PublicUser, RawComment } from "../model.ts";
import type {
Comment,
Dump,
PublicUser,
RawComment,
RawDump,
} from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializePublicUser,
parseAPIResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -79,9 +86,9 @@ export function Dump() {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
throw new Error(apiResponse.error?.message ?? "Failed to load dump");
throw new Error(apiResponse.error.message);
}
const dump: Dump = deserializeDump(apiResponse.data);
setDumpState({ status: "loaded", dump });

View File

@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
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 type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts";
@@ -45,7 +45,7 @@ export function DumpEdit() {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump: Dump = deserializeDump(apiResponse.data);
@@ -54,10 +54,7 @@ export function DumpEdit() {
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
} else {
setState({
status: "error",
error: apiResponse.error?.message ?? "Failed to load.",
});
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
@@ -92,12 +89,9 @@ export function DumpEdit() {
});
}
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
setState({
status: "error",
error: apiResponse.error?.message ?? "Update failed.",
});
setState({ status: "error", error: apiResponse.error.message });
return;
}

View File

@@ -26,6 +26,7 @@ import {
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useScrollSave } from "../hooks/useScrollSave.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
@@ -448,74 +449,39 @@ export function Index() {
!dumpsState.loadingMore,
);
useEffect(() => {
if (dumpsState.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (dumpsState.status === "loaded") {
saveState(
dumpsState.dumps,
dumpsState.page,
dumpsState.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [dumpsState, saveState]);
useScrollSave(
dumpsState.status === "loaded",
useCallback((y) => {
if (dumpsState.status !== "loaded") return;
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
}, [dumpsState, saveState]),
);
useEffect(() => {
if (followedUsersDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedUsersDumps.status === "loaded") {
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedUsersDumps, saveFollowedUsers]);
useScrollSave(
followedUsersDumps.status === "loaded",
useCallback((y) => {
if (followedUsersDumps.status !== "loaded") return;
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
y,
);
}, [followedUsersDumps, saveFollowedUsers]),
);
useEffect(() => {
if (followedPlaylistsDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedPlaylistsDumps.status === "loaded") {
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
useScrollSave(
followedPlaylistsDumps.status === "loaded",
useCallback((y) => {
if (followedPlaylistsDumps.status !== "loaded") return;
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
y,
);
}, [followedPlaylistsDumps, saveFollowedPlaylists]),
);
// ── Scroll restoration ──

View File

@@ -6,11 +6,14 @@ import type {
RawDump,
RawPlaylist,
RawPlaylistWithDumps,
ReorderPlaylistRequest,
UpdatePlaylistRequest,
} from "../model.ts";
import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -59,6 +62,16 @@ export function PlaylistDetail() {
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
// While an undo-remove is in flight (POST re-add + PUT reorder), holds the
// desired dump order so intermediate WS dumps_updated events don't cause glitches.
const pendingUndoOrderRef = useRef<string[] | null>(null);
// Debounce timer for the reorder setState in dumps_updated so that rapid
// consecutive events (POST re-add followed immediately by PUT reorder) are
// coalesced — only the final order is applied, preventing the glitch on
// other clients who don't have pendingUndoOrderRef.
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
// without stale closure issues (state would only update on next render).
@@ -90,6 +103,7 @@ export function PlaylistDetail() {
useEffect(() => () => {
cancels.current.forEach((c) => c());
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
}, []);
const fetchAbortRef = useRef<AbortController | null>(null);
@@ -126,6 +140,10 @@ export function PlaylistDetail() {
setFading({});
cancels.current.forEach((c) => c());
cancels.current.clear();
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
dumpReorderTimerRef.current = null;
}
})
.catch((err) => {
if (err.name === "AbortError") return;
@@ -272,25 +290,36 @@ export function PlaylistDetail() {
}
}
// Apply the server-authoritative order: active dumps in ev.dumpIds order,
// fading dumps (not in newIds) appended at the end.
setState((s) => {
if (s.status !== "loaded") return s;
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
return {
...s,
playlist: {
...s.playlist,
dumps: [
...ev.dumpIds!
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!),
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
],
},
};
});
dumpOrderRef.current = ev.dumpIds!;
// Debounce the reorder setState so rapid consecutive dumps_updated events
// (e.g. POST re-add followed immediately by PUT reorder during an undo)
// coalesce into a single update — only the final order is applied.
// On the owner's client, pendingUndoOrderRef also suppresses the wrong
// intermediate order; this debounce protects other clients on the WS.
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
}
const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
const serverOrder = ev.dumpIds!;
dumpReorderTimerRef.current = setTimeout(() => {
dumpReorderTimerRef.current = null;
setState((s) => {
if (s.status !== "loaded") return s;
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
const orderedActive = orderToApply
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!);
let ai = 0;
// Replace each active slot with the next server-ordered active dump;
// fading dumps keep their current slot unchanged.
const merged = s.playlist.dumps.map((d) =>
newIds.has(d.id) ? orderedActive[ai++] : d
);
// Append any newly added dumps not yet in the array.
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
return { ...s, playlist: { ...s.playlist, dumps: merged } };
});
dumpOrderRef.current = serverOrder;
}, 80);
} else if (ev.type === "updated" && ev.playlist) {
setState((prev) => {
if (prev.status !== "loaded") return prev;
@@ -416,7 +445,11 @@ export function PlaylistDetail() {
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dumpIds: activeDumps.map((d) => d.id) }),
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
});
} catch {
fetchPlaylist();
@@ -442,13 +475,36 @@ export function PlaylistDetail() {
};
const handleCancelRemove = (dumpId: string) => {
if (!playlistId) return;
if (!playlistId || state.status !== "loaded") return;
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// Re-add server-side since DELETE already fired
// Capture the desired order now (dump is still in playlist.dumps at its
// original position; activeDumpIds hasn't been updated yet in this closure).
const restoredIds = new Set([...activeDumpIds, dumpId]);
const desiredOrder = state.playlist.dumps
.filter((d) => restoredIds.has(d.id))
.map((d) => d.id);
// Hold the desired order so the WS handler ignores the intermediate
// dumps_updated event from the POST (which puts the dump at the top).
pendingUndoOrderRef.current = desiredOrder;
// Re-add server-side since DELETE already fired, then immediately restore
// the original position (addDumpToPlaylist would otherwise put it at top).
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "POST",
}).catch(() => {});
})
.then(() =>
authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
),
})
)
.finally(() => {
pendingUndoOrderRef.current = null;
})
.catch(() => {});
};
const openEdit = () => {
@@ -472,19 +528,20 @@ export function PlaylistDetail() {
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
}),
body: JSON.stringify(
{
...(editTitle !== state.playlist.title
? { title: editTitle }
: {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
} satisfies UpdatePlaylistRequest,
),
},
);
const updateJson = await updateRes.json() as {
success: boolean;
data: RawPlaylist;
};
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
const updatedPlaylist = updateJson.success
? deserializePlaylist(updateJson.data)
: null;

View File

@@ -1,200 +1,47 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useState } from "react";
import { Link, useParams } from "react-router";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserDumps() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { user: me } = useAuth();
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
const { state, setItems, sentinelRef } = useUserDumpFeed(
username,
"dumps",
`feed:user-dumps-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const [createModalOpen, setCreateModalOpen] = useState(false);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId;
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
}, []);
const dumpItems = state.status === "loaded" ? state.dumps : [];
const dumpItems = state.status === "loaded" ? state.items : [];
usePositionAwareSync(
dumpItems,
setDumps,
setItems,
lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, {
useDumpListSync(setItems, {
ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
skipReinsert: true,
});
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
if (cached) {
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, dumpsRes]) =>
Promise.all([userRes.json(), dumpsRes.json()])
)
.then(([userBody, dumpsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = dumpsBody.success
? dumpsBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
@@ -216,36 +63,24 @@ export function UserDumps() {
);
}
const { profileUser, dumps, hasMore, loadingMore } = state;
const { profileUser, items: dumps, hasMore, loadingMore } = state;
return (
<PageShell>
<div className="profile-subpage-header">
<Link
to={`/users/${username}`}
className="profile-subpage-back"
>
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Dumps</h1>
{isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
</button>
)}
</div>
</div>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Dumps"
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
</button>
)}
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />

View File

@@ -3,7 +3,12 @@ import type { SubmitEvent } from "react";
import { useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import {
deserializeAuthResponse,
type LoginRequest,
parseAPIResponse,
type RawAuthResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
@@ -26,26 +31,23 @@ export function UserLogin() {
setState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const res = await fetch(`${API_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password } satisfies LoginRequest),
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({
status: "error",
error: apiResponse.error?.message ?? "Login failed.",
});
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });

View File

@@ -26,9 +26,10 @@ import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { useScrollSave } from "../hooks/useScrollSave.ts";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
@@ -283,35 +284,24 @@ export function UserPlaylists() {
!state.followed.loadingMore,
);
// Scroll save
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;
saveCreated(
state.created.items,
state.created.page,
state.created.hasMore,
y,
);
saveFollowed(
state.followed.items,
state.followed.page,
state.followed.hasMore,
y,
);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveCreated, saveFollowed]);
useScrollSave(
state.status === "loaded",
useCallback((y) => {
if (state.status !== "loaded") return;
saveCreated(
state.created.items,
state.created.page,
state.created.hasMore,
y,
);
saveFollowed(
state.followed.items,
state.followed.page,
state.followed.hasMore,
y,
);
}, [state, saveCreated, saveFollowed]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
@@ -364,34 +354,25 @@ export function UserPlaylists() {
return (
<PageShell>
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Playlists"
actions={isOwnProfile && (
<NewPlaylistForm
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.created.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
created: { ...s.created, items: [p, ...s.created.items] },
};
})}
/>
<h1 className="profile-subpage-title">Playlists</h1>
{isOwnProfile && (
<NewPlaylistForm
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.created.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
created: { ...s.created, items: [p, ...s.created.items] },
};
})}
/>
)}
</div>
</div>
)}
/>
<section className="profile-section">
<div className="profile-section-header">

View File

@@ -13,11 +13,12 @@ import {
deserializeAuthResponse,
deserializeDump,
deserializePublicUser,
deserializeUser,
hydrateDump,
hydratePlaylist,
parseAPIResponse,
type RawDump,
type RawUser,
type RawPublicUser,
type UpdateUserRequest,
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
@@ -478,28 +479,24 @@ export function UserPublicProfile() {
method: "POST",
body: formData,
});
const body = await res.json() as {
success: boolean;
data?: RawUser;
error?: { message: string };
};
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!res.ok || !body.success) {
setAvatarError(body.error?.message ?? "Upload failed");
if (!body.success) {
setAvatarError(body.error.message);
return;
}
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw && body.data) {
if (storedRaw) {
login({
...deserializeAuthResponse(JSON.parse(storedRaw)),
user: deserializeUser(body.data),
user: deserializePublicUser(body.data),
});
}
setState((prev) =>
prev.status === "loaded" && body.data
? { ...prev, user: deserializeUser(body.data) }
prev.status === "loaded"
? { ...prev, user: deserializePublicUser(body.data) }
: prev
);
} catch {
@@ -517,11 +514,16 @@ export function UserPublicProfile() {
try {
const res = await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
body: JSON.stringify({ description: descDraft.trim() }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
description: descDraft.trim() || undefined,
} satisfies UpdateUserRequest,
),
});
const body = await res.json();
if (!res.ok || !body.success) {
setDescError(body.error?.message ?? "Failed to save");
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setDescError(body.error.message);
return;
}
setState((s) =>
@@ -949,6 +951,7 @@ function UpvotedDumpList(
canVote={canVote}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
className={extraCls}
/>
);

View File

@@ -3,7 +3,12 @@ import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { API_URL, VALIDATION } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import {
deserializeAuthResponse,
parseAPIResponse,
type RawAuthResponse,
type RegisterRequest,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
@@ -47,26 +52,25 @@ export function UserRegister() {
setFormState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, inviteToken: token }),
body: JSON.stringify(
{ username, password, inviteToken: token } satisfies RegisterRequest,
),
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setFormState({
status: "error",
error: apiResponse.error?.message ?? "Registration failed.",
});
setFormState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setFormState({ status: "error", error: friendlyFetchError(err) });

View File

@@ -1,139 +1,60 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
votes: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserUpvoted() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { user: me } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
`feed:user-upvoted-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
}, []);
useDumpListSync(setVotesDumps);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const { fading, startFading, cancelFading, cancelAll } = useFading();
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
cancelAll();
setVotedIds(new Set());
prevMyVotesRef.current = null;
const controller = new AbortController();
const onItemsAppended = useCallback((newItems: Dump[]) => {
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
}, []);
if (cached) {
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
const voteIds = new Set(cached.items.map((d) => d.id));
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
votes: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
setVotedIds(voteIds);
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const { state, setState, setItems, sentinelRef } = useUserDumpFeed(
username,
"votes",
`feed:user-upvoted-full:${username ?? ""}`,
{ onItemsAppended },
);
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, votesRes]) =>
Promise.all([userRes.json(), votesRes.json()])
)
.then(([userBody, votesBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = votesBody.success
? votesBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
const voteItems = items.map(deserializeDump);
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
votes: voteItems,
hasMore,
page: 1,
loadingMore: false,
});
setVotedIds(new Set(voteItems.map((d) => d.id)));
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
useDumpListSync(setItems);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
// Own profile: keep votedIds in sync with myVotes.
// Fading is triggered directly here to avoid a gap render between
// setVotedIds and the old prevVotedIds tracking effect.
// Reset vote tracking when username changes
useEffect(() => {
cancelAll();
setVotedIds(new Set());
prevMyVotesRef.current = null;
}, [username]);
// Seed votedIds once items are loaded
useEffect(() => {
if (state.status !== "loaded") return;
setVotedIds(new Set(state.items.map((d) => d.id)));
}, [state.status]);
// Own profile: keep votedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// First sync after load: initialize without animating the diff.
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -157,7 +78,6 @@ export function UserUpvoted() {
n.delete(dumpId);
return n;
});
// Start fading in same batch so visibleDumps never has a gap render.
startFading(dumpId);
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
@@ -168,82 +88,16 @@ export function UserUpvoted() {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
if (s.status !== "loaded" || s.items.some((d) => d.id === dumpId)) {
return s;
}
return { ...s, votes: [dump, ...s.votes] };
return { ...s, items: [dump, ...s.items] };
});
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
? {
...s,
votes: [...s.votes, ...newItems],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.votes, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
@@ -265,27 +119,18 @@ export function UserUpvoted() {
);
}
const { profileUser, votes, hasMore, loadingMore } = state;
const { profileUser, items: votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<PageShell>
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Upvoted</h1>
</div>
</div>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Upvoted"
/>
{visibleDumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
@@ -308,6 +153,7 @@ export function UserUpvoted() {
castVote={castVote}
removeVote={removeVote}
className={extraCls}
isOwner={!!me && me.id === dump.userId}
/>
);
})}