From d2ee177374dacd968f64e7725ad83107228090bf Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 8 Sep 2024 07:04:50 +0000 Subject: [PATCH 1/5] feat: Add Music Neighborhood graph on Artist Page --- frontend/css/entity-pages.less | 9 +- frontend/css/music-neighborhood.less | 54 +++---- frontend/js/src/artist/ArtistPage.tsx | 129 +++++++++-------- .../music-neighborhood/MusicNeighborhood.tsx | 120 +++------------- .../components/SimilarArtist.tsx | 136 ++++++++++++++++++ listenbrainz/webserver/views/entity_pages.py | 18 ++- 6 files changed, 266 insertions(+), 200 deletions(-) create mode 100644 frontend/js/src/explore/music-neighborhood/components/SimilarArtist.tsx diff --git a/frontend/css/entity-pages.less b/frontend/css/entity-pages.less index 9a6ea2947b..9cf8fe34b1 100644 --- a/frontend/css/entity-pages.less +++ b/frontend/css/entity-pages.less @@ -146,18 +146,11 @@ flex-basis: 350px; } .top-listeners, - .reviews, - .similarity { + .reviews { flex: 1; flex-basis: 300px; } - .similarity .artists { - display: grid; - grid-auto-flow: row; - grid-template-columns: repeat(auto-fill, 50%); - } .reviews { - max-width: 50%; .critiquebrainz-button { margin-top: 2em; } diff --git a/frontend/css/music-neighborhood.less b/frontend/css/music-neighborhood.less index 2fe30bb222..c31bfa2819 100644 --- a/frontend/css/music-neighborhood.less +++ b/frontend/css/music-neighborhood.less @@ -146,38 +146,38 @@ } } } + } +} - .artist-similarity-graph-container { - flex: 1; - height: calc(100vh - @brainzplayer-height); - max-height: 1120px; // 80% of the max content width (so we have a square aspect ratio - overflow: auto; +.artist-similarity-graph-container { + flex: 1; + height: calc(100vh - @brainzplayer-height); + max-height: 1120px; // 80% of the max content width (so we have a square aspect ratio + overflow: auto; - .artist-similarity-graph-node { - cursor: pointer; + .artist-similarity-graph-node { + cursor: pointer; - .centered-text { - padding: 0 4px; - overflow: hidden; + .centered-text { + padding: 0 4px; + overflow: hidden; - display: flex; - align-items: center; - text-align: center; - border-radius: 50%; - height: inherit; + display: flex; + align-items: center; + text-align: center; + border-radius: 50%; + height: inherit; - .centered-text-inner { - width: 100%; - } - .ellipsis-3-lines { - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.1em; - } - } + .centered-text-inner { + width: 100%; + } + .ellipsis-3-lines { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.1em; } } } diff --git a/frontend/js/src/artist/ArtistPage.tsx b/frontend/js/src/artist/ArtistPage.tsx index 28e1873a6e..5f44be9dd4 100644 --- a/frontend/js/src/artist/ArtistPage.tsx +++ b/frontend/js/src/artist/ArtistPage.tsx @@ -9,7 +9,13 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { chain, isEmpty, isUndefined, partition, sortBy } from "lodash"; import { sanitize } from "dompurify"; -import { Link, useLoaderData, useLocation, useParams } from "react-router-dom"; +import { + Link, + useLoaderData, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; import { Helmet } from "react-helmet"; import { useQuery } from "@tanstack/react-query"; import GlobalAppContext from "../utils/GlobalAppContext"; @@ -30,12 +36,17 @@ import type { import ReleaseCard from "../explore/fresh-releases/components/ReleaseCard"; import { RouteQuery } from "../utils/Loader"; import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext"; +import SimilarArtistComponent from "../explore/music-neighborhood/components/SimilarArtist"; export type ArtistPageProps = { popularRecordings: PopularRecording[]; artist: MusicBrainzArtist; releaseGroups: ReleaseGroup[]; - similarArtists: SimilarArtist[]; + similarArtists: { + artists: SimilarArtist[]; + topReleaseGroupColor: ReleaseColor | undefined; + topRecordingColor: ReleaseColor | undefined; + }; listeningStats: ListeningStats; coverArt?: string; }; @@ -58,6 +69,8 @@ export default function ArtistPage(): JSX.Element { coverArt: coverArtSVG, } = data || {}; + const navigate = useNavigate(); + const { total_listen_count: listenCount, listeners: topListeners, @@ -133,6 +146,17 @@ export default function ArtistPage(): JSX.Element { notation: "compact", }); + const artistGraphNodeInfo = { + artist_mbid: artist?.artist_mbid, + name: artist?.name, + } as ArtistNodeInfo; + + const onArtistChange = (artist_mbid: string) => { + navigate(`/artist/${artist_mbid}`); + }; + + const graphParentElementRef = React.useRef(null); + const getReleaseCard = (rg: ReleaseGroup) => { return ( )} -
-

Similar artists

-
- {sortBy(similarArtists, "score") - .reverse() - .map((similarArtist) => { - const listenDetails = ( -
- - {similarArtist.name} - -
- ); - const artistAsListen: BaseListenFormat = { - listened_at: 0, - track_metadata: { - artist_name: similarArtist.name, - track_name: "", - }, - }; - return ( - } - // eslint-disable-next-line react/jsx-no-useless-fragment - feedbackComponent={<>} - compact - /> - ); - })} +
+ + {similarArtists && similarArtists.artists && ( + <> +

Similar Artists

+
+
-
-
-

Reviews

- {reviews?.length ? ( - <> - {reviews.slice(0, 3).map(getReviewEventContent)} - - More on CritiqueBrainz… - - - ) : ( - <> -

Be the first to review this artist on CritiqueBrainz

- - Add my review - - - )} -
+ + )} +
+

Reviews

+ {reviews?.length ? ( + <> + {reviews.slice(0, 3).map(getReviewEventContent)} + + More on CritiqueBrainz… + + + ) : ( + <> +

Be the first to review this artist on CritiqueBrainz

+ + Add my review + + + )}
); diff --git a/frontend/js/src/explore/music-neighborhood/MusicNeighborhood.tsx b/frontend/js/src/explore/music-neighborhood/MusicNeighborhood.tsx index 8569ab82c6..c760cd7f90 100644 --- a/frontend/js/src/explore/music-neighborhood/MusicNeighborhood.tsx +++ b/frontend/js/src/explore/music-neighborhood/MusicNeighborhood.tsx @@ -1,21 +1,19 @@ import * as React from "react"; -import tinycolor from "tinycolor2"; import { toast } from "react-toastify"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; -import { isEmpty, isEqual, kebabCase } from "lodash"; -import { useLoaderData, useLocation } from "react-router-dom"; +import { kebabCase } from "lodash"; +import { useLocation } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useQuery } from "@tanstack/react-query"; import { ToastMsg } from "../../notifications/Notifications"; import GlobalAppContext from "../../utils/GlobalAppContext"; import SearchBox from "./components/SearchBox"; -import SimilarArtistsGraph from "./components/SimilarArtistsGraph"; import Panel from "./components/Panel"; -import generateTransformedArtists from "./utils/generateTransformedArtists"; import { downloadComponentAsImage, copyImageToClipboard } from "./utils/utils"; import { RouteQuery } from "../../utils/Loader"; import { useBrainzPlayerDispatch } from "../../common/brainzplayer/BrainzPlayerContext"; +import SimilarArtist from "./components/SimilarArtist"; type MusicNeighborhoodLoaderData = { algorithm: string; @@ -23,20 +21,6 @@ type MusicNeighborhoodLoaderData = { }; const SIMILAR_ARTISTS_LIMIT_VALUE = 18; -const BACKGROUND_ALPHA = 0.2; -const MAXIMUM_LUMINANCE = 0.8; -const MINIMUM_LUMINANCE = 0.2; - -const colorGenerator = (): [tinycolor.Instance, tinycolor.Instance] => { - const initialColor = tinycolor(`hsv(${Math.random() * 360}, 100%, 90%)`); - return [initialColor, initialColor.clone().tetrad()[1]]; -}; -const isColorTooLight = (color: tinycolor.Instance): boolean => { - return color.getLuminance() > MAXIMUM_LUMINANCE; -}; -const isColorTooDark = (color: tinycolor.Instance): boolean => { - return color.getLuminance() < MINIMUM_LUMINANCE; -}; export default function MusicNeighborhood() { const location = useLocation(); @@ -46,7 +30,6 @@ export default function MusicNeighborhood() { const { algorithm: DEFAULT_ALGORITHM, artist_mbid: DEFAULT_ARTIST_MBID } = data || {}; const BASE_URL = `https://labs.api.listenbrainz.org/similar-artists/json?algorithm=${DEFAULT_ALGORITHM}&artist_mbids=`; - const DEFAULT_COLORS = colorGenerator(); const { APIService } = React.useContext(GlobalAppContext); const [similarArtistsLimit, setSimilarArtistsLimit] = React.useState( @@ -64,8 +47,14 @@ export default function MusicNeighborhood() { const [artistGraphNodeInfo, setArtistGraphNodeInfo] = React.useState< ArtistNodeInfo >(); + const [topAlbumReleaseColor, setTopAlbumReleaseColor] = React.useState< + ReleaseColor + >(); + const [ + topRecordingReleaseColor, + setTopRecordingReleaseColor, + ] = React.useState(); - const [colors, setColors] = React.useState(DEFAULT_COLORS); const [loading, setLoading] = React.useState(false); const [currentTracks, setCurrentTracks] = React.useState>([]); @@ -155,23 +144,6 @@ export default function MusicNeighborhood() { setSimilarArtistsList(completeSimilarArtistsList.slice(0, limit)); }; - const transformedArtists = React.useMemo( - () => - artistGraphNodeInfo - ? generateTransformedArtists( - artistGraphNodeInfo, - similarArtistsList, - colors[0], - colors[1], - similarArtistsLimit - ) - : { - nodes: [], - links: [], - }, - [artistGraphNodeInfo, similarArtistsList, colors, similarArtistsLimit] - ); - const fetchArtistInfo = React.useCallback( async (artistMBID: string): Promise => { const [ @@ -209,71 +181,16 @@ export default function MusicNeighborhood() { }; setArtistInfo(newArtistInfo); - const topAlbumReleaseColor = topAlbumsForArtist[0]?.release_color; - const topRecordingReleaseColor = topRecordingsForArtist[0]?.release_color; - let firstColor; - let secondColor; - if (!isEmpty(topAlbumReleaseColor)) { - const { red, green, blue } = topAlbumReleaseColor; - firstColor = tinycolor({ r: red, g: green, b: blue }); - } else { - // Do we want to pick a color from an array of predefined colors instead of random? - firstColor = tinycolor.random(); - } - if ( - !isEmpty(topRecordingReleaseColor) && - !isEqual(topAlbumReleaseColor, topRecordingReleaseColor) - ) { - const { red, green, blue } = topRecordingReleaseColor; - secondColor = tinycolor({ r: red, g: green, b: blue }); - // We should consider using another color library that allows us to calculate color distance - // better using deltaE algorithms. Looks into color.js and chroma.js for example. - const hue1 = firstColor.toHsv().h; - const hue2 = secondColor.toHsv().h; - const distanceBetweenColors = Math.min( - Math.abs(hue2 - hue1), - 360 - Math.abs(hue2 - hue1) - ); - if (distanceBetweenColors < 25) { - // Colors are too similar, set up for picking another color below. - secondColor = undefined; - } - } - if (!secondColor) { - // If we don't have required release info, base the second color on the first, - // randomly picking one of the tetrad complementary colors. - const randomTetradColor = Math.round(Math.random() * (3 - 1) + 1); - secondColor = tinycolor(firstColor).clone().tetrad()[randomTetradColor]; - } - - // Adjust the colors if they are too light or too dark - [firstColor, secondColor].forEach((color) => { - if (isColorTooLight(color)) { - color.darken(20).saturate(30); - } else if (isColorTooDark(color)) { - color.lighten(20).saturate(30); - } - }); - - setColors([firstColor, secondColor]); + setTopAlbumReleaseColor(topAlbumsForArtist[0]?.release_color ?? null); + setTopRecordingReleaseColor( + topRecordingsForArtist[0]?.release_color ?? null + ); return newArtistInfo; }, [APIService] ); - const backgroundGradient = React.useMemo(() => { - const releaseHue = colors[0] - .clone() - .setAlpha(BACKGROUND_ALPHA) - .toRgbString(); - const recordingHue = colors[1] - .clone() - .setAlpha(BACKGROUND_ALPHA) - .toRgbString(); - return `linear-gradient(180deg, ${releaseHue} 0%, ${recordingHue} 100%)`; - }, [colors]); - const onArtistChange = React.useCallback( async (artistMBID: string) => { try { @@ -344,10 +261,13 @@ export default function MusicNeighborhood() {
- {artistInfo && } diff --git a/frontend/js/src/explore/music-neighborhood/components/SimilarArtist.tsx b/frontend/js/src/explore/music-neighborhood/components/SimilarArtist.tsx new file mode 100644 index 0000000000..6a53449320 --- /dev/null +++ b/frontend/js/src/explore/music-neighborhood/components/SimilarArtist.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import tinycolor from "tinycolor2"; +import { isEmpty, isEqual } from "lodash"; +import SimilarArtistsGraph from "./SimilarArtistsGraph"; +import generateTransformedArtists from "../utils/generateTransformedArtists"; + +type SimilarArtistProps = { + onArtistChange: (artist_mbid: string) => void; + artistGraphNodeInfo: ArtistNodeInfo | undefined; + similarArtistsList: ArtistNodeInfo[]; + topAlbumReleaseColor: ReleaseColor | undefined; + topRecordingReleaseColor: ReleaseColor | undefined; + similarArtistsLimit: number; + graphParentElementRef: React.RefObject; +}; + +const BACKGROUND_ALPHA = 0.2; +const MAXIMUM_LUMINANCE = 0.8; +const MINIMUM_LUMINANCE = 0.2; + +const colorGenerator = (): [tinycolor.Instance, tinycolor.Instance] => { + const initialColor = tinycolor(`hsv(${Math.random() * 360}, 100%, 90%)`); + return [initialColor, initialColor.clone().tetrad()[1]]; +}; + +const isColorTooLight = (color: tinycolor.Instance): boolean => { + return color.getLuminance() > MAXIMUM_LUMINANCE; +}; +const isColorTooDark = (color: tinycolor.Instance): boolean => { + return color.getLuminance() < MINIMUM_LUMINANCE; +}; + +function SimilarArtist(props: SimilarArtistProps) { + const { + onArtistChange, + artistGraphNodeInfo, + similarArtistsList, + topAlbumReleaseColor, + topRecordingReleaseColor, + similarArtistsLimit, + graphParentElementRef, + } = props; + + const DEFAULT_COLORS = colorGenerator(); + const [artistColors, setArtistColors] = React.useState(DEFAULT_COLORS); + + const calculateNewArtistColors = React.useMemo(() => { + let firstColor; + let secondColor; + if (topAlbumReleaseColor && !isEmpty(topAlbumReleaseColor)) { + const { red, green, blue } = topAlbumReleaseColor; + firstColor = tinycolor({ r: red, g: green, b: blue }); + } else { + // Do we want to pick a color from an array of predefined colors instead of random? + firstColor = tinycolor.random(); + } + if ( + topRecordingReleaseColor && + !isEmpty(topRecordingReleaseColor) && + !isEqual(topAlbumReleaseColor, topRecordingReleaseColor) + ) { + const { red, green, blue } = topRecordingReleaseColor; + secondColor = tinycolor({ r: red, g: green, b: blue }); + // We should consider using another color library that allows us to calculate color distance + // better using deltaE algorithms. Looks into color.js and chroma.js for example. + const hue1 = firstColor.toHsv().h; + const hue2 = secondColor.toHsv().h; + const distanceBetweenColors = Math.min( + Math.abs(hue2 - hue1), + 360 - Math.abs(hue2 - hue1) + ); + if (distanceBetweenColors < 25) { + // Colors are too similar, set up for picking another color below. + secondColor = undefined; + } + } + if (!secondColor) { + // If we don't have required release info, base the second color on the first, + // randomly picking one of the tetrad complementary colors. + const randomTetradColor = Math.round(Math.random() * (3 - 1) + 1); + secondColor = tinycolor(firstColor).clone().tetrad()[randomTetradColor]; + } + + // Adjust the colors if they are too light or too dark + [firstColor, secondColor].forEach((color) => { + if (isColorTooLight(color)) { + color.darken(20).saturate(30); + } else if (isColorTooDark(color)) { + color.lighten(20).saturate(30); + } + }); + + setArtistColors([firstColor, secondColor]); + }, [topAlbumReleaseColor, topRecordingReleaseColor]); + + const transformedArtists = React.useMemo( + () => + artistGraphNodeInfo + ? generateTransformedArtists( + artistGraphNodeInfo, + similarArtistsList, + artistColors[0], + artistColors[1], + similarArtistsLimit + ) + : { + nodes: [], + links: [], + }, + [artistGraphNodeInfo, similarArtistsList, artistColors, similarArtistsLimit] + ); + + const backgroundGradient = React.useMemo(() => { + const releaseHue = artistColors[0] + .clone() + .setAlpha(BACKGROUND_ALPHA) + .toRgbString(); + const recordingHue = artistColors[1] + .clone() + .setAlpha(BACKGROUND_ALPHA) + .toRgbString(); + + return `linear-gradient(180deg, ${releaseHue} 0%, ${recordingHue} 100%)`; + }, [artistColors]); + + return ( + + ); +} + +export default React.memo(SimilarArtist); diff --git a/listenbrainz/webserver/views/entity_pages.py b/listenbrainz/webserver/views/entity_pages.py index 77dece69d5..39ae2faa47 100644 --- a/listenbrainz/webserver/views/entity_pages.py +++ b/listenbrainz/webserver/views/entity_pages.py @@ -143,10 +143,20 @@ def artist_entity(artist_mbid): ts_curs, [artist_mbid], "session_based_days_7500_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", - 15 + 18 ) except IndexError: similar_artists = [] + + try: + top_release_group_color = popularity.get_top_release_groups_for_artist(db_conn, ts_conn, artist_mbid, 1)[0]["release_color"] + except IndexError: + top_release_group_color = None + + try: + top_recording_color = popularity.get_top_recordings_for_artist(db_conn, ts_conn, artist_mbid, 1)[0]["release_color"] + except IndexError: + top_recording_color = None release_group_data = artist_data[0].release_group_data release_group_mbids = [rg["mbid"] for rg in release_group_data] @@ -176,7 +186,11 @@ def artist_entity(artist_mbid): data = { "artist": artist, "popularRecordings": popular_recordings, - "similarArtists": similar_artists, + "similarArtists": { + "artists": similar_artists, + "topReleaseGroupColor": top_release_group_color, + "topRecordingColor": top_recording_color + }, "listeningStats": listening_stats, "releaseGroups": release_groups, "coverArt": cover_art From c15b3d34f96236765a833d18eb2e52636560ffba Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 8 Sep 2024 12:05:48 +0000 Subject: [PATCH 2/5] fix: Music Neighborhood Dropdown UI --- frontend/css/music-neighborhood.less | 10 ++ .../components/SearchBox.tsx | 131 ++++++++++++------ 2 files changed, 98 insertions(+), 43 deletions(-) diff --git a/frontend/css/music-neighborhood.less b/frontend/css/music-neighborhood.less index c31bfa2819..4dc8a078f6 100644 --- a/frontend/css/music-neighborhood.less +++ b/frontend/css/music-neighborhood.less @@ -18,6 +18,16 @@ justify-content: space-between; } + #artist-search-button { + margin-right: 10px; + background-color: @blue; + + &:hover { + background-color: @mb_blue_light; + border-color: @mb_blue_dark; + } + } + .share-buttons { margin-left: 30px; display: flex; diff --git a/frontend/js/src/explore/music-neighborhood/components/SearchBox.tsx b/frontend/js/src/explore/music-neighborhood/components/SearchBox.tsx index 2a3383c296..207adc1809 100644 --- a/frontend/js/src/explore/music-neighborhood/components/SearchBox.tsx +++ b/frontend/js/src/explore/music-neighborhood/components/SearchBox.tsx @@ -1,10 +1,16 @@ import React, { useMemo, useState } from "react"; -import { faSearch, faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { + faSearch, + faMinus, + faPlus, + faSpinner, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { throttle } from "lodash"; import { toast } from "react-toastify"; import { ToastMsg } from "../../../notifications/Notifications"; import GlobalAppContext from "../../../utils/GlobalAppContext"; +import DropdownRef from "../../../utils/Dropdown"; interface SearchBoxProps { currentSimilarArtistsLimit: number; @@ -29,8 +35,10 @@ function SearchBox({ const [searchResults, setSearchResults] = useState< Array >([]); - const [searchQuery, setSearchQuery] = useState(""); - const [openDropdown, setOpenDropdown] = useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); + const dropdownRef = DropdownRef(); + const searchInputRef = React.useRef(null); + const [loading, setLoading] = React.useState(false); const getArtists = useMemo(() => { return throttle( @@ -49,6 +57,8 @@ function SearchBox({ />, { toastId: "error" } ); + } finally { + setLoading(false); } } }, @@ -57,22 +67,9 @@ function SearchBox({ ); }, []); - // Lookup the artist based on the query - const handleQueryChange = async (query: string) => { - setSearchQuery(query); - if (query.length && query.trim().length) { - setOpenDropdown(true); - } else { - setOpenDropdown(false); - } - await getArtists(query); - }; - // Handle button click on an artist in the dropdown list - const handleButtonClick = (artist: ArtistTypeSearchResult) => { - onArtistChange(artist.id); - setOpenDropdown(false); - setSearchQuery(artist.name); + const handleButtonClick = (artistId: string) => { + onArtistChange(artistId); }; const increment = () => { onSimilarArtistsLimitChange(currentSimilarArtistsLimit + 1); @@ -81,39 +78,87 @@ function SearchBox({ onSimilarArtistsLimitChange(currentSimilarArtistsLimit - 1); }; + const reset = () => { + setSearchQuery(""); + setSearchResults([]); + setLoading(false); + searchInputRef?.current?.focus(); + }; + + React.useEffect(() => { + if (!searchQuery) { + return; + } + setLoading(true); + getArtists(searchQuery); + }, [searchQuery, getArtists]); + return ( <> -
-
- handleQueryChange(e.target.value)} - placeholder="Artist name" - value={searchQuery} - aria-haspopup={Boolean(searchResults?.length)} - /> - -
-
- {openDropdown && - searchResults?.map((artist) => { + + {Boolean(searchResults?.length) && ( + + )}
From 812a94c2434017496001b536e98c60000b13c4b3 Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Sun, 8 Sep 2024 17:29:04 +0000 Subject: [PATCH 3/5] lint: Fix PEP8 Issues --- listenbrainz/webserver/views/entity_pages.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/listenbrainz/webserver/views/entity_pages.py b/listenbrainz/webserver/views/entity_pages.py index 39ae2faa47..a913105670 100644 --- a/listenbrainz/webserver/views/entity_pages.py +++ b/listenbrainz/webserver/views/entity_pages.py @@ -147,9 +147,11 @@ def artist_entity(artist_mbid): ) except IndexError: similar_artists = [] - + try: - top_release_group_color = popularity.get_top_release_groups_for_artist(db_conn, ts_conn, artist_mbid, 1)[0]["release_color"] + top_release_group_color = popularity.get_top_release_groups_for_artist( + db_conn, ts_conn, artist_mbid, 1 + )[0]["release_color"] except IndexError: top_release_group_color = None From f1f800214be46d51e49e7ceacd24c985d1d20a1a Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Mon, 9 Sep 2024 11:15:07 +0000 Subject: [PATCH 4/5] refactor: Entity Review Component --- frontend/css/entity-pages.less | 54 +++++++++++++++++++++++++-- frontend/js/src/album/AlbumPage.tsx | 4 +- frontend/js/src/artist/ArtistPage.tsx | 12 ++++-- frontend/js/src/utils/types.d.ts | 1 + frontend/js/src/utils/utils.tsx | 33 +++++++++------- 5 files changed, 84 insertions(+), 20 deletions(-) diff --git a/frontend/css/entity-pages.less b/frontend/css/entity-pages.less index 9cf8fe34b1..3e88564ea5 100644 --- a/frontend/css/entity-pages.less +++ b/frontend/css/entity-pages.less @@ -151,6 +151,11 @@ flex-basis: 300px; } .reviews { + .review-cards { + display: flex; + flex-direction: column; + gap: 10px; + } .critiquebrainz-button { margin-top: 2em; } @@ -191,14 +196,57 @@ } .review { - position: relative; + border: 1px solid #ddd; + border-radius: 8px; + padding: 16px; + margin: 16px 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background-color: #fff; + transition: box-shadow 0.3s ease; + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + .review-card-header { + display: flex; + align-items: center; + justify-content: space-between; + + .rating-stars { + margin-left: 8px; + } + .review-card-header-author { + font-size: 12px; + font-weight: bold; + } + } .text { max-height: 10em; overflow: hidden; text-overflow: ellipsis; + font-size: 14px; + line-height: 1.6; + color: #333; + + display: -webkit-box; + line-clamp: 4; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; } - .read-more { - text-align: initial; + .review-card-footer { + display: flex; + justify-content: space-between; + font-size: 12px; + font-weight: bold; + color: #333; + .read-more-link { + text-decoration: none; + transition: color 0.3s ease; + color: #353070; + + &:hover { + color: #4a4a8c; + } + } } } diff --git a/frontend/js/src/album/AlbumPage.tsx b/frontend/js/src/album/AlbumPage.tsx index 2d72647fa8..cf108c48ae 100644 --- a/frontend/js/src/album/AlbumPage.tsx +++ b/frontend/js/src/album/AlbumPage.tsx @@ -457,7 +457,9 @@ export default function AlbumPage(): JSX.Element {

Reviews

{reviews?.length ? ( <> - {reviews.slice(0, 3).map(getReviewEventContent)} +
+ {reviews.slice(0, 3).map(getReviewEventContent)} +
- {similarArtists && similarArtists.artists && ( + {similarArtists && similarArtists.artists.length > 0 ? ( <>

Similar Artists

@@ -436,15 +436,19 @@ export default function ArtistPage(): JSX.Element { />
- )} + ) : null}

Reviews

{reviews?.length ? ( <> - {reviews.slice(0, 3).map(getReviewEventContent)} +
+ {reviews.slice(0, 3).map(getReviewEventContent)} +
More on CritiqueBrainz… @@ -455,6 +459,8 @@ export default function ArtistPage(): JSX.Element { Add my review diff --git a/frontend/js/src/utils/types.d.ts b/frontend/js/src/utils/types.d.ts index 395aa58cc2..20aaad9cd7 100644 --- a/frontend/js/src/utils/types.d.ts +++ b/frontend/js/src/utils/types.d.ts @@ -539,6 +539,7 @@ type CritiqueBrainzReview = { languageCode?: string; rating?: number; user_name?: string; + published_on?: string; }; type CritiqueBrainzReviewAPI = { diff --git a/frontend/js/src/utils/utils.tsx b/frontend/js/src/utils/utils.tsx index d3c0b6ccee..3288c4f17b 100644 --- a/frontend/js/src/utils/utils.tsx +++ b/frontend/js/src/utils/utils.tsx @@ -1005,20 +1005,27 @@ export function getReviewEventContent( const userName = _.get(eventMetadata, "user_name") ?? _.get(eventMetadata, "user.display_name"); + const publishedOn = _.get(eventMetadata, "published_on"); return (
{!isUndefined(eventMetadata.rating) && isFinite(eventMetadata.rating) && ( -
- Rating: - {}} - className="rating-stars" - ratingValue={eventMetadata.rating} - transition - size={20} - iconsCount={5} - /> +
+
+ Rating: + {}} + className="rating-stars" + ratingValue={eventMetadata.rating} + transition + size={20} + iconsCount={5} + /> +
+ + {publishedOn && + ` on ${preciseTimestamp(publishedOn, "includeYear")}`} +
)}
@@ -1029,13 +1036,13 @@ export function getReviewEventContent( {additionalContent}
-
+
by {userName} Read on CritiqueBrainz From f11f132039e4628b327cf955369c5637b42f744b Mon Sep 17 00:00:00 2001 From: anshg1214 Date: Mon, 9 Sep 2024 11:59:06 +0000 Subject: [PATCH 5/5] feat: Open CB Review Modal to review entity --- frontend/js/src/album/AlbumPage.tsx | 37 +++++++++++++++----- frontend/js/src/artist/ArtistPage.tsx | 33 ++++++++++++------ frontend/js/src/cb-review/CBReviewModal.tsx | 38 +++++++++++++++++++-- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/frontend/js/src/album/AlbumPage.tsx b/frontend/js/src/album/AlbumPage.tsx index cf108c48ae..91e0a67b0d 100644 --- a/frontend/js/src/album/AlbumPage.tsx +++ b/frontend/js/src/album/AlbumPage.tsx @@ -12,6 +12,7 @@ import tinycolor from "tinycolor2"; import { Helmet } from "react-helmet"; import { useQuery } from "@tanstack/react-query"; import { Link, useLocation, useParams } from "react-router-dom"; +import NiceModal from "@ebay/nice-modal-react"; import { getRelIconLink, ListeningStats, @@ -29,6 +30,7 @@ import ListenCard from "../common/listens/ListenCard"; import OpenInMusicBrainzButton from "../components/OpenInMusicBrainz"; import { RouteQuery } from "../utils/Loader"; import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext"; +import CBReviewModal from "../cb-review/CBReviewModal"; // not the same format of tracks as what we get in the ArtistPage props type AlbumRecording = { @@ -62,6 +64,7 @@ export default function AlbumPage(): JSX.Element { const { APIService } = React.useContext(GlobalAppContext); const location = useLocation(); const params = useParams() as { albumMBID: string }; + const { albumMBID } = params; const { data } = useQuery( RouteQuery(["album", params], location.pathname) ); @@ -468,16 +471,32 @@ export default function AlbumPage(): JSX.Element { ) : ( - <> -

Be the first to review this album on CritiqueBrainz

- - Add my review - - +

Be the first to review this album on CritiqueBrainz

)} +
diff --git a/frontend/js/src/artist/ArtistPage.tsx b/frontend/js/src/artist/ArtistPage.tsx index 9617250893..ccbff5ccf7 100644 --- a/frontend/js/src/artist/ArtistPage.tsx +++ b/frontend/js/src/artist/ArtistPage.tsx @@ -18,6 +18,7 @@ import { } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useQuery } from "@tanstack/react-query"; +import NiceModal from "@ebay/nice-modal-react"; import GlobalAppContext from "../utils/GlobalAppContext"; import { getReviewEventContent } from "../utils/utils"; import TagsComponent from "../tags/TagsComponent"; @@ -37,6 +38,7 @@ import ReleaseCard from "../explore/fresh-releases/components/ReleaseCard"; import { RouteQuery } from "../utils/Loader"; import { useBrainzPlayerDispatch } from "../common/brainzplayer/BrainzPlayerContext"; import SimilarArtistComponent from "../explore/music-neighborhood/components/SimilarArtist"; +import CBReviewModal from "../cb-review/CBReviewModal"; export type ArtistPageProps = { popularRecordings: PopularRecording[]; @@ -454,18 +456,27 @@ export default function ArtistPage(): JSX.Element { ) : ( - <> -

Be the first to review this artist on CritiqueBrainz

- - Add my review - - +

Be the first to review this artist on CritiqueBrainz

)} +
); diff --git a/frontend/js/src/cb-review/CBReviewModal.tsx b/frontend/js/src/cb-review/CBReviewModal.tsx index 2846c51461..c408be71a7 100644 --- a/frontend/js/src/cb-review/CBReviewModal.tsx +++ b/frontend/js/src/cb-review/CBReviewModal.tsx @@ -26,7 +26,8 @@ import Loader from "../components/Loader"; import { ToastMsg } from "../notifications/Notifications"; export type CBReviewModalProps = { - listen: Listen; + listen?: Listen; + entityToReview?: ReviewableEntity[]; }; iso.registerLocale(eng); // library requires language of the language list to be initiated @@ -39,13 +40,15 @@ const MBBaseUrl = "https://metabrainz.org"; // only used for href // gets all iso-639-1 languages and codes for dropdown const allLanguagesKeyValue = Object.entries(iso.getNames("en")); -export default NiceModal.create(({ listen }: CBReviewModalProps) => { +export default NiceModal.create((props: CBReviewModalProps) => { + const { listen, entityToReview: entityToReviewProps } = props; const modal = useModal(); const navigate = useNavigate(); const closeModal = React.useCallback(() => { modal.hide(); document?.body?.classList?.remove("modal-open"); + document?.body?.getElementsByClassName("modal-backdrop")[0]?.remove(); setTimeout(modal.remove, 200); }, [modal]); @@ -160,6 +163,9 @@ export default NiceModal.create(({ listen }: CBReviewModalProps) => { React.useEffect(() => { /* determine entity functions */ + if (!listen) { + return; + } const getAllEntities = async () => { if (!listen) { return; @@ -235,6 +241,32 @@ export default NiceModal.create(({ listen }: CBReviewModalProps) => { } }, [listen, getGroupMBIDFromRelease, getRecordingMBIDFromTrack, handleError]); + React.useEffect(() => { + if (!entityToReviewProps || !entityToReviewProps.length) { + return; + } + + const recordingEntityToSet = entityToReviewProps.find( + (entity) => entity.type === "recording" + ); + + const releaseGroupEntityToSet = entityToReviewProps.find( + (entity) => entity.type === "release_group" + ); + + const artistEntityToSet = entityToReviewProps.find( + (entity) => entity.type === "artist" + ); + + setRecordingEntity(recordingEntityToSet!); + setReleaseGroupEntity(releaseGroupEntityToSet!); + setArtistEntity(artistEntityToSet!); + + setEntityToReview( + recordingEntityToSet! || releaseGroupEntityToSet! || artistEntityToSet! + ); + }, [entityToReviewProps]); + /* input handling */ const handleLanguageChange = React.useCallback( (event: React.ChangeEvent) => { @@ -433,7 +465,7 @@ export default NiceModal.create(({ listen }: CBReviewModalProps) => { return (
{/* Show warning when recordingEntity is not available */} - {!recordingEntity && ( + {!recordingEntity && listen && (
We could not find a recording for {getTrackName(listen)}.