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 { 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/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"; 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(); 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({ status: "loading" }); const [prevUsername, setPrevUsername] = useState(username); if (prevUsername !== username) { setPrevUsername(username); setState({ status: "loading" }); } const [confirmDeleteId, setConfirmDeleteId] = useState(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 : { items: [], hasMore: false }; const followedData = followedBody.success ? followedBody.data as PaginatedData : { 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; 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; 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 handleDelete = async (playlistId: string) => { await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE", }); setState((s) => s.status === "loaded" ? { ...s, created: { ...s.created, items: s.created.items.filter((p) => p.id !== playlistId), }, } : s ); }; if (state.status === "loading") { return (

Loading…

); } if (state.status === "error") { return ( ← Back to profile } /> ); } const { profileUser, created, followed } = state; return ( 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] }, }; })} /> )} />

Created ({created.items.length} {created.hasMore ? "+" : ""})

{created.items.length === 0 ? (

No playlists yet.

) : (
    {created.items.map((p) => ( setConfirmDeleteId(p.id) : undefined} /> ))}
)} {created.hasMore &&
} {created.loadingMore && (

Loading more…

)} {!created.hasMore && created.items.length > 0 && (

You've reached the end.

)}

Followed ({followed.items.length} {followed.hasMore ? "+" : ""})

{followed.items.length === 0 ? (

No followed playlists yet.

) : (
    {followed.items.map((p) => ( ))}
)} {followed.hasMore &&
} {followed.loadingMore && (

Loading more…

)} {!followed.hasMore && followed.items.length > 0 && (

You've reached the end.

)}
{confirmDeleteId && ( { handleDelete(confirmDeleteId); setConfirmDeleteId(null); }} onCancel={() => setConfirmDeleteId(null)} /> )}
); }