v3: the profile page is split by one rule, and what it re-invented is shared across the app — UserPublicProfile was 1,949 lines because it held its data, six tabs, seven subcomponents and hand-rolled copies of things the app already had or repeated elsewhere. The page now only picks the tab and puts the pieces together (253 lines); useProfile holds the user and the three lists with their live sync, cache and scroll; each tab and header piece is one file under components/profile/; the followed and invitees tabs load on first open through the new useFetchState, keyed by username, which keeps them across tab switches. Every piece taken out replaces its copies: DumpCardList, which reads the votes from WSContext, is every list of dump cards (the index feeds, followed, search, related dumps, UserDumps, UserUpvoted, the profile), so Index no longer threads vote props into the feeds; PlaylistCardList every list of playlist cards; FeedFooter the eight sentinel/loading-more/end blocks; ListState the loading/error/empty chains; Segmented the settings toggles, RoleSwitcher and the playlist edit form, with SegmentedField built on it; LoadMoreButton the three load-more buttons; NewDumpButton UserDumps' and the profile's; useScrollRestore four copies of the scroll restore, and the profile uses useScrollSave instead of saving its whole cache on every scroll. useFading takes an onFaded callback, which replaces PlaylistDetail's copy of its timers; useUpvotedList is the vote tracking UserUpvoted and the profile each had, and a withdrawn upvote now leaves the list and the cache once faded (it was only hidden, and came back after a tab switch). usePlaylistMemberships is the membership load and toggle AddToPlaylistModal and DumpCreateModal each had. pagedList.ts (PagedList, firstPage, restoredList, fetchFirstPage, withFirst), fetchPublicUser and dataOr are shared by the profile, UserPlaylists and useUserDumpFeed; a refused side list still shows empty, as documented, and only the user fails the page. FollowedFeed's two sections are one local hook, and its readPage is expectOk. A card is its owner's when the item is the signed-in user's, everywhere: the profile's upvoted column marked every dump as the owner's on one's own profile. No message changed; the catalogs only move their references. 643 frontend tests pass (11 new for useFetchState, useFading's onFaded and pagedList), and tsc, eslint, doc:lint, deno check and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ import { can } from "../utils/auth/permissions.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
||||
import { fadeClassName, useFading } from "../hooks/feed/useFading.ts";
|
||||
import { useWS } from "../hooks/ws/useWS.ts";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
@@ -48,6 +49,7 @@ import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { TextEditor } from "../components/editor/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { VisibilitySegmented } from "../components/form/SegmentedField.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { FormError } from "../components/form/FormError.tsx";
|
||||
@@ -98,11 +100,19 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// A removed dump shows faded for 2 seconds, while it can be undone, then
|
||||
// slides out ("dismissing"), as on the upvoted page.
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
// slides out, as on the upvoted page, and leaves `state.playlist.dumps`.
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading(
|
||||
(id) =>
|
||||
setState((prev) =>
|
||||
prev.status !== "loaded" ? prev : {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
|
||||
},
|
||||
}
|
||||
),
|
||||
);
|
||||
// An undo re-adds the dump, which the server puts first, then restores its
|
||||
// place. This holds that place's order until both requests are done, so the
|
||||
// socket's echo of the first doesn't flash the dump at the top.
|
||||
@@ -139,7 +149,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
|
||||
}, []);
|
||||
|
||||
@@ -176,9 +185,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
setActiveDumpIds(ids);
|
||||
dumpOrderRef.current = order;
|
||||
for (const id of ids) knownDumpIdsRef.current.add(id);
|
||||
setFading({});
|
||||
cancels.current.forEach((c) => c());
|
||||
cancels.current.clear();
|
||||
cancelAll();
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
dumpReorderTimerRef.current = null;
|
||||
@@ -191,77 +198,13 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
error: friendlyFetchError(err),
|
||||
});
|
||||
});
|
||||
}, [playlistId, token]);
|
||||
}, [playlistId, token, cancelAll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [fetchPlaylist]);
|
||||
|
||||
// Fades out dump `id`, then drops it from `state.playlist.dumps`. Its entry
|
||||
// in `cancels` stops the fade at whichever step it is.
|
||||
function startFade(id: string) {
|
||||
if (cancels.current.has(id)) return;
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||
|
||||
const t1 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||
|
||||
const t2 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
|
||||
},
|
||||
};
|
||||
});
|
||||
kill();
|
||||
}, 350);
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t2);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}, 2000);
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t1);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
|
||||
// The socket's changes to this playlist: its dumps, its details, its end.
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
@@ -283,7 +226,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
startFade(id);
|
||||
startFading(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +234,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
// "loading", which unsets playlistUUID and runs this effect again.
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
cancels.current.get(id)?.();
|
||||
cancelFading(id);
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
@@ -366,7 +309,14 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
}
|
||||
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
|
||||
}, [
|
||||
lastPlaylistEvent,
|
||||
playlistUUID,
|
||||
navigate,
|
||||
token,
|
||||
startFading,
|
||||
cancelFading,
|
||||
]);
|
||||
|
||||
// A deleted dump leaves at once, without the removal's fade.
|
||||
useEffect(() => {
|
||||
@@ -497,7 +447,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
cancels.current.get(dumpId)?.();
|
||||
cancelFading(dumpId);
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// The fade may have taken it out of the list already.
|
||||
@@ -516,12 +466,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFade(dumpId);
|
||||
startFading(dumpId);
|
||||
};
|
||||
|
||||
const handleCancelRemove = (dumpId: string) => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
cancels.current.get(dumpId)?.();
|
||||
cancelFading(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// A fading dump keeps its slot in `state.playlist.dumps`, so that order is
|
||||
// the one to restore.
|
||||
@@ -559,7 +509,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFade(dumpId);
|
||||
startFading(dumpId);
|
||||
setActionError(t`The dump couldn't be put back. Try again.`);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -753,11 +703,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
const phase = fading[dump.id];
|
||||
// drag index is within the active-only list
|
||||
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
|
||||
const cardCls = phase === "cooldown"
|
||||
? "dump-card--fading"
|
||||
: phase === "dismissing"
|
||||
? "dump-card--dismissing"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -786,7 +731,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={cardCls}
|
||||
className={fadeClassName(phase)}
|
||||
isOwner={!!user && user.id === dump.userId}
|
||||
/>
|
||||
{canEdit && (isActive
|
||||
@@ -980,22 +925,11 @@ function PlaylistEditForm(
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="visibility-toggle playlist-edit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={field.value ? "active" : ""}
|
||||
onClick={() => field.onChange(true)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!field.value ? "active" : ""}
|
||||
onClick={() => field.onChange(false)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user