diff --git a/frontend/css/playlists.less b/frontend/css/playlists.less index 10af2f1237..076a14b1d0 100644 --- a/frontend/css/playlists.less +++ b/frontend/css/playlists.less @@ -1,11 +1,48 @@ @playlistBackgroundColor: #fafafa; @descriptionLines: 3; +#playlists-page .playlist-view-options { + display: flex; + margin-top: 0.8rem; + gap: 1rem; + + .playlist-sort-controls { + display: flex; + align-items: center; + gap: 1rem; + } +} + #playlists-container { display: flex; flex-wrap: wrap; padding-top: 1em; + &.list-view { + flex-direction: column; + } + + /* Pure CSS multiline ellipsis by Sagi Shrieber: + http://hackingui.com/a-pure-css-solution-for-multiline-text-truncation/ + */ + .description { + overflow: hidden; + position: relative; + line-height: 1.2em; + max-height: @descriptionLines * 1.2em; + text-align: justify; + padding-right: 2em; + display: -webkit-box; + line-clamp: @descriptionLines; + margin-top: 0.5rem; + -webkit-line-clamp: @descriptionLines; + -webkit-box-orient: vertical; + + p { + margin-bottom: 0; + } + } + .playlist { margin: 0.5em; width: 15em; @@ -40,34 +77,86 @@ margin: 0; } } + } + } - /* Pure CSS multiline ellipsis by Sagi Shrieber: - http://hackingui.com/a-pure-css-solution-for-multiline-text-truncation/ - */ - .description { - overflow: hidden; - position: relative; - line-height: 1.2em; - max-height: @descriptionLines * 1.2em; - text-align: justify; - padding-right: 2em; - &:before { - content: "..."; - position: absolute; - right: 1em; - bottom: 0; + .playlist-card-list-view { + display: flex; + flex-direction: row; + border: 1px solid #e2e8f0; + padding: 1rem; + margin: 0.5rem 0; + min-height: 8rem; + border-radius: 10px; + gap: 3rem; + + @media (max-width: 640px) { + gap: 0; + } + + &:hover { + cursor: pointer; + } + + .playlist-card-container { + display: flex; + justify-content: space-between; + width: 100%; + gap: 0.8rem; + + .playlist-info { + display: flex; + align-items: center; + gap: 2rem; + + .playlist-index { + min-width: 2rem; + text-align: right; + font-weight: bold; + } + } + } + + .playlist-info-content { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + + @media (min-width: 640px) { + margin-bottom: 0; + } + + .playlist-title { + font-size: 1.75rem; + a { + font-weight: 500; + color: #353070; + text-decoration: none; } - &:after { - content: ""; - position: absolute; - right: 0; - width: 1em; - height: 1em; - margin-top: 0.2em; - background-color: inherit; + } + } + + .playlist-more-info { + align-self: center; + flex-shrink: 0; + + .playlist-stats { + display: flex; + flex-direction: column; + + .playlist-date { + color: #6b7280; + display: flex; + align-items: center; + gap: 8px; } } } + + .playlist-actions { + align-self: center; + margin: 1rem; + } } } @@ -81,6 +170,11 @@ height: initial !important; color: white; text-align: center; + + &.list-view { + width: auto; + } + > div { display: flex; flex-direction: column; @@ -137,10 +231,3 @@ align-self: center; min-width: 3em; } - -.playlist-sort-controls { - display: flex; - align-items: center; - gap: 1rem; - margin-top: 0.8em; -} diff --git a/frontend/js/src/user/playlists/Playlists.tsx b/frontend/js/src/user/playlists/Playlists.tsx index a407700ed8..a905faf130 100644 --- a/frontend/js/src/user/playlists/Playlists.tsx +++ b/frontend/js/src/user/playlists/Playlists.tsx @@ -3,7 +3,6 @@ import { faPlusCircle, faUsers, faFileImport, - faMusic, } from "@fortawesome/free-solid-svg-icons"; import { faSpotify, faItunesNote } from "@fortawesome/free-brands-svg-icons"; import * as React from "react"; @@ -12,7 +11,7 @@ import { orderBy } from "lodash"; import NiceModal from "@ebay/nice-modal-react"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useLoaderData } from "react-router-dom"; +import { useLoaderData, useSearchParams } from "react-router-dom"; import { toast } from "react-toastify"; import { Helmet } from "react-helmet"; import Card from "../../components/Card"; @@ -29,18 +28,21 @@ import { getPlaylistId, PlaylistType, } from "../../playlists/utils"; +import PlaylistView from "./playlistView.d"; +import { faGrid, faStacked } from "../../utils/icons"; +import { getObjectForURLSearchParams } from "../../utils/utils"; export type UserPlaylistsProps = { playlists: JSPFObject[]; user: ListenBrainzUser; playlistCount: number; + pageCount: number; }; export type UserPlaylistsState = { playlists: JSPFPlaylist[]; - playlistCount: number; - playlistType: PlaylistType; sortBy: SortOption; + view: PlaylistView; }; enum SortOption { @@ -53,24 +55,38 @@ enum SortOption { type UserPlaylistsLoaderData = UserPlaylistsProps; +type UserPlaylistsClassProps = UserPlaylistsProps & { + page: number; + playlistType: PlaylistType; + handleClickPrevious: () => void; + handleClickNext: () => void; + handleSetPlaylistType: (newType: PlaylistType) => void; +}; + export default class UserPlaylists extends React.Component< - UserPlaylistsProps, + UserPlaylistsClassProps, UserPlaylistsState > { static contextType = GlobalAppContext; declare context: React.ContextType; - constructor(props: UserPlaylistsProps) { + constructor(props: UserPlaylistsClassProps) { super(props); const { playlists, playlistCount } = props; this.state = { playlists: playlists?.map((pl) => pl.playlist) ?? [], - playlistCount, - playlistType: PlaylistType.playlists, sortBy: SortOption.DATE_CREATED, + view: PlaylistView.GRID, }; } + componentDidUpdate(prevProps: Readonly): void { + const { playlists } = this.props; + if (prevProps.playlists !== playlists) { + this.setState({ playlists: playlists.map((pl) => pl.playlist) }); + } + } + alertNotAuthorized = () => { toast.error( { - this.setState({ playlists }); - }; - setPlaylistType = (type: PlaylistType) => { - this.setState({ playlistType: type, sortBy: SortOption.DATE_CREATED }); + const { handleSetPlaylistType, playlistType } = this.props; + if (type !== playlistType) { + handleSetPlaylistType(type); + this.setState({ sortBy: SortOption.DATE_CREATED }); + } }; onCopiedPlaylist = (newPlaylist: JSPFPlaylist): void => { - const { playlistType } = this.state; + const { playlistType } = this.props; if (this.isCurrentUserPage() && playlistType === PlaylistType.playlists) { this.setState((prevState) => ({ playlists: [newPlaylist, ...prevState.playlists], @@ -195,8 +211,15 @@ export default class UserPlaylists extends React.Component< }; render() { - const { user } = this.props; - const { playlists, playlistCount, playlistType, sortBy } = this.state; + const { + user, + pageCount, + page, + playlistType, + handleClickPrevious, + handleClickNext, + } = this.props; + const { playlists, sortBy, view } = this.state; const { currentUser } = this.context; return ( @@ -207,141 +230,171 @@ export default class UserPlaylists extends React.Component< } Playlists`}
-
- this.setPlaylistType(PlaylistType.playlists)} - > - Playlists - - this.setPlaylistType(PlaylistType.collaborations)} - > - Collaborative - -
- {this.isCurrentUserPage() && ( -
- -
    Playlists + + + this.setPlaylistType(PlaylistType.collaborations) + } > -
  • - -
  • -
  • - -
  • -
  • - -
  • -
+ Collaborative +
- )} -
-
- Sort by: - +
+ this.setState({ view: PlaylistView.GRID })} + title="Grid view" + > + + + this.setState({ view: PlaylistView.LIST })} + title="List view" + > + + +
+
+
+
+ Sort by: + +
+ {this.isCurrentUserPage() && ( +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ )} +
{this.isCurrentUserPage() && [ { @@ -366,5 +419,49 @@ export default class UserPlaylists extends React.Component< export function UserPlaylistsWrapper() { const data = useLoaderData() as UserPlaylistsLoaderData; - return ; + const [searchParams, setSearchParams] = useSearchParams(); + const searchParamsObj = getObjectForURLSearchParams(searchParams); + const currPageNoStr = searchParams.get("page") || "1"; + const currPageNo = parseInt(currPageNoStr, 10); + const type = searchParams.get("type") || ""; + + const handleClickPrevious = () => { + setSearchParams({ + ...searchParamsObj, + page: Math.max(currPageNo - 1, 1).toString(), + }); + }; + + const handleClickNext = () => { + setSearchParams({ + ...searchParamsObj, + page: Math.min(currPageNo + 1, data.pageCount).toString(), + }); + }; + + const playlistType = + type === "collaborative" + ? PlaylistType.collaborations + : PlaylistType.playlists; + + const handleSetPlaylistType = (newType: PlaylistType) => { + const newParams = { ...searchParamsObj }; + if (newType === PlaylistType.collaborations) { + newParams.type = "collaborative"; + } else { + delete newParams?.type; + } + setSearchParams(newParams); + }; + + return ( + + ); } diff --git a/frontend/js/src/playlists/components/PlaylistCard.tsx b/frontend/js/src/user/playlists/components/PlaylistCard.tsx similarity index 56% rename from frontend/js/src/playlists/components/PlaylistCard.tsx rename to frontend/js/src/user/playlists/components/PlaylistCard.tsx index 7a011d0a8f..63a883b64e 100644 --- a/frontend/js/src/playlists/components/PlaylistCard.tsx +++ b/frontend/js/src/user/playlists/components/PlaylistCard.tsx @@ -2,18 +2,25 @@ import * as React from "react"; -import { faCog, faSave } from "@fortawesome/free-solid-svg-icons"; +import { + faCog, + faSave, + faCalendar, + faMusic, + faEllipsisVertical, +} from "@fortawesome/free-solid-svg-icons"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { sanitize } from "dompurify"; import { toast } from "react-toastify"; -import { Link } from "react-router-dom"; -import Card from "../../components/Card"; -import { ToastMsg } from "../../notifications/Notifications"; -import GlobalAppContext from "../../utils/GlobalAppContext"; -import PlaylistMenu from "./PlaylistMenu"; -import { getPlaylistExtension, getPlaylistId } from "../utils"; +import { Link, useNavigate } from "react-router-dom"; +import Card from "../../../components/Card"; +import { ToastMsg } from "../../../notifications/Notifications"; +import GlobalAppContext from "../../../utils/GlobalAppContext"; +import PlaylistMenu from "../../../playlists/components/PlaylistMenu"; +import { getPlaylistExtension, getPlaylistId } from "../../../playlists/utils"; +import PlaylistView from "../playlistView.d"; export type PlaylistCardProps = { playlist: JSPFPlaylist; @@ -21,10 +28,14 @@ export type PlaylistCardProps = { onPlaylistEdited: (playlist: JSPFPlaylist) => void; onPlaylistDeleted: (playlist: JSPFPlaylist) => void; showOptions: boolean; + view: PlaylistView; + index: number; }; export default function PlaylistCard({ playlist, + view, + index, onSuccessfulCopy, onPlaylistEdited, onPlaylistDeleted, @@ -33,6 +44,7 @@ export default function PlaylistCard({ const { APIService, currentUser, spotifyAuth } = React.useContext( GlobalAppContext ); + const navigate = useNavigate(); const playlistId = getPlaylistId(playlist); const customFields = getPlaylistExtension(playlist); @@ -92,6 +104,94 @@ export default function PlaylistCard({ } }, [currentUser.auth_token, playlistId, APIService, onSuccessfulCopy]); + const navigateToPlaylist = () => { + navigate(`/playlist/${sanitize(playlistId)}/`); + }; + + if (view === PlaylistView.LIST) { + return ( +
+
{ + if (e.key === "Enter") { + navigateToPlaylist(); + } + }} + role="presentation" + > +
+
{index + 1}
+
+
+ + {playlist.title} + +
+ {playlist.annotation && ( +
+ )} +
+
+
+
+
+ + {new Date(playlist.date).toLocaleString(undefined, { + dateStyle: "short", + })} +
+
+ + {playlist.track?.length} track + {playlist.track?.length === 1 ? "" : "s"} +
+
+
+
+
+ + {showOptions ? ( + + ) : ( + + )} +
+
+ ); + } + return ( {!showOptions ? ( diff --git a/frontend/js/src/user/playlists/components/PlaylistsList.tsx b/frontend/js/src/user/playlists/components/PlaylistsList.tsx index c832749390..db22f56029 100644 --- a/frontend/js/src/user/playlists/components/PlaylistsList.tsx +++ b/frontend/js/src/user/playlists/components/PlaylistsList.tsx @@ -1,25 +1,21 @@ -/* eslint-disable jsx-a11y/anchor-is-valid */ -/* eslint-disable camelcase */ - import { noop } from "lodash"; import * as React from "react"; -import { toast } from "react-toastify"; -import Loader from "../../../components/Loader"; -import { ToastMsg } from "../../../notifications/Notifications"; -import GlobalAppContext from "../../../utils/GlobalAppContext"; -import PlaylistCard from "../../../playlists/components/PlaylistCard"; +import PlaylistCard from "./PlaylistCard"; import { PlaylistType } from "../../../playlists/utils"; +import PlaylistView from "../playlistView.d"; +import Pagination from "../../../common/Pagination"; export type PlaylistsListProps = { playlists: JSPFPlaylist[]; - user: ListenBrainzUser; - paginationOffset?: number; - playlistCount: number; + pageCount: number; + page: number; activeSection: PlaylistType; + view: PlaylistView; onCopiedPlaylist?: (playlist: JSPFPlaylist) => void; onPlaylistEdited: (playlist: JSPFPlaylist) => void; onPlaylistDeleted: (playlist: JSPFPlaylist) => void; - onPaginatePlaylists: (playlists: JSPFPlaylist[]) => void; + handleClickPrevious: () => void; + handleClickNext: () => void; }; export type PlaylistsListState = { @@ -28,192 +24,52 @@ export type PlaylistsListState = { playlistCount: number; }; -export default class PlaylistsList extends React.Component< - React.PropsWithChildren, - PlaylistsListState -> { - static contextType = GlobalAppContext; - declare context: React.ContextType; - - private DEFAULT_PLAYLISTS_PER_PAGE = 25; - - constructor(props: React.PropsWithChildren) { - super(props); - this.state = { - loading: false, - paginationOffset: props.paginationOffset || 0, - playlistCount: props.playlistCount, - }; - } - - async componentDidUpdate( - prevProps: React.PropsWithChildren - ): Promise { - const { user, activeSection } = this.props; - const { currentUser } = this.context; - if (prevProps.activeSection !== activeSection) { - await this.fetchPlaylists(0); - } - } - - alertNotAuthorized = () => { - toast.error( - , - { toastId: "auth-error" } - ); - }; - - isCurrentUserPage = () => { - const { user, activeSection } = this.props; - const { currentUser } = this.context; - if (activeSection === PlaylistType.recommendations) { - return false; - } - return currentUser?.name === user.name; - }; - - handleClickNext = async () => { - const { paginationOffset, playlistCount } = this.state; - const newOffset = paginationOffset + this.DEFAULT_PLAYLISTS_PER_PAGE; - // No more playlists to fetch - if (newOffset >= playlistCount) { - return; - } - await this.fetchPlaylists(newOffset); - }; - - handleClickPrevious = async () => { - const { paginationOffset } = this.state; - // No more playlists to fetch - if (paginationOffset === 0) { - return; - } - const newOffset = Math.max( - 0, - paginationOffset - this.DEFAULT_PLAYLISTS_PER_PAGE - ); - await this.fetchPlaylists(newOffset); - }; - - handleAPIResponse = (newPlaylists: { - playlists: JSPFObject[]; - playlist_count: number; - count: string; - offset: string; - }) => { - const { onPaginatePlaylists } = this.props; - const parsedOffset = parseInt(newPlaylists.offset, 10); - this.setState({ - playlistCount: newPlaylists.playlist_count, - paginationOffset: parsedOffset, - loading: false, - }); - onPaginatePlaylists( - newPlaylists.playlists.map((pl: JSPFObject) => pl.playlist) - ); - }; - - fetchPlaylists = async (newOffset: number = 0) => { - const { APIService, currentUser } = this.context; - const { user, activeSection } = this.props; - this.setState({ loading: true }); - try { - const newPlaylists = await APIService.getUserPlaylists( - user.name, - currentUser?.auth_token, - newOffset, - this.DEFAULT_PLAYLISTS_PER_PAGE, - activeSection === PlaylistType.recommendations, - activeSection === PlaylistType.collaborations - ); - - this.handleAPIResponse(newPlaylists); - } catch (error) { - toast.error( - , - { toastId: "load-playlists-error" } - ); - this.setState({ loading: false }); - } - }; +export default function PlaylistsList( + props: PlaylistsListProps & { children: React.ReactNode } +) { + const { + playlists, + activeSection, + children, + view, + page, + pageCount, + onCopiedPlaylist, + onPlaylistEdited, + onPlaylistDeleted, + handleClickPrevious, + handleClickNext, + } = props; - render() { - const { - playlists, - activeSection, - children, - onCopiedPlaylist, - onPlaylistEdited, - onPlaylistDeleted, - } = this.props; - const { paginationOffset, playlistCount, loading } = this.state; - const { currentUser } = this.context; - return ( -
- - {!playlists.length && ( -

No playlists to show yet. Come back later !

- )} -
- {playlists.map((playlist: JSPFPlaylist) => { - return ( - - ); - })} - {children} -
- + return ( +
+ {!playlists.length &&

No playlists to show yet. Come back later !

} +
+ {playlists.map((playlist: JSPFPlaylist, index: number) => { + return ( + + ); + })} + {children}
- ); - } + +
+ ); } diff --git a/frontend/js/src/user/playlists/playlistView.d.ts b/frontend/js/src/user/playlists/playlistView.d.ts new file mode 100644 index 0000000000..07a78ca228 --- /dev/null +++ b/frontend/js/src/user/playlists/playlistView.d.ts @@ -0,0 +1,6 @@ +enum PlaylistView { + LIST = "list", + GRID = "grid", +} + +export default PlaylistView; diff --git a/frontend/js/src/utils/icons.ts b/frontend/js/src/utils/icons.ts index a5303567d5..d90e40d330 100644 --- a/frontend/js/src/utils/icons.ts +++ b/frontend/js/src/utils/icons.ts @@ -4,7 +4,6 @@ import { IconPrefix, } from "@fortawesome/fontawesome-svg-core"; -// eslint-disable-next-line import/prefer-default-export export const faRepeatOnce: IconDefinition = { prefix: "fas" as IconPrefix, iconName: "repeat-one" as IconName, @@ -16,3 +15,27 @@ export const faRepeatOnce: IconDefinition = { "M11 4v1.466a.25.25 0 0 0 .41.192l2.36-1.966a.25.25 0 0 0 0-.384l-2.36-1.966a.25.25 0 0 0-.41.192V3H5a5 5 0 0 0-4.48 7.223.5.5 0 0 0 .896-.446A4 4 0 0 1 5 4zm4.48 1.777a.5.5 0 0 0-.896.446A4 4 0 0 1 11 12H5.001v-1.466a.25.25 0 0 0-.41-.192l-2.36 1.966a.25.25 0 0 0 0 .384l2.36 1.966a.25.25 0 0 0 .41-.192V13h6a5 5 0 0 0 4.48-7.223Z M9 5.5a.5.5 0 0 0-.854-.354l-1.75 1.75a.5.5 0 1 0 .708.708L8 6.707V10.5a.5.5 0 0 0 1 0z", ], }; + +export const faGrid: IconDefinition = { + prefix: "fas" as IconPrefix, + iconName: "grid" as IconName, + icon: [ + 14, + 14, + [], + "", + "M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5z", + ], +}; + +export const faStacked: IconDefinition = { + prefix: "fas" as IconPrefix, + iconName: "list" as IconName, + icon: [ + 14, + 14, + [], + "", + "M3 0h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm0 8h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1z", + ], +}; diff --git a/listenbrainz/webserver/views/user.py b/listenbrainz/webserver/views/user.py index 481a2e1b77..78fda88fbb 100644 --- a/listenbrainz/webserver/views/user.py +++ b/listenbrainz/webserver/views/user.py @@ -1,4 +1,5 @@ from datetime import datetime +from math import ceil from collections import defaultdict import listenbrainz.db.user as db_user @@ -11,7 +12,7 @@ from listenbrainz import webserver from listenbrainz.db.msid_mbid_mapping import fetch_track_metadata_for_items -from listenbrainz.db.playlist import get_playlists_for_user, get_recommendation_playlists_for_user +from listenbrainz.db.playlist import get_playlists_for_user, get_recommendation_playlists_for_user, get_playlists_collaborated_on from listenbrainz.db.pinned_recording import get_current_pin_for_user, get_pin_count_for_user, get_pin_history_for_user from listenbrainz.db.feedback import get_feedback_count_for_user, get_feedback_for_user from listenbrainz.db import year_in_music as db_year_in_music @@ -21,7 +22,7 @@ from listenbrainz.webserver.errors import APIBadRequest from listenbrainz.webserver.login import User, api_login_required from listenbrainz.webserver.views.api import DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL -from werkzeug.exceptions import NotFound +from listenbrainz.webserver.views.api_tools import get_non_negative_param from brainzutils import cache @@ -165,6 +166,9 @@ def stats(user_name: str): def playlists(user_name: str): """ Show user playlists """ + page = get_non_negative_param("page", default=1) + type = request.args.get("type", "") + user = _get_user(user_name) if not user: return jsonify({"error": "Cannot find user: %s" % user_name}), 404 @@ -177,10 +181,18 @@ def playlists(user_name: str): include_private = current_user.is_authenticated and current_user.id == user.id playlists = [] - user_playlists, playlist_count = get_playlists_for_user( - db_conn, ts_conn, user.id, include_private=include_private, - load_recordings=False, count=DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL, offset=0 - ) + offset = (page - 1) * DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL + + if type == "collaborative": + user_playlists, playlist_count = get_playlists_collaborated_on( + db_conn, ts_conn, user.id, include_private=include_private, + load_recordings=True, count=DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL, offset=offset + ) + else: + user_playlists, playlist_count = get_playlists_for_user( + db_conn, ts_conn, user.id, include_private=include_private, + load_recordings=True, count=DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL, offset=offset + ) for playlist in user_playlists: playlists.append(playlist.serialize_jspf()) @@ -188,6 +200,7 @@ def playlists(user_name: str): "playlists": playlists, "user": user_data, "playlistCount": playlist_count, + "pageCount": ceil(playlist_count / DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL), "logged_in_user_follows_user": logged_in_user_follows_user(user), }