338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
/**
|
|
* Renders the page that lists all of a user's playlists, the ones they made and
|
|
* the ones they follow.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { Link, useParams } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
|
|
import { API_URL } from "../config/api.ts";
|
|
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
|
import type { Playlist } from "../../shared/playlist.ts";
|
|
import type { PublicUser } from "../../shared/user.ts";
|
|
import {
|
|
deserializePlaylist,
|
|
hydratePlaylist,
|
|
} from "../utils/shared/deserialize.ts";
|
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
|
import { useWS } from "../hooks/ws/useWS.ts";
|
|
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
|
import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts";
|
|
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
|
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
|
import {
|
|
useScrollRestore,
|
|
useScrollSave,
|
|
} from "../hooks/feed/useScrollSave.ts";
|
|
import {
|
|
canLoadMore,
|
|
mapItems,
|
|
type PagedList,
|
|
pageUrl,
|
|
readFirstPage,
|
|
readPage,
|
|
restoredList,
|
|
withFirst,
|
|
} from "../utils/feed/pagedList.ts";
|
|
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
|
|
import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts";
|
|
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
|
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
|
import { ProfileSection } from "../components/profile/ProfileSection.tsx";
|
|
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
|
|
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
|
|
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
|
|
import { PageShell } from "../components/layout/PageShell.tsx";
|
|
import { PageError } from "../components/layout/PageError.tsx";
|
|
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
|
|
|
type State =
|
|
| { status: "loading" }
|
|
| { status: "error"; error: string }
|
|
| {
|
|
status: "loaded";
|
|
profileUser: PublicUser;
|
|
created: PagedList<Playlist>;
|
|
followed: PagedList<Playlist>;
|
|
};
|
|
|
|
/**
|
|
* Renders `/users/:username/playlists`, for anyone. It lists the playlists the
|
|
* user made (the private ones only to them) and the public ones they follow,
|
|
* from `GET /api/users/:username/playlists` and `…/followed-playlists`, a page
|
|
* at a time, and the socket keeps both lists live. The user can make and
|
|
* delete playlists here. Going back to the page restores both lists and the
|
|
* scroll position from sessionStorage.
|
|
*/
|
|
export function UserPlaylists(): React.JSX.Element {
|
|
const { username } = useParams();
|
|
const { user: me, authFetch } = 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;
|
|
|
|
type SetList = (
|
|
fn: (list: PagedList<Playlist>) => PagedList<Playlist>,
|
|
) => void;
|
|
const setCreatedList = useCallback<SetList>(
|
|
(fn) =>
|
|
setState((s) =>
|
|
s.status !== "loaded" ? s : { ...s, created: fn(s.created) }
|
|
),
|
|
[],
|
|
);
|
|
const setFollowedList = useCallback<SetList>(
|
|
(fn) =>
|
|
setState((s) =>
|
|
s.status !== "loaded" ? s : { ...s, followed: fn(s.followed) }
|
|
),
|
|
[],
|
|
);
|
|
const setCreated = useCallback(
|
|
(fn: (prev: Playlist[]) => Playlist[]) => setCreatedList(mapItems(fn)),
|
|
[setCreatedList],
|
|
);
|
|
const setFollowed = useCallback(
|
|
(fn: (prev: Playlist[]) => Playlist[]) => setFollowedList(mapItems(fn)),
|
|
[setFollowedList],
|
|
);
|
|
const createdItems = state.status === "loaded" ? state.created.items : [];
|
|
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
|
? (lastPlaylistEvent.playlist ?? null)
|
|
: null;
|
|
// Before usePlaylistListSync: it saves a playlist's place before the sync
|
|
// takes it out, and puts it back there when it turns public again.
|
|
usePositionAwareSync(
|
|
createdItems,
|
|
setCreated,
|
|
lastPlaylistItem,
|
|
(p) => p.isPublic && p.userId === profileUserId,
|
|
);
|
|
usePlaylistListSync(setCreated, {
|
|
isOwner: isOwnProfile,
|
|
ownerId: profileUserId ?? undefined,
|
|
});
|
|
|
|
usePlaylistListSync(setFollowed, { noNewEntries: true });
|
|
|
|
const createdUrl = `${API_URL}/api/users/${username}/playlists`;
|
|
const followedUrl = `${API_URL}/api/users/${username}/followed-playlists`;
|
|
const fetchPage = (url: string) => (page: number) =>
|
|
authFetch(pageUrl(url, page))
|
|
.then((res) => readPage(res, deserializePlaylist));
|
|
|
|
useEffect(() => {
|
|
if (!username) return;
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
const firstPageOf = (url: string) =>
|
|
authFetch(pageUrl(url, 1), { signal })
|
|
.then((res) => readFirstPage(res, deserializePlaylist));
|
|
|
|
// Back on the page, the lists come from the cache and only the user is
|
|
// fetched again.
|
|
const cached = cachedCreated && cachedFollowed;
|
|
Promise.all([
|
|
fetchPublicUser(username, signal),
|
|
cached ? restoredList(cachedCreated) : firstPageOf(createdUrl),
|
|
cached ? restoredList(cachedFollowed) : firstPageOf(followedUrl),
|
|
])
|
|
.then(([profileUser, created, followed]) =>
|
|
setState({ status: "loaded", profileUser, created, followed })
|
|
)
|
|
.catch((err) => {
|
|
if (signal.aborted) return;
|
|
setState({ status: "error", error: friendlyFetchError(err) });
|
|
});
|
|
return () => controller.abort();
|
|
}, [
|
|
username,
|
|
cachedCreated,
|
|
cachedFollowed,
|
|
authFetch,
|
|
createdUrl,
|
|
followedUrl,
|
|
]);
|
|
|
|
const createdList = state.status === "loaded" ? state.created : null;
|
|
const followedList = state.status === "loaded" ? state.followed : null;
|
|
const createdSentinelRef = useInfiniteScroll(
|
|
useLoadMore(createdList, setCreatedList, fetchPage(createdUrl)),
|
|
canLoadMore(createdList),
|
|
);
|
|
const followedSentinelRef = useInfiniteScroll(
|
|
useLoadMore(followedList, setFollowedList, fetchPage(followedUrl)),
|
|
canLoadMore(followedList),
|
|
);
|
|
|
|
useScrollSave(
|
|
state.status === "loaded",
|
|
useCallback((y) => {
|
|
if (state.status !== "loaded") return;
|
|
saveCreated(state.created, y);
|
|
saveFollowed(state.followed, y);
|
|
}, [state, saveCreated, saveFollowed]),
|
|
);
|
|
|
|
useScrollRestore(cachedCreated?.scrollY, state.status === "loaded");
|
|
|
|
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;
|
|
}
|
|
setCreated((items) => items.filter((p) => p.id !== playlistId));
|
|
};
|
|
|
|
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) => setCreated((items) => withFirst(items, p))}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
{deleteError && (
|
|
<ErrorCard title={t`Could not delete`} message={deleteError} />
|
|
)}
|
|
|
|
<ProfileSection
|
|
title={
|
|
<Trans>
|
|
Created ({created.items.length}
|
|
{created.hasMore ? "+" : ""})
|
|
</Trans>
|
|
}
|
|
>
|
|
{created.items.length === 0
|
|
? (
|
|
<p className="empty-state">
|
|
<Trans>No playlists yet.</Trans>
|
|
</p>
|
|
)
|
|
: (
|
|
<>
|
|
<PlaylistCardList
|
|
playlists={created.items}
|
|
onDelete={isOwnProfile
|
|
? (p) => setConfirmDeleteId(p.id)
|
|
: undefined}
|
|
/>
|
|
<FeedFooter
|
|
hasMore={created.hasMore}
|
|
loadingMore={created.loadingMore}
|
|
sentinelRef={createdSentinelRef}
|
|
/>
|
|
</>
|
|
)}
|
|
</ProfileSection>
|
|
|
|
<ProfileSection
|
|
title={
|
|
<Trans>
|
|
Followed ({followed.items.length}
|
|
{followed.hasMore ? "+" : ""})
|
|
</Trans>
|
|
}
|
|
>
|
|
{followed.items.length === 0
|
|
? (
|
|
<p className="empty-state">
|
|
<Trans>No followed playlists yet.</Trans>
|
|
</p>
|
|
)
|
|
: (
|
|
<>
|
|
<PlaylistCardList playlists={followed.items} />
|
|
<FeedFooter
|
|
hasMore={followed.hasMore}
|
|
loadingMore={followed.loadingMore}
|
|
sentinelRef={followedSentinelRef}
|
|
/>
|
|
</>
|
|
)}
|
|
</ProfileSection>
|
|
|
|
{confirmDeleteId && (
|
|
<ConfirmModal
|
|
message={t`Delete this playlist? This cannot be undone.`}
|
|
confirmLabel={t`Delete playlist`}
|
|
onConfirm={() => {
|
|
handleDelete(confirmDeleteId);
|
|
setConfirmDeleteId(null);
|
|
}}
|
|
onCancel={() => setConfirmDeleteId(null)}
|
|
/>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|