diff --git a/frontend/css/listens-page.less b/frontend/css/listens-page.less index e093134516..7cb80c96ce 100644 --- a/frontend/css/listens-page.less +++ b/frontend/css/listens-page.less @@ -181,6 +181,9 @@ height: 100%; } .listen-card { + &.card { + height: initial; + } margin-bottom: 7px; a:focus, a:hover { diff --git a/frontend/css/main.less b/frontend/css/main.less index dbeed78d9c..f27e4135bf 100644 --- a/frontend/css/main.less +++ b/frontend/css/main.less @@ -337,3 +337,19 @@ pre code.hljs { .input-group > .input-group-btn > .btn { line-height: 2em; } + +.btn-icon { + padding: 0.5em; + font-size: 1.5em; + width: 2em; + height: 2em; + line-height: 1em; + border-radius: 5px; + & + .btn-icon { + margin-left: 0.4em; + } +} + +.btn-rounded { + border-radius: 3em; +} diff --git a/frontend/css/recommendation-page.less b/frontend/css/recommendation-page.less index 21c8828ef2..05f35dd5fd 100644 --- a/frontend/css/recommendation-page.less +++ b/frontend/css/recommendation-page.less @@ -2,4 +2,212 @@ #recommendations { &:extend(#listens); } -/* stylelint-enable */ \ No newline at end of file +/* stylelint-enable */ + +.playlists-masonry-container { + position: relative; + // hide overflow behind button with gradient to indicate there's more + .nav-button { + position: absolute; + top: 0; + bottom: 0; + width: 55px; + border: none; + font-size: 2em; + color: @light-grey; + z-index: 1; + opacity: 1; + transition: opacity 300ms linear; + &.backward { + background: linear-gradient(to right, @white 10%, transparent); + text-align: left; + left: 0; + } + &.forward { + background: linear-gradient(to left, @white 10%, transparent); + right: 0; + text-align: right; + } + } + &.scroll-start .nav-button.backward, + &.scroll-end& .nav-button.forward { + opacity: 0; + pointer-events: none; + } +} +.playlists-masonry { + width: 100%; + display: grid; + grid-gap: 1em; + grid-auto-rows: 150px; + grid-auto-columns: 300px; + grid-auto-flow: column; + overflow-x: scroll; + overflow-y: hidden; + scroll-snap-type: x proximity; + &.dragging { + scroll-snap-type: none; + } + + > * { + position: relative; + min-width: 260px; + padding: 1em; + display: flex; + align-items: flex-end; + background-size: cover; + background-color: #b2d4b0; + background-position: center; + border: none; + scroll-snap-align: start; + filter: unset; + transition: all 300ms ease-out; + &:hover { + filter: saturate(1.4); + .title { + margin-bottom: 5px; + } + } + &.selected { + border: 5px solid @blue; + border-radius: 3px; + } + .title { + font-family: Roboto, sans-serif; + font-weight: 900; + font-size: 2em; + line-height: 1.1em; + color: @blue; + transition: margin-bottom 300ms ease-out; + margin-bottom: 0px; + text-align: left; + border: none; + max-width: 80%; + } + .playlist-timer { + --timer-color: fade(white, 50%); + width: 25px; + height: 25px; + border-radius: 50%; + background: conic-gradient( + fade(white, 15%) var(--degrees-progress), + var(--timer-color) calc(360deg - var(--degrees-progress)) + ); + align-self: flex-start; + order: 3; + position: absolute; + left: calc( + 100% - 1em - 25px + ); // width of parent minus padding minus width of timer + &.pressing { + animation: blink 1s ease-out alternate infinite; + --timer-color: fade(red, 70%); + background-color: fade(white, 50%); + } + @keyframes blink { + 0% { + opacity: 0.3; + } + 50% { + opacity: 0.75; + } + 100% { + opacity: 1; + } + } + } + .btn { + margin-left: auto; + } + // Magic background image cycler + /* stylelint-disable */ + each(range(3), { + //We only have three backgrounds available for green (#1 is only used for weekly jams) + &.green:nth-of-type(3n+@{value}) { + @bgValue: (@value + 1); + background-image: url("../img/recommendations/green-@{bgValue}.svg"); + } + }); + each(range(4), { + &.red:nth-of-type(4n+@{value}) { + background-image: url("../img/recommendations/red-@{value}.svg"); + } + }); + each(range(4), { + &.blue:nth-of-type(4n+@{value}) { + background-image: url("../img/recommendations/blue-@{value}.svg"); + } + }); + /* stylelint-enable */ + + &:nth-child(1 of .weekly-jams) { + // Make first weekly-jams element bigger and placed at the start + min-width: 320px; + grid-column-start: 1; + grid-column-end: 3; + grid-row-start: 1; + grid-row-end: 3; + background-image: url("../img/recommendations/green-1.svg"); + .title { + width: 5em; + font-size: 4em; + } + &:hover .title { + margin-bottom: 10px; + } + .playlist-timer { + width: 35px; + height: 35px; + left: calc(100% - 1em - 35px); + } + } + &:not(:nth-child(1 of .weekly-jams)) button { + aspect-ratio: 1; + padding: 3px 7px; + .button-text { + display: none; + } + } + } + + @media (max-width: @screen-sm) { + grid-auto-columns: unset; + } +} + +#selected-playlist { + display: flex; + flex-wrap: wrap-reverse; + align-items: flex-end; + gap: 1em; + margin-top: 2em; + border-top: 1px solid @light; + padding-top: 2em; + + .playlist-items { + flex: 2; + flex-basis: 350px; + } + .playlist-settings { + width: 350px; + flex: 1; + flex-basis: 300px; + padding: 1em; + overflow: hidden; + + .playlist-settings-header { + background: @blue; + color: @white; + padding: 1em; + margin: -1em -1em 0; + .title { + margin-top: 0.6em; + font-size: 1.3em; + font-weight: 600; + } + } + .buttons { + padding: 0.7em 0; + } + } +} diff --git a/frontend/img/recommendations/blue-1.svg b/frontend/img/recommendations/blue-1.svg new file mode 100644 index 0000000000..352978d37c --- /dev/null +++ b/frontend/img/recommendations/blue-1.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/blue-2.svg b/frontend/img/recommendations/blue-2.svg new file mode 100644 index 0000000000..2f482287bc --- /dev/null +++ b/frontend/img/recommendations/blue-2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/blue-3.svg b/frontend/img/recommendations/blue-3.svg new file mode 100644 index 0000000000..560b7caf32 --- /dev/null +++ b/frontend/img/recommendations/blue-3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/blue-4.svg b/frontend/img/recommendations/blue-4.svg new file mode 100644 index 0000000000..94df5b01d7 --- /dev/null +++ b/frontend/img/recommendations/blue-4.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/green-1.svg b/frontend/img/recommendations/green-1.svg new file mode 100644 index 0000000000..4ce85683bc --- /dev/null +++ b/frontend/img/recommendations/green-1.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/green-2.svg b/frontend/img/recommendations/green-2.svg new file mode 100644 index 0000000000..b8d2cdffb5 --- /dev/null +++ b/frontend/img/recommendations/green-2.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/green-3.svg b/frontend/img/recommendations/green-3.svg new file mode 100644 index 0000000000..80a00838a6 --- /dev/null +++ b/frontend/img/recommendations/green-3.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/green-4.svg b/frontend/img/recommendations/green-4.svg new file mode 100644 index 0000000000..103011b58f --- /dev/null +++ b/frontend/img/recommendations/green-4.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/no-freshness.png b/frontend/img/recommendations/no-freshness.png new file mode 100644 index 0000000000..0b351b8381 Binary files /dev/null and b/frontend/img/recommendations/no-freshness.png differ diff --git a/frontend/img/recommendations/red-1.svg b/frontend/img/recommendations/red-1.svg new file mode 100644 index 0000000000..8b59057b29 --- /dev/null +++ b/frontend/img/recommendations/red-1.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/red-2.svg b/frontend/img/recommendations/red-2.svg new file mode 100644 index 0000000000..090f5e041d --- /dev/null +++ b/frontend/img/recommendations/red-2.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/red-3.svg b/frontend/img/recommendations/red-3.svg new file mode 100644 index 0000000000..6872ba5ea8 --- /dev/null +++ b/frontend/img/recommendations/red-3.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/img/recommendations/red-4.svg b/frontend/img/recommendations/red-4.svg new file mode 100644 index 0000000000..c9fe239087 --- /dev/null +++ b/frontend/img/recommendations/red-4.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/js/lib/dragscroll.js b/frontend/js/lib/dragscroll.js index 30b8cb7f5b..03a07265dc 100644 --- a/frontend/js/lib/dragscroll.js +++ b/frontend/js/lib/dragscroll.js @@ -61,6 +61,9 @@ mouseup, (cont.mu = function () { pushed = 0; + setTimeout(function () { + el.classList.remove("dragging"); + }, 100); }), 0 ); @@ -69,6 +72,7 @@ mousemove, (cont.mm = function (e) { if (pushed) { + el.classList.add("dragging"); (scroller = el.scroller || el).scrollLeft -= newScrollX = -lastClientX + (lastClientX = e.clientX); scroller.scrollTop -= newScrollY = diff --git a/frontend/js/src/listens/ListenPayloadModal.tsx b/frontend/js/src/listens/ListenPayloadModal.tsx index 9c940f0157..caa6ab9203 100644 --- a/frontend/js/src/listens/ListenPayloadModal.tsx +++ b/frontend/js/src/listens/ListenPayloadModal.tsx @@ -11,7 +11,7 @@ const json = require("highlight.js/lib/languages/json"); hljs.registerLanguage("json", json); export type ListenPayloadModalProps = { - listen: Listen; + listen: Listen | JSPFPlaylist; }; /** A note about this modal: diff --git a/frontend/js/src/playlists/Playlist.tsx b/frontend/js/src/playlists/Playlist.tsx index c8b090c9b3..8d27e1eeef 100644 --- a/frontend/js/src/playlists/Playlist.tsx +++ b/frontend/js/src/playlists/Playlist.tsx @@ -751,7 +751,8 @@ export default class PlaylistPage extends React.Component< {collaborator} - {index < customFields.collaborators.length - 1 + {index < + (customFields?.collaborators?.length ?? 0) - 1 ? ", " : ""} @@ -805,7 +806,7 @@ export default class PlaylistPage extends React.Component< handle=".drag-handle" list={tracks as (JSPFTrack & { id: string })[]} onEnd={this.movePlaylistItem} - setList={(newState: JSPFTrack[]) => + setList={(newState) => this.setState({ playlist: { ...playlist, track: newState }, }) diff --git a/frontend/js/src/playlists/PlaylistItemCard.tsx b/frontend/js/src/playlists/PlaylistItemCard.tsx index c220be3a47..8a577d50bf 100644 --- a/frontend/js/src/playlists/PlaylistItemCard.tsx +++ b/frontend/js/src/playlists/PlaylistItemCard.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { get as _get } from "lodash"; +import { get as _get, isFunction, isUndefined } from "lodash"; import { faGripLines, faMinusCircle } from "@fortawesome/free-solid-svg-icons"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -12,12 +12,14 @@ export type PlaylistItemCardProps = { track: JSPFTrack; currentFeedback: ListenFeedBack; canEdit: Boolean; - removeTrackFromPlaylist: (track: JSPFTrack) => void; + removeTrackFromPlaylist?: (track: JSPFTrack) => void; updateFeedbackCallback: ( recordingMsid: string, score: ListenFeedBack | RecommendationFeedBack, recordingMbid?: string ) => void; + showTimestamp?: boolean; + showUsername?: boolean; }; export default class PlaylistItemCard extends React.Component< @@ -25,7 +27,9 @@ export default class PlaylistItemCard extends React.Component< > { removeTrack = () => { const { track, removeTrackFromPlaylist } = this.props; - removeTrackFromPlaylist(track); + if (removeTrackFromPlaylist) { + removeTrackFromPlaylist(track); + } }; render() { @@ -34,6 +38,9 @@ export default class PlaylistItemCard extends React.Component< canEdit, currentFeedback, updateFeedbackCallback, + showUsername, + showTimestamp, + removeTrackFromPlaylist, } = this.props; // const customFields = getTrackExtension(track); // const trackDuration = track.duration @@ -49,7 +56,7 @@ export default class PlaylistItemCard extends React.Component< ) : undefined; let additionalMenuItems; - if (canEdit) { + if (canEdit && isFunction(removeTrackFromPlaylist)) { additionalMenuItems = [ => { + if (!currentUser?.auth_token) { + toast.error("You must be logged in for this operation"); + return; + } + if (!playlistId?.length) { + toast.error("No playlist to copy; missing a playlist ID"); + return; + } + try { + const newPlaylistId = await copyPlaylist( + currentUser.auth_token, + playlistId + ); + toast.success( + <> + Saved as playlist  + {newPlaylistId} + + ); + } catch (error) { + toast.error(error.message); + } + }, [playlistId, currentUser, copyPlaylist]); + + const play = React.useCallback(() => { + window.postMessage( + { brainzplayer_event: "play-listen", payload: firstListen }, + window.location.origin + ); + }, [firstListen]); + + return ( +
+
+
{playlist.title}
+
+ {track.length} tracks | Updated {preciseTimestamp(playlist.date)} + {extension?.additional_metadata?.expires_at && + ` | Deleted in ${preciseTimestamp( + extension?.additional_metadata?.expires_at, + "timeAgo" + )}`} +
+
+
+
+ + + +
+
+ {extension?.public ? "Public" : "Private"} playlist by  + {playlist.creator} |{" "} + {extension?.created_for && `For ${extension?.created_for}`} +
+ Link to this playlist +
+
+ {playlist.annotation && ( + <> +
+ {/*
*/} + + )} +
+
+ ); +} diff --git a/frontend/js/src/recommendations/RecommendationsPage.tsx b/frontend/js/src/recommendations/RecommendationsPage.tsx index 4d347add0a..298e73fa86 100644 --- a/frontend/js/src/recommendations/RecommendationsPage.tsx +++ b/frontend/js/src/recommendations/RecommendationsPage.tsx @@ -7,30 +7,41 @@ import { createRoot } from "react-dom/client"; import * as Sentry from "@sentry/react"; import { Integrations } from "@sentry/tracing"; import NiceModal from "@ebay/nice-modal-react"; -import { toast } from "react-toastify"; +import { toast, ToastContainer } from "react-toastify"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faChevronLeft, + faChevronRight, + faSave, +} from "@fortawesome/free-solid-svg-icons"; +import { get, isUndefined, set, throttle } from "lodash"; +import { ReactSortable } from "react-sortablejs"; import withAlertNotifications from "../notifications/AlertNotificationsHOC"; -import APIServiceClass from "../utils/APIService"; import GlobalAppContext from "../utils/GlobalAppContext"; import Loader from "../components/Loader"; import ErrorBoundary from "../utils/ErrorBoundary"; -import { getPageProps } from "../utils/utils"; -import PlaylistsList from "../playlists/PlaylistsList"; -import { PlaylistType } from "../playlists/utils"; +import { getPageProps, preciseTimestamp } from "../utils/utils"; +import { + getPlaylistExtension, + getPlaylistId, + getRecordingMBIDFromJSPFTrack, + JSPFTrackToListen, +} from "../playlists/utils"; +import RecommendationPlaylistSettings from "./RecommendationPlaylistSettings"; +import BrainzPlayer from "../brainzplayer/BrainzPlayer"; +import PlaylistItemCard from "../playlists/PlaylistItemCard"; import { ToastMsg } from "../notifications/Notifications"; export type RecommendationsPageProps = { playlists?: JSPFObject[]; user: ListenBrainzUser; - paginationOffset: string; - playlistCount: number; }; export type RecommendationsPageState = { playlists: JSPFPlaylist[]; - playlistSelectedForOperation?: JSPFPlaylist; + selectedPlaylist?: JSPFPlaylist; loading: boolean; - paginationOffset: number; - playlistCount: number; + recordingFeedbackMap: RecordingFeedbackMap; }; export default class RecommendationsPage extends React.Component< @@ -39,53 +50,504 @@ export default class RecommendationsPage extends React.Component< > { static contextType = GlobalAppContext; declare context: React.ContextType; + private scrollContainer: React.RefObject; + + static getPlaylistInfo( + playlist: JSPFPlaylist, + isOld = false + ): { shortTitle: string; cssClasses: string } { + const extension = getPlaylistExtension(playlist); + const sourcePatch = + extension?.additional_metadata?.algorithm_metadata.source_patch; + let year; + switch (sourcePatch) { + case "weekly-jams": + return { + shortTitle: !isOld ? "Weekly Jams" : `Last Week's Jams`, + cssClasses: "weekly-jams green", + }; + case "weekly-exploration": + return { + shortTitle: !isOld ? "Weekly Exploration" : `Last Week's Exploration`, + cssClasses: "green", + }; + case "daily-jams": + return { + shortTitle: "Daily Jams", + cssClasses: "blue", + }; + case "top-discoveries-for-year": + // get year from title, fallback to using creation date minus 1 + year = + playlist.title.match(/\d{2,4}/)?.[0] ?? + new Date(playlist.date).getUTCFullYear() - 1; + return { + shortTitle: `${year} Top Discoveries`, + cssClasses: "red", + }; + case "top-missed-recordings-for-year": + // get year from title, fallback to using creation date minus 1 + year = + playlist.title.match(/\d{2,4}/)?.[0] ?? + new Date(playlist.date).getUTCFullYear() - 1; + return { + shortTitle: `${year} Missed Tracks`, + cssClasses: "red", + }; + default: + return { + shortTitle: playlist.title, + cssClasses: "blue", + }; + } + } + + throttledOnScroll: React.ReactEventHandler; constructor(props: RecommendationsPageProps) { super(props); - const concatenatedPlaylists = props.playlists?.map((pl) => pl.playlist); + const playlists = props.playlists?.map((pl) => pl.playlist); this.state = { - playlists: concatenatedPlaylists ?? [], + playlists: playlists ?? [], + recordingFeedbackMap: {}, loading: false, - paginationOffset: parseInt(props.paginationOffset, 10) || 0, - playlistCount: props.playlistCount, }; + this.scrollContainer = React.createRef(); + this.throttledOnScroll = throttle(this.onScroll, 400, { leading: true }); } - alertMustBeLoggedIn = () => { - toast.error( - , - { toastId: "auth-error" } + async componentDidMount(): Promise { + const { playlists } = this.state; + const selectedPlaylist = + playlists.find((pl) => { + const extension = getPlaylistExtension(pl); + const sourcePatch = + extension?.additional_metadata?.algorithm_metadata.source_patch; + return sourcePatch === "weekly-jams"; + }) ?? playlists[0]; + if (selectedPlaylist) { + const playlistId = getPlaylistId(selectedPlaylist); + await this.fetchPlaylist(playlistId); + } + } + + getFeedback = async (mbids?: string[]): Promise => { + const { currentUser, APIService } = this.context; + const { selectedPlaylist } = this.state; + const recordings = + mbids ?? selectedPlaylist?.track.map(getRecordingMBIDFromJSPFTrack); + if (currentUser && recordings?.length) { + try { + const data = await APIService.getFeedbackForUserForRecordings( + currentUser.name, + recordings + ); + return data.feedback; + } catch (error) { + toast.error( + `Could not get love/hate feedback: ${ + error.message ?? error.toString() + }` + ); + } + } + return []; + }; + + loadFeedback = async (mbids?: string[]): Promise => { + const { recordingFeedbackMap } = this.state; + const feedback = await this.getFeedback(mbids); + const newRecordingFeedbackMap: RecordingFeedbackMap = { + ...recordingFeedbackMap, + }; + feedback.forEach((fb: FeedbackResponse) => { + if (fb.recording_mbid) { + newRecordingFeedbackMap[fb.recording_mbid] = fb.score; + } + }); + return newRecordingFeedbackMap; + }; + + updateFeedback = ( + recordingMbid: string, + score: ListenFeedBack | RecommendationFeedBack + ) => { + if (recordingMbid) { + const { recordingFeedbackMap } = this.state; + recordingFeedbackMap[recordingMbid] = score as ListenFeedBack; + this.setState({ recordingFeedbackMap }); + } + }; + + getFeedbackForRecordingMbid = ( + recordingMbid?: string | null + ): ListenFeedBack => { + const { recordingFeedbackMap } = this.state; + return recordingMbid ? get(recordingFeedbackMap, recordingMbid, 0) : 0; + }; + + fetchPlaylist = async (playlistId: string) => { + const { APIService, currentUser } = this.context; + try { + const response = await APIService.getPlaylist( + playlistId, + currentUser?.auth_token + ); + const JSPFObject: JSPFObject = await response.json(); + + // React-SortableJS expects an 'id' attribute (non-negociable), so add it to each object + JSPFObject.playlist?.track?.forEach((jspfTrack: JSPFTrack) => { + set(jspfTrack, "id", getRecordingMBIDFromJSPFTrack(jspfTrack)); + }); + // Fetch feedback for loaded tracks + const newTracksMBIDS = JSPFObject.playlist.track.map( + getRecordingMBIDFromJSPFTrack + ); + const recordingFeedbackMap = await this.loadFeedback(newTracksMBIDS); + this.setState({ + selectedPlaylist: JSPFObject.playlist, + recordingFeedbackMap, + }); + } catch (error) { + toast.error(error.message); + } + }; + + // The playlist prop only contains generic info, not the actual tracks + // We need to fetch the playlist to get it in full. + selectPlaylist: React.ReactEventHandler = async (event) => { + if (!(event?.currentTarget instanceof HTMLElement)) { + return; + } + if ( + event.currentTarget.closest(".dragscroll")?.classList.contains("dragging") + ) { + // We are dragging with the dragscroll library, ignore the click + event.preventDefault(); + return; + } + const { playlistId } = event.currentTarget.dataset; + if (!playlistId) { + toast.error("No playlist to select"); + return; + } + await this.fetchPlaylist(playlistId); + }; + + copyPlaylist: React.ReactEventHandler = async (event) => { + if (!(event?.currentTarget instanceof HTMLElement)) { + return; + } + event?.stopPropagation(); + if ( + event.currentTarget.closest(".dragscroll")?.classList.contains("dragging") + ) { + // We are dragging with the dragscroll library, ignore the click + event.preventDefault(); + return; + } + const { APIService, currentUser } = this.context; + const playlistId = event.currentTarget?.parentElement?.dataset?.playlistId; + + if (!currentUser?.auth_token) { + toast.warning("You must be logged in to save playlists"); + return; + } + if (!playlistId) { + toast.error("No playlist to copy"); + return; + } + try { + const newPlaylistId = await APIService.copyPlaylist( + currentUser.auth_token, + playlistId + ); + + toast.success( + <> + Duplicated to playlist  + {newPlaylistId} + + ); + } catch (error) { + toast.error(error.message ?? error); + } + }; + + hasRightToEdit = (): boolean => { + const { currentUser } = this.context; + const { user } = this.props; + return currentUser?.name === user.name; + }; + + movePlaylistItem = async (evt: any) => { + const { currentUser, APIService } = this.context; + const { selectedPlaylist } = this.state; + if (!currentUser?.auth_token) { + toast.error("You must be logged in to modify this playlist"); + return; + } + if (!this.hasRightToEdit()) { + toast.error("You are not authorized to modify this playlist"); + return; + } + try { + await APIService.movePlaylistItem( + currentUser.auth_token, + getPlaylistId(selectedPlaylist), + evt.item.getAttribute("data-recording-mbid"), + evt.oldIndex, + evt.newIndex, + 1 + ); + } catch (error) { + toast.error(error.toString()); + // Revert the move in state.playlist order + const newTracks = isUndefined(selectedPlaylist) + ? [] + : [...selectedPlaylist.track]; + // The ol' switcheroo ! + const toMoveBack = newTracks[evt.newIndex]; + newTracks[evt.newIndex] = newTracks[evt.oldIndex]; + newTracks[evt.oldIndex] = toMoveBack; + + this.setState((prevState) => ({ + selectedPlaylist: { ...prevState.selectedPlaylist!, track: newTracks }, + })); + } + }; + + getPlaylistCard = ( + playlist: JSPFPlaylist, + info: { + shortTitle: string; + cssClasses: string; + } + ) => { + const { shortTitle, cssClasses } = info; + const { currentUser } = this.context; + const { user } = this.props; + const { selectedPlaylist } = this.state; + const isLoggedIn = Boolean(currentUser?.auth_token); + const isCurrentUser = user.name === currentUser?.name; + const playlistId = getPlaylistId(playlist); + const extension = getPlaylistExtension(playlist); + const expiryDate = extension?.additional_metadata?.expires_at; + let percentTimeLeft; + if (expiryDate) { + const start = new Date(playlist.date).getTime(); + const end = new Date(expiryDate).getTime(); + const today = new Date().getTime(); + + const elapsed = Math.abs(today - start); + const total = Math.abs(end - start); + percentTimeLeft = Math.round((elapsed / total) * 100); + } + return ( +
{ + if (["Enter", " "].includes(event.key)) this.selectPlaylist(event); + }} + data-playlist-id={playlistId} + role="button" + tabIndex={0} + > + {!isUndefined(percentTimeLeft) && ( +
75 ? "pressing" : "" + }`} + title={`Deleted in ${preciseTimestamp(expiryDate!, "timeAgo")}`} + style={{ + ["--degrees-progress" as any]: `${ + (percentTimeLeft / 100) * 360 + }deg`, + }} + /> + )} +
{shortTitle ?? playlist.title}
+ {isLoggedIn && ( + + )} +
); }; - updatePlaylists = (playlists: JSPFPlaylist[]): void => { - this.setState({ playlists }); + onScroll: React.ReactEventHandler = (event) => { + const element = event.target as HTMLDivElement; + const parent = element.parentElement; + if (!element || !parent) { + return; + } + // calculate horizontal scroll percentage + const scrollPercentage = + (100 * element.scrollLeft) / (element.scrollWidth - element.clientWidth); + + if (scrollPercentage > 95) { + parent.classList.add("scroll-end"); + parent.classList.remove("scroll-start"); + } else if (scrollPercentage < 5) { + parent.classList.add("scroll-start"); + parent.classList.remove("scroll-end"); + } else { + parent.classList.remove("scroll-end"); + parent.classList.remove("scroll-start"); + } + }; + + manualScroll: React.ReactEventHandler = (event) => { + if (!this.scrollContainer?.current) { + return; + } + if (event?.currentTarget.classList.contains("forward")) { + this.scrollContainer.current.scrollBy({ + left: 300, + top: 0, + behavior: "smooth", + }); + } else { + this.scrollContainer.current.scrollBy({ + left: -300, + top: 0, + behavior: "smooth", + }); + } }; render() { + const { currentUser, APIService } = this.context; + const { playlists, selectedPlaylist, loading } = this.state; const { user } = this.props; - const { playlists, paginationOffset, playlistCount, loading } = this.state; + const listensFromJSPFTracks = + selectedPlaylist?.track.map(JSPFTrackToListen) ?? []; return ( -
-

Recommendation playlists created for {user.name}

-

- These playlists are ephemeral and will only be available for a month. - Be sure to save the ones you like to your own playlists ! -

+
+

Created for {user.name}

+ - {}} + {!playlists.length ? ( +
+ No recommendations to show +

+ Oh no. Either something’s gone wrong, or you need to submit more + listens before we can prepare delicious fresh produce just for + you. +

+
+ ) : ( +
+ +
+ {playlists.map((playlist, index) => { + const extension = getPlaylistExtension(playlist); + const sourcePatch = + extension?.additional_metadata?.algorithm_metadata + .source_patch; + const isFirstOfType = + playlists.findIndex((pl) => { + const extension2 = getPlaylistExtension(pl); + const sourcePatch2 = + extension2?.additional_metadata?.algorithm_metadata + .source_patch; + return sourcePatch === sourcePatch2; + }) === index; + + const info = RecommendationsPage.getPlaylistInfo( + playlist, + !isFirstOfType + ); + return this.getPlaylistCard(playlist, info); + })} +
+ +
+ )} + {selectedPlaylist && ( +
+
+ {selectedPlaylist.track.length > 0 ? ( + + this.setState((prevState) => ({ + selectedPlaylist: { + ...prevState.selectedPlaylist!, + track: newState, + }, + })) + } + > + {selectedPlaylist.track.map((track: JSPFTrack, index) => { + return ( + + ); + })} + + ) : ( +
+

Nothing in this playlist yet

+
+ )} +
+ +
+ )} +
); @@ -108,13 +570,7 @@ document.addEventListener("DOMContentLoaded", () => { tracesSampleRate: sentry_traces_sample_rate, }); } - const { - playlists, - user, - playlist_count: playlistCount, - pagination_offset: paginationOffset, - playlists_per_page: playlistsPerPage, - } = reactProps; + const { playlists, user } = reactProps; const RecommendationsPageWithAlertNotifications = withAlertNotifications( RecommendationsPage @@ -123,12 +579,15 @@ document.addEventListener("DOMContentLoaded", () => { const renderRoot = createRoot(domContainer!); renderRoot.render( + diff --git a/frontend/js/src/utils/types.d.ts b/frontend/js/src/utils/types.d.ts index e80003c31d..8f59343cf9 100644 --- a/frontend/js/src/utils/types.d.ts +++ b/frontend/js/src/utils/types.d.ts @@ -471,12 +471,21 @@ declare type JSPFObject = { playlist: JSPFPlaylist; }; +declare type JSPFPlaylistMetadata = { + external_urls?: { [key: string]: any }; + algorithm_metadata: { + source_patch: string; + }; + expires_at?: string; // ISO date string +}; + declare type JSPFPlaylistExtension = { - collaborators: string[]; + collaborators?: string[]; public: boolean; created_for?: string; copied_from?: string; // Full ListenBrainz playlist URI last_modified_at?: string; // ISO date string + additional_metadata?: JSPFPlaylistMetadata; }; declare type JSPFTrackExtension = { diff --git a/listenbrainz/db/playlist.py b/listenbrainz/db/playlist.py index 330966b979..0d5daa06cf 100644 --- a/listenbrainz/db/playlist.py +++ b/listenbrainz/db/playlist.py @@ -16,6 +16,10 @@ TROI_BOT_USER_ID = 12939 TROI_BOT_DEBUG_USER_ID = 19055 +LISTENBRAINZ_USER_ID = 23944 + +# These are the recommendation troi patches that we showcase on the recommendations page for each user +RECOMMENDATION_PATCHES = ('daily-jams', 'weekly-jams', 'weekly-exploration', 'top-discoveries-for-year', 'top-missed-recordings-for-year') def get_by_mbid(playlist_id: str, load_recordings: bool = True) -> Optional[model_playlist.Playlist]: @@ -146,6 +150,44 @@ def get_playlists_for_user(user_id: int, return playlists, count +def get_recommendation_playlists_for_user(user_id: int): + """Get all recommendation playlists that have been created for the user + + Arguments: + user_id: The user to find playlists for + + Returns: + A list of playlists + + """ + + params = {"creator_id": (LISTENBRAINZ_USER_ID, TROI_BOT_USER_ID), "created_for_id": user_id, "patches": RECOMMENDATION_PATCHES} + query = sqlalchemy.text(f""" + SELECT pl.id + , pl.mbid + , pl.name + , pl.description + , pl.creator_id + , pl.created + , pl.public + , pl.created + , pl.last_updated + , pl.copied_from_id + , pl.created_for_id + , pl.additional_metadata + FROM playlist.playlist pl + WHERE additional_metadata->'algorithm_metadata'->>'source_patch' IN :patches + AND created_for_id = :created_for_id + AND creator_id IN :creator_id + ORDER BY pl.created DESC""") + + with ts.engine.connect() as connection: + result = connection.execute(query, params) + playlists = _playlist_resultset_to_model(connection, result, False) + + return playlists + + def _playlist_resultset_to_model(connection, result, load_recordings): """Parse the result of an sql query to get playlists diff --git a/listenbrainz/tests/integration/test_api.py b/listenbrainz/tests/integration/test_api.py index 4280f38875..f942e90b8b 100644 --- a/listenbrainz/tests/integration/test_api.py +++ b/listenbrainz/tests/integration/test_api.py @@ -1064,3 +1064,10 @@ def test_get_user_services(self): content_type="application/json" ) self.assert403(response) + + def test_user_recommendations(self): + r = self.client.get(url_for("api_v1.user_recommendations", playlist_user_name=self.followed_user["musicbrainz_id"])) + self.assert200(r) + + r = self.client.get(url_for("api_v1.user_recommendations", playlist_user_name="does not exist")) + self.assert404(r) diff --git a/listenbrainz/webserver/templates/playlists/recommendations.html b/listenbrainz/webserver/templates/playlists/recommendations.html index ba9c28f90d..8e07e6d151 100644 --- a/listenbrainz/webserver/templates/playlists/recommendations.html +++ b/listenbrainz/webserver/templates/playlists/recommendations.html @@ -1,11 +1,12 @@ {% extends 'user/base.html' %} {%- block title -%} + Created for {% if current_user.musicbrainz_id == user.musicbrainz_id %} - Your + you {% else %} - {{ user.musicbrainz_id }}'s  + {{ user.musicbrainz_id }} {%- endif -%} - Recommendations - ListenBrainz + - ListenBrainz {%- endblock-%} diff --git a/listenbrainz/webserver/templates/user/base.html b/listenbrainz/webserver/templates/user/base.html index ab4c54ec87..6f0baa3e81 100644 --- a/listenbrainz/webserver/templates/user/base.html +++ b/listenbrainz/webserver/templates/user/base.html @@ -26,7 +26,7 @@
  • Stats
  • Taste
  • Playlists
  • -
  • Recommendations
  • +
  • Created for you
  • diff --git a/listenbrainz/webserver/views/api.py b/listenbrainz/webserver/views/api.py index f247cdd4e0..4a433aa8e8 100644 --- a/listenbrainz/webserver/views/api.py +++ b/listenbrainz/webserver/views/api.py @@ -641,6 +641,29 @@ def get_playlists_collaborated_on_for_user(playlist_user_name): return jsonify(serialize_playlists(playlists, playlist_count, count, offset)) +@api_bp.route("/user//playlists/recommendations", methods=['GET', 'OPTIONS']) +@crossdomain +@ratelimit() +@api_listenstore_needed +def user_recommendations(playlist_user_name): + """ + Fetch recommendation playlist metadata in JSPF format without recordings for playlist_user_name. + This endpoint only lists playlists that are to be shown on the listenbrainz.org recommendations + pages. + + :statuscode 200: success + :statuscode 404: user not found + :resheader Content-Type: *application/json* + """ + + playlist_user = db_user.get_by_mb_id(playlist_user_name) + if playlist_user is None: + raise APINotFound("Cannot find user: %s" % playlist_user_name) + + playlists = db_playlist.get_recommendation_playlists_for_user(playlist_user.id) + return jsonify(serialize_playlists(playlists, len(playlists), 0, 0)) + + @api_bp.route("/user//services", methods=['GET', 'OPTIONS']) @crossdomain @ratelimit() diff --git a/listenbrainz/webserver/views/user.py b/listenbrainz/webserver/views/user.py index 7d341c9f85..f47e802b8b 100644 --- a/listenbrainz/webserver/views/user.py +++ b/listenbrainz/webserver/views/user.py @@ -13,7 +13,7 @@ from listenbrainz.db.missing_musicbrainz_data import get_user_missing_musicbrainz_data from listenbrainz.db.msid_mbid_mapping import fetch_track_metadata_for_items from listenbrainz.db.playlist import get_playlists_for_user, get_playlists_created_for_user, \ - get_playlists_collaborated_on + get_playlists_collaborated_on, get_recommendation_playlists_for_user 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 @@ -279,15 +279,14 @@ def recommendation_playlists(user_name: str): } playlists = [] - user_playlists, playlist_count = get_playlists_created_for_user( - user.id, False, count, offset) + user_playlists = get_recommendation_playlists_for_user( + user.id) for playlist in user_playlists: playlists.append(serialize_jspf(playlist)) props = { "playlists": playlists, "user": user_data, - "playlist_count": playlist_count, "logged_in_user_follows_user": logged_in_user_follows_user(user), }