diff --git a/apps/web/src/api/library.ts b/apps/web/src/api/library.ts index 52f4f61c..cd38d530 100644 --- a/apps/web/src/api/library.ts +++ b/apps/web/src/api/library.ts @@ -1,5 +1,7 @@ import { client } from "."; +import { Album } from "../types/album"; import { Artist } from "../types/artist"; +import { Track } from "../types/track"; export const getSongByUri = async (uri: string) => { if (uri.includes("app.rocksky.scrobble")) { @@ -162,3 +164,57 @@ export const getArtistListeners = async (uri: string, limit: number) => { ); return response.data; }; + +export const getAlbumsByGenre = async ( + genre: string, + offset = 0, + limit = 20, +) => { + const response = await client.get<{ albums: Album[] }>( + "/xrpc/app.rocksky.album.getAlbums", + { + params: { + genre, + limit, + offset, + }, + }, + ); + return response.data; +}; + +export const getArtistsByGenre = async ( + genre: string, + offset = 0, + limit = 20, +) => { + const response = await client.get<{ artists: Artist[] }>( + "/xrpc/app.rocksky.artist.getArtists", + { + params: { + genre, + limit, + offset, + }, + }, + ); + return response.data; +}; + +export const getTracksByGenre = async ( + genre: string, + offset = 0, + limit = 20, +) => { + const response = await client.get<{ tracks: Track[] }>( + "/xrpc/app.rocksky.song.getSongs", + { + params: { + genre, + limit, + offset, + }, + }, + ); + return response.data; +}; diff --git a/apps/web/src/components/Handle/Handle.tsx b/apps/web/src/components/Handle/Handle.tsx index bc365944..57e5d3b5 100644 --- a/apps/web/src/components/Handle/Handle.tsx +++ b/apps/web/src/components/Handle/Handle.tsx @@ -261,12 +261,13 @@ function Handle(props: HandleProps) { {tags.length > 0 && (
{tags.map((genre) => ( - # {genre} - + ))}
)} diff --git a/apps/web/src/hooks/useLibrary.tsx b/apps/web/src/hooks/useLibrary.tsx index 4f538c0e..a69b22a6 100644 --- a/apps/web/src/hooks/useLibrary.tsx +++ b/apps/web/src/hooks/useLibrary.tsx @@ -2,14 +2,17 @@ import { useQuery } from "@tanstack/react-query"; import { getAlbum, getAlbums, + getAlbumsByGenre, getArtist, getArtistAlbums, getArtistListeners, getArtists, + getArtistsByGenre, getArtistTracks, getLovedTracks, getSongByUri, getTracks, + getTracksByGenre, } from "../api/library"; export const useSongByUriQuery = (uri: string) => @@ -117,3 +120,40 @@ export const useArtistListenersQuery = (uri: string, limit = 10) => enabled: !!uri, select: (data) => data.listeners, }); + +export const useTracksByGenreQuery = (genre: string, offset = 0, limit = 20) => + useQuery({ + queryKey: ["tracks", genre, offset, limit], + queryFn: () => getTracksByGenre(genre, offset, limit), + enabled: !!genre, + select: (data) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?.tracks.map((x: any) => ({ + ...x, + scrobbles: x.playCount, + })), + }); + +export const useAlbumsByGenreQuery = (genre: string, offset = 0, limit = 20) => + useQuery({ + queryKey: ["albums", genre, offset, limit], + queryFn: () => getAlbumsByGenre(genre, offset, limit), + enabled: !!genre, + select: (data) => + data?.albums.map((x) => ({ + ...x, + scrobbles: x.playCount, + })), + }); + +export const useArtistsByGenreQuery = (genre: string, offset = 0, limit = 20) => + useQuery({ + queryKey: ["artists", genre, offset, limit], + queryFn: () => getArtistsByGenre(genre, offset, limit), + enabled: !!genre, + select: (data) => + data?.artists.map((x) => ({ + ...x, + scrobbles: x.playCount, + })), + }); diff --git a/apps/web/src/pages/album/Album.tsx b/apps/web/src/pages/album/Album.tsx index 5d7006c4..d9d60515 100644 --- a/apps/web/src/pages/album/Album.tsx +++ b/apps/web/src/pages/album/Album.tsx @@ -198,12 +198,13 @@ const Album = () => {
{(album?.tags || []).map((genre) => ( - # {genre} - + ))}
diff --git a/apps/web/src/pages/artist/Artist.tsx b/apps/web/src/pages/artist/Artist.tsx index 25d617a2..d5f83d2c 100644 --- a/apps/web/src/pages/artist/Artist.tsx +++ b/apps/web/src/pages/artist/Artist.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { ExternalLink } from "@styled-icons/evaicons-solid"; -import { useParams } from "@tanstack/react-router"; +import { Link, useParams } from "@tanstack/react-router"; import { Avatar } from "baseui/avatar"; import { HeadingMedium, HeadingXSmall, LabelMedium } from "baseui/typography"; import { useAtomValue, useSetAtom } from "jotai"; @@ -215,12 +215,13 @@ const Artist = () => { {artist && (
{(artist?.tags || []).map((genre) => ( - # {genre} - + ))}
)} diff --git a/apps/web/src/pages/genre/Genre.tsx b/apps/web/src/pages/genre/Genre.tsx new file mode 100644 index 00000000..68da4dd5 --- /dev/null +++ b/apps/web/src/pages/genre/Genre.tsx @@ -0,0 +1,87 @@ +import { HeadingMedium } from "baseui/typography"; +import Main from "../../layouts/Main"; +import { Tab, Tabs } from "baseui/tabs-motion"; +import React, { useState } from "react"; +import Artists from "./artists"; +import Albums from "./albums"; +import Tracks from "./tracks"; +import _ from "lodash"; +import { useParams } from "@tanstack/react-router"; + +export default function Genre() { + const { id: genre } = useParams({ strict: false }); + const [activeKey, setActiveKey] = useState("0"); + return ( +
+
+ + {_.upperFirst(genre)} music + + + { + setActiveKey(activeKey); + }} + overrides={{ + TabHighlight: { + style: { + backgroundColor: "var(--color-purple)", + }, + }, + TabBorder: { + style: { + display: "none", + }, + }, + }} + activateOnFocus + > + + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/pages/genre/albums/Albums.tsx b/apps/web/src/pages/genre/albums/Albums.tsx new file mode 100644 index 00000000..890ca772 --- /dev/null +++ b/apps/web/src/pages/genre/albums/Albums.tsx @@ -0,0 +1,77 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { useAlbumsByGenreQuery } from "../../../hooks/useLibrary"; +import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; +import { BlockProps } from "baseui/block"; +import numeral from "numeral"; +import dayjs from "dayjs"; + +const itemProps: BlockProps = { + display: "flex", + alignItems: "flex-start", + flexDirection: "column", +}; + +function Albums() { + const { id: genre } = useParams({ strict: false }); + const { data, isLoading } = useAlbumsByGenreQuery(genre!, 0, 20); + return ( + <> + {!isLoading && ( + + {data?.map((album) => ( + +
+ + {album.title} + + + {album.title} + + + + {album.artist} + + + + {numeral(album.playCount).format("0,0")} plays + + + {album.releaseDate + ? dayjs(album.releaseDate).format("MMMM D, YYYY") + : album.year} + +
+
+ ))} +
+ )} + + ); +} + +export default Albums; diff --git a/apps/web/src/pages/genre/albums/index.tsx b/apps/web/src/pages/genre/albums/index.tsx new file mode 100644 index 00000000..7203af7d --- /dev/null +++ b/apps/web/src/pages/genre/albums/index.tsx @@ -0,0 +1,3 @@ +import Albums from "./Albums"; + +export default Albums; diff --git a/apps/web/src/pages/genre/artists/Artists.tsx b/apps/web/src/pages/genre/artists/Artists.tsx new file mode 100644 index 00000000..3aa8449d --- /dev/null +++ b/apps/web/src/pages/genre/artists/Artists.tsx @@ -0,0 +1,61 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { useArtistsByGenreQuery } from "../../../hooks/useLibrary"; +import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; +import { BlockProps } from "baseui/block"; +import numeral from "numeral"; + +const itemProps: BlockProps = { + display: "flex", + alignItems: "flex-start", + flexDirection: "column", +}; + +function Artists() { + const { id: genre } = useParams({ strict: false }); + const { data, isLoading } = useArtistsByGenreQuery(genre!, 0, 20); + return ( + <> + {!isLoading && ( + + {data?.map((artist) => ( + +
+ + {artist.name} + + + {artist.name} + + + {numeral(artist.playCount).format("0,0")} plays + +
+
+ ))} +
+ )} + + ); +} + +export default Artists; diff --git a/apps/web/src/pages/genre/artists/index.tsx b/apps/web/src/pages/genre/artists/index.tsx new file mode 100644 index 00000000..ae0d5e14 --- /dev/null +++ b/apps/web/src/pages/genre/artists/index.tsx @@ -0,0 +1,3 @@ +import Artists from "./Artists"; + +export default Artists; diff --git a/apps/web/src/pages/genre/index.tsx b/apps/web/src/pages/genre/index.tsx new file mode 100644 index 00000000..380cc642 --- /dev/null +++ b/apps/web/src/pages/genre/index.tsx @@ -0,0 +1,3 @@ +import Genre from "./Genre"; + +export default Genre; diff --git a/apps/web/src/pages/genre/tracks/Tracks.tsx b/apps/web/src/pages/genre/tracks/Tracks.tsx new file mode 100644 index 00000000..e9f2a0c6 --- /dev/null +++ b/apps/web/src/pages/genre/tracks/Tracks.tsx @@ -0,0 +1,166 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { useTracksByGenreQuery } from "../../../hooks/useLibrary"; +import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; +import numeral from "numeral"; + +type Row = { + id: string; + title: string; + artist: string; + albumArtist: string; + albumArt: string; + albumUri?: string; + artistUri?: string; + uri: string; + scrobbles: number; + index: number; +}; + +function Tracks() { + const { id: genre } = useParams({ strict: false }); + const { data, isLoading } = useTracksByGenreQuery(genre!, 0, 20); + return ( + <> + {!isLoading && ( + <> + ({ + id: x.id, + title: x.title, + artist: x.artist, + albumArtist: x.albumArtist, + albumArt: x.albumArt, + uri: x.uri, + scrobbles: x.scrobbles, + albumUri: x.albumUri, + artistUri: x.artistUri, + index, + }))} + divider="clean" + overrides={{ + TableHeadRow: { + style: { + display: "none", + }, + }, + TableBodyCell: { + style: { + verticalAlign: "center", + }, + }, + TableBodyRow: { + style: { + backgroundColor: "var(--color-background)", + ":hover": { + backgroundColor: "var(--color-menu-hover)", + }, + }, + }, + TableEmptyMessage: { + style: { + backgroundColor: "var(--color-background)", + }, + }, + Table: { + style: { + backgroundColor: "var(--color-background)", + }, + }, + }} + > + + {(row: Row) => ( +
+
+
+ {row.index + 1} +
+
+ {row.albumUri && ( + + {!!row.albumArt && ( + {row.title} + )} + {!row.albumArt && ( +
+ )} + + )} + {!row.albumUri && ( +
+ {!!row.albumArt && ( + {row.title} + )} + {!row.albumArt && ( +
+ )} +
+ )} +
+ + {row.title} + + {row.artistUri && ( + + {row.albumArtist} + + )} + {!row.artistUri && ( +
+ {row.albumArtist} +
+ )} +
+
+ )} + + + {(row: Row, index?: number) => ( +
+
+ {numeral(row.scrobbles).format("0,0")}{" "} + {index == 0 && " scrobbles"} +
+ +
+ )} +
+ + + )} + + ); +} + +export default Tracks; diff --git a/apps/web/src/pages/genre/tracks/index.tsx b/apps/web/src/pages/genre/tracks/index.tsx new file mode 100644 index 00000000..62b1b28a --- /dev/null +++ b/apps/web/src/pages/genre/tracks/index.tsx @@ -0,0 +1,3 @@ +import Tracks from "./Tracks"; + +export default Tracks; diff --git a/apps/web/src/pages/genre/tracks/styles.tsx b/apps/web/src/pages/genre/tracks/styles.tsx new file mode 100644 index 00000000..d7874a02 --- /dev/null +++ b/apps/web/src/pages/genre/tracks/styles.tsx @@ -0,0 +1,135 @@ +export default { + pagination: { + Root: { + style: { + justifyContent: "center", + marginTop: "30px", + }, + }, + DropdownContainer: { + style: { + backgroundColor: "var(--color-background)", + }, + }, + Select: { + props: { + overrides: { + Root: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + ":hover": { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + }, + }, + }, + ControlContainer: { + style: { + outline: "none", + backgroundColor: "var(--color-background)", + color: "var(--color-text) !important", + ":hover": { + backgroundColor: "var(--color-background)", + color: "var(--color-text) !important", + }, + }, + }, + SelectArrow: { + props: { + overrides: { + Svg: { + style: { color: "var(--color-text)" }, + }, + }, + }, + }, + DropdownListItem: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + ":hover": { + backgroundColor: "var(--color-menu-hover)", + color: "var(--color-text)", + }, + }, + }, + SingleValue: { + style: { + color: "var(--color-text)", + }, + }, + Dropdown: { + style: { + outline: "none", + backgroundColor: "var(--color-background)", + }, + }, + DropdownContainer: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + }, + }, + Input: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + ":hover": { + backgroundColor: "var(--color-background) !important", + color: "var(--color-text) !important", + }, + }, + }, + InputContainer: { + style: { + backgroundColor: "var(--color-background)", + ":hover": { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + }, + }, + }, + Popover: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + }, + }, + OptionContent: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + }, + }, + }, + }, + }, + PrevButton: { + style: { + backgroundColor: "var(--color-background) !important", + color: "var(--color-text) !important", + ":hover": { + backgroundColor: "var(--color-background) !important", + color: "var(--color-text) !important", + }, + }, + }, + NextButton: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + ":hover": { + backgroundColor: "var(--color-background)", + color: "var(--color-text) ", + }, + }, + }, + MaxLabel: { + style: { + backgroundColor: "var(--color-background)", + color: "var(--color-text)", + }, + }, + }, +}; diff --git a/apps/web/src/pages/home/feed/Feed.tsx b/apps/web/src/pages/home/feed/Feed.tsx index c365f9c4..5813faa7 100644 --- a/apps/web/src/pages/home/feed/Feed.tsx +++ b/apps/web/src/pages/home/feed/Feed.tsx @@ -206,12 +206,13 @@ function Feed() { {(song?.tags || []).length > 0 && (
{(song?.tags || []).map((genre: string) => ( - # {genre} - + ))}
)} diff --git a/apps/web/src/pages/profile/Profile.tsx b/apps/web/src/pages/profile/Profile.tsx index c469b491..f5b11779 100644 --- a/apps/web/src/pages/profile/Profile.tsx +++ b/apps/web/src/pages/profile/Profile.tsx @@ -33,6 +33,7 @@ import Circles from "./circles"; import TopTrack from "./toptrack"; import { useArtistsQuery } from "../../hooks/useLibrary"; import { getLastDays } from "../../lib/date"; +import { Link } from "@tanstack/react-router"; const Group = styled.div` display: flex; @@ -306,12 +307,13 @@ function Profile(props: ProfileProps) { {tags.length > 0 && (
{tags.map((genre) => ( - # {genre} - + ))}
)} diff --git a/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx b/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx index 12dd30ac..fb53a687 100644 --- a/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx +++ b/apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx @@ -144,23 +144,23 @@ function TopAlbums() { - + {album.title} - + {album.artistUri && ( - + {album.artist} - + )} {!album.artistUri && ( - + {album.artist} - + )} {album.scrobbles} plays diff --git a/apps/web/src/pages/song/Song.tsx b/apps/web/src/pages/song/Song.tsx index e1df473d..27e42e23 100644 --- a/apps/web/src/pages/song/Song.tsx +++ b/apps/web/src/pages/song/Song.tsx @@ -320,12 +320,13 @@ const Song = () => { {(song?.tags || []).length > 0 && (
{(song?.tags || []).map((genre) => ( - # {genre} - + ))}
)} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 8a60e90c..063d8687 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as GoogledriveIndexRouteImport } from './routes/googledrive/index' import { Route as DropboxIndexRouteImport } from './routes/dropbox/index' import { Route as GoogledriveIdRouteImport } from './routes/googledrive/$id' +import { Route as GenreIdRouteImport } from './routes/genre/$id' import { Route as DropboxIdRouteImport } from './routes/dropbox/$id' import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index' import { Route as ProfileDidTracksRouteImport } from './routes/profile/$did/tracks' @@ -67,6 +68,11 @@ const GoogledriveIdRoute = GoogledriveIdRouteImport.update({ path: '/googledrive/$id', getParentRoute: () => rootRouteImport, } as any) +const GenreIdRoute = GenreIdRouteImport.update({ + id: '/genre/$id', + path: '/genre/$id', + getParentRoute: () => rootRouteImport, +} as any) const DropboxIdRoute = DropboxIdRouteImport.update({ id: '/dropbox/$id', path: '/dropbox/$id', @@ -149,6 +155,7 @@ export interface FileRoutesByFullPath { '/loading': typeof LoadingRoute '/scrobble': typeof ScrobbleRoute '/dropbox/$id': typeof DropboxIdRoute + '/genre/$id': typeof GenreIdRoute '/googledrive/$id': typeof GoogledriveIdRoute '/dropbox': typeof DropboxIndexRoute '/googledrive': typeof GoogledriveIndexRoute @@ -173,6 +180,7 @@ export interface FileRoutesByTo { '/loading': typeof LoadingRoute '/scrobble': typeof ScrobbleRoute '/dropbox/$id': typeof DropboxIdRoute + '/genre/$id': typeof GenreIdRoute '/googledrive/$id': typeof GoogledriveIdRoute '/dropbox': typeof DropboxIndexRoute '/googledrive': typeof GoogledriveIndexRoute @@ -198,6 +206,7 @@ export interface FileRoutesById { '/loading': typeof LoadingRoute '/scrobble': typeof ScrobbleRoute '/dropbox/$id': typeof DropboxIdRoute + '/genre/$id': typeof GenreIdRoute '/googledrive/$id': typeof GoogledriveIdRoute '/dropbox/': typeof DropboxIndexRoute '/googledrive/': typeof GoogledriveIndexRoute @@ -224,6 +233,7 @@ export interface FileRouteTypes { | '/loading' | '/scrobble' | '/dropbox/$id' + | '/genre/$id' | '/googledrive/$id' | '/dropbox' | '/googledrive' @@ -248,6 +258,7 @@ export interface FileRouteTypes { | '/loading' | '/scrobble' | '/dropbox/$id' + | '/genre/$id' | '/googledrive/$id' | '/dropbox' | '/googledrive' @@ -272,6 +283,7 @@ export interface FileRouteTypes { | '/loading' | '/scrobble' | '/dropbox/$id' + | '/genre/$id' | '/googledrive/$id' | '/dropbox/' | '/googledrive/' @@ -297,6 +309,7 @@ export interface RootRouteChildren { LoadingRoute: typeof LoadingRoute ScrobbleRoute: typeof ScrobbleRoute DropboxIdRoute: typeof DropboxIdRoute + GenreIdRoute: typeof GenreIdRoute GoogledriveIdRoute: typeof GoogledriveIdRoute DropboxIndexRoute: typeof DropboxIndexRoute GoogledriveIndexRoute: typeof GoogledriveIndexRoute @@ -367,6 +380,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GoogledriveIdRouteImport parentRoute: typeof rootRouteImport } + '/genre/$id': { + id: '/genre/$id' + path: '/genre/$id' + fullPath: '/genre/$id' + preLoaderRoute: typeof GenreIdRouteImport + parentRoute: typeof rootRouteImport + } '/dropbox/$id': { id: '/dropbox/$id' path: '/dropbox/$id' @@ -481,6 +501,7 @@ const rootRouteChildren: RootRouteChildren = { LoadingRoute: LoadingRoute, ScrobbleRoute: ScrobbleRoute, DropboxIdRoute: DropboxIdRoute, + GenreIdRoute: GenreIdRoute, GoogledriveIdRoute: GoogledriveIdRoute, DropboxIndexRoute: DropboxIndexRoute, GoogledriveIndexRoute: GoogledriveIndexRoute, diff --git a/apps/web/src/routes/genre/$id.tsx b/apps/web/src/routes/genre/$id.tsx new file mode 100644 index 00000000..7bb08048 --- /dev/null +++ b/apps/web/src/routes/genre/$id.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import GenrePage from "../../pages/genre"; + +export const Route = createFileRoute("/genre/$id")({ + component: GenrePage, +}); diff --git a/apps/web/src/types/album.ts b/apps/web/src/types/album.ts new file mode 100644 index 00000000..5255b58f --- /dev/null +++ b/apps/web/src/types/album.ts @@ -0,0 +1,13 @@ +export type Album = { + id: string; + uri: string; + title: string; + artist: string; + artistUri: string; + year: number; + albumArt: string; + releaseDate: string; + sha256: string; + playCount: number; + uniqueListeners: number; +}; diff --git a/apps/web/src/types/track.ts b/apps/web/src/types/track.ts new file mode 100644 index 00000000..1fa0d7d0 --- /dev/null +++ b/apps/web/src/types/track.ts @@ -0,0 +1,19 @@ +export type Track = { + id: string; + uri: string; + unique_listeners: number; + play_count: number; + title: string; + artist: string; + artist_uri: string; + album: string; + album_uri: string; + album_art: string; + album_artist: string; + copyright_message: string; + disc_number: number; + duration: number; + sha256: string; + track_number: number; + created_at: string; +};