Files
gerbeur/src/pages/UserPlaylists.tsx
khannurien 07d0344be7 v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:30:28 +00:00

494 lines
15 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type {
PaginatedData,
Playlist,
PublicUser,
RawPlaylist,
} from "../model.ts";
import {
deserializePlaylist,
deserializePublicUser,
hydratePlaylist,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useWS } from "../hooks/useWS.ts";
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 { useScrollSave } from "../hooks/useScrollSave.ts";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { expectOk } from "../components/form/index.ts";
interface PlaylistFeed {
items: Playlist[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
created: PlaylistFeed;
followed: PlaylistFeed;
};
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
return { items, hasMore, page: 1, loadingMore: false };
}
export function UserPlaylists() {
const { username } = useParams();
const { user: me, authFetch, token } = useAuth();
useDocumentTitle(username ?? null);
const { lastPlaylistEvent } = useWS();
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
Playlist
>(
`feed:user-playlists:${username ?? ""}`,
hydratePlaylist,
);
const { cached: cachedFollowed, saveState: saveFollowed } = useFeedCache<
Playlist
>(
`feed:user-followed-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<State>({ status: "loading" });
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
}
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId;
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, created: { ...s.created, items: fn(s.created.items) } }
);
}, []);
const createdItems = state.status === "loaded" ? state.created.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
: null;
usePositionAwareSync(
createdItems,
setCreated,
lastPlaylistItem,
(p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setCreated, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
skipReinsert: true,
});
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
);
}, []);
usePlaylistListSync(setFollowed, { noNewEntries: true });
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
if (cachedCreated && cachedFollowed) {
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),
created: {
items: cachedCreated.items,
hasMore: cachedCreated.hasMore,
page: cachedCreated.page,
loadingMore: false,
},
followed: {
items: cachedFollowed.items,
hasMore: cachedFollowed.hasMore,
page: cachedFollowed.page,
loadingMore: false,
},
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
),
])
.then(([userRes, createdRes, followedRes]) =>
Promise.all([userRes.json(), createdRes.json(), followedRes.json()])
)
.then(([userBody, createdBody, followedBody]) => {
if (!userBody.success) throw new Error("User not found");
const createdData = createdBody.success
? createdBody.data as PaginatedData<RawPlaylist>
: { items: [], hasMore: false };
const followedData = followedBody.success
? followedBody.data as PaginatedData<RawPlaylist>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
created: initialFeed(
createdData.items.map(deserializePlaylist),
createdData.hasMore,
),
followed: initialFeed(
followedData.items.map(deserializePlaylist),
followedData.hasMore,
),
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, cachedCreated, cachedFollowed, token]);
const loadMoreCreated = useCallback(() => {
if (
state.status !== "loaded" || !state.created.hasMore ||
state.created.loadingMore || !username
) return;
const nextPage = state.created.page + 1;
setState((s) =>
s.status === "loaded"
? { ...s, created: { ...s.created, loadingMore: true } }
: s
);
fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
created: {
items: [...s.created.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded"
? { ...s, created: { ...s.created, loadingMore: false } }
: s
)
);
}, [state, username, token]);
const loadMoreFollowed = useCallback(() => {
if (
state.status !== "loaded" || !state.followed.hasMore ||
state.followed.loadingMore || !username
) return;
const nextPage = state.followed.page + 1;
setState((s) =>
s.status === "loaded"
? { ...s, followed: { ...s.followed, loadingMore: true } }
: s
);
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
setState((s) =>
s.status === "loaded"
? {
...s,
followed: {
items: [...s.followed.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded"
? { ...s, followed: { ...s.followed, loadingMore: false } }
: s
)
);
}, [state, username]);
const createdSentinelRef = useInfiniteScroll(
loadMoreCreated,
state.status === "loaded" && state.created.hasMore &&
!state.created.loadingMore,
);
const followedSentinelRef = useInfiniteScroll(
loadMoreFollowed,
state.status === "loaded" && state.followed.hasMore &&
!state.followed.loadingMore,
);
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(() => {
if (cachedCreated?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cachedCreated.scrollY);
scrollRestored.current = true;
}
}, [state.status, cachedCreated]);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (playlistId: string) => {
setDeleteError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
if (!res.ok) await expectOk(res); // throws the server's message
} catch (err) {
setDeleteError(friendlyFetchError(err));
return;
}
setState((s) =>
s.status === "loaded"
? {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => p.id !== playlistId),
},
}
: s
);
};
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">
<Trans>Loading…</Trans>
</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
<Trans>← Back to profile</Trans>
</Link>
}
/>
);
}
const { profileUser, created, followed } = state;
return (
<PageShell>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Playlists`}
actions={isOwnProfile && (
<NewPlaylistButton
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] },
};
})}
/>
)}
/>
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Created ({created.items.length}
{created.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{created.items.length === 0
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{created.items.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
isOwner={isOwnProfile}
onDelete={isOwnProfile
? () => setConfirmDeleteId(p.id)
: undefined}
/>
))}
</ul>
)}
{created.hasMore && <div ref={createdSentinelRef} />}
{created.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!created.hasMore && created.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{followed.items.length === 0
? (
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{followed.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
)}
{followed.hasMore && <div ref={followedSentinelRef} />}
{followed.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!followed.hasMore && followed.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
{confirmDeleteId && (
<ConfirmModal
message={t`Delete this playlist? This cannot be undone.`}
confirmLabel={t`Delete playlist`}
onConfirm={() => {
handleDelete(confirmDeleteId);
setConfirmDeleteId(null);
}}
onCancel={() => setConfirmDeleteId(null)}
/>
)}
</PageShell>
);
}