A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz

Merge branch 'main' into feat/feed-generator

+1082 -1073
+6 -6
apps/web/src/api/library.ts
··· 29 29 30 30 export const getArtistTracks = async ( 31 31 uri: string, 32 - limit = 10 32 + limit = 10, 33 33 ): Promise< 34 34 { 35 35 id: string; ··· 45 45 > => { 46 46 const response = await client.get( 47 47 "/xrpc/app.rocksky.artist.getArtistTracks", 48 - { params: { uri, limit } } 48 + { params: { uri, limit } }, 49 49 ); 50 50 return response.data.tracks; 51 51 }; 52 52 53 53 export const getArtistAlbums = async ( 54 54 uri: string, 55 - limit = 10 55 + limit = 10, 56 56 ): Promise< 57 57 { 58 58 id: string; ··· 65 65 > => { 66 66 const response = await client.get( 67 67 "/xrpc/app.rocksky.artist.getArtistAlbums", 68 - { params: { uri, limit } } 68 + { params: { uri, limit } }, 69 69 ); 70 70 return response.data.albums; 71 71 }; ··· 96 96 "/xrpc/app.rocksky.actor.getActorLovedSongs", 97 97 { 98 98 params: { did, limit, offset }, 99 - } 99 + }, 100 100 ); 101 101 return response.data.tracks; 102 102 }; ··· 118 118 export const getArtistListeners = async (uri: string, limit: number) => { 119 119 const response = await client.get( 120 120 "/xrpc/app.rocksky.artist.getArtistListeners", 121 - { params: { uri, limit } } 121 + { params: { uri, limit } }, 122 122 ); 123 123 return response.data; 124 124 };
+137 -137
apps/web/src/components/ContextMenu/ContextMenu.tsx
··· 4 4 import { StatefulPopover } from "baseui/popover"; 5 5 6 6 export type ContextMenuProps = { 7 - file: { 8 - id: string; 9 - name: string; 10 - type: string; 11 - }; 7 + file: { 8 + id: string; 9 + name: string; 10 + type: string; 11 + }; 12 12 }; 13 13 14 14 function ContextMenu(props: ContextMenuProps) { 15 - const { file } = props; 16 - return ( 17 - <StatefulPopover 18 - autoFocus={false} 19 - content={({ close }) => ( 20 - <div className="border-[var(--color-border)] w-[240px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 21 - <div 22 - className="h-[54px] flex flex-row items-center pl-[5px] pr-[5px]" 23 - style={{ 24 - borderBottom: "1px solid var(--color-border)", 25 - }} 26 - > 27 - <div className="h-[43px] flex items-center justify-center ml-[10px] mr-[10px] text-[var(--color-text)]"> 28 - {file.type == "folder" && ( 29 - <div> 30 - <Folder2 size={20} /> 31 - </div> 32 - )} 33 - {file.type !== "folder" && ( 34 - <div> 35 - <MusicNoteBeamed size={20} /> 36 - </div> 37 - )} 38 - </div> 39 - <div className="text-[var(--color-text)] whitespace-nowrap text-ellipsis overflow-hidden"> 40 - {file.name} 41 - </div> 42 - </div> 43 - <NestedMenus> 44 - <StatefulMenu 45 - items={[ 46 - { 47 - id: "0", 48 - label: "Play", 49 - }, 50 - { 51 - id: "1", 52 - label: "Play Next", 53 - }, 54 - { 55 - id: "2", 56 - label: "Add to Playlist", 57 - }, 58 - { 59 - id: "3", 60 - label: "Play Last", 61 - }, 62 - { 63 - id: "4", 64 - label: "Add Shuffled", 65 - }, 66 - ]} 67 - onItemSelect={({ item }) => { 68 - console.log(`Selected item: ${item.label}`); 69 - close(); 70 - }} 71 - overrides={{ 72 - List: { 73 - style: { 74 - boxShadow: "none", 75 - outline: "none !important", 76 - backgroundColor: "var(--color-background)", 77 - }, 78 - }, 79 - ListItem: { 80 - style: { 81 - backgroundColor: "var(--color-background)", 82 - color: "var(--color-text)", 83 - ":hover": { 84 - backgroundColor: "var(--color-menu-hover)", 85 - }, 86 - }, 87 - }, 88 - Option: { 89 - props: { 90 - getChildMenu: (item: { label: string }) => { 91 - if (item.label === "Add to Playlist") { 92 - return ( 93 - <div className="border-[var(--color-border)] w-[205px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 94 - <StatefulMenu 95 - items={{ 96 - __ungrouped: [ 97 - { 98 - label: "Create new playlist", 99 - }, 100 - ], 101 - }} 102 - overrides={{ 103 - List: { 104 - style: { 105 - boxShadow: "none", 106 - outline: "none !important", 107 - backgroundColor: "var(--color-background)", 108 - }, 109 - }, 110 - ListItem: { 111 - style: { 112 - backgroundColor: "var(--color-background)", 113 - color: "var(--color-text)", 114 - ":hover": { 115 - backgroundColor: 116 - "var(--color-menu-hover)", 117 - }, 118 - }, 119 - }, 120 - }} 121 - /> 122 - </div> 123 - ); 124 - } 125 - return null; 126 - }, 127 - }, 128 - }, 129 - }} 130 - /> 131 - </NestedMenus> 132 - </div> 133 - )} 134 - overrides={{ 135 - Inner: { 136 - style: { 137 - backgroundColor: "var(--color-background)", 138 - }, 139 - }, 140 - }} 141 - > 142 - <button className="text-[var(--color-text-muted)] cursor-pointer bg-transparent border-none hover:bg-transparent"> 143 - <EllipsisHorizontal size={24} /> 144 - </button> 145 - </StatefulPopover> 146 - ); 15 + const { file } = props; 16 + return ( 17 + <StatefulPopover 18 + autoFocus={false} 19 + content={({ close }) => ( 20 + <div className="border-[var(--color-border)] w-[240px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 21 + <div 22 + className="h-[54px] flex flex-row items-center pl-[5px] pr-[5px]" 23 + style={{ 24 + borderBottom: "1px solid var(--color-border)", 25 + }} 26 + > 27 + <div className="h-[43px] flex items-center justify-center ml-[10px] mr-[10px] text-[var(--color-text)]"> 28 + {file.type == "folder" && ( 29 + <div> 30 + <Folder2 size={20} /> 31 + </div> 32 + )} 33 + {file.type !== "folder" && ( 34 + <div> 35 + <MusicNoteBeamed size={20} /> 36 + </div> 37 + )} 38 + </div> 39 + <div className="text-[var(--color-text)] whitespace-nowrap text-ellipsis overflow-hidden"> 40 + {file.name} 41 + </div> 42 + </div> 43 + <NestedMenus> 44 + <StatefulMenu 45 + items={[ 46 + { 47 + id: "0", 48 + label: "Play", 49 + }, 50 + { 51 + id: "1", 52 + label: "Play Next", 53 + }, 54 + { 55 + id: "2", 56 + label: "Add to Playlist", 57 + }, 58 + { 59 + id: "3", 60 + label: "Play Last", 61 + }, 62 + { 63 + id: "4", 64 + label: "Add Shuffled", 65 + }, 66 + ]} 67 + onItemSelect={({ item }) => { 68 + console.log(`Selected item: ${item.label}`); 69 + close(); 70 + }} 71 + overrides={{ 72 + List: { 73 + style: { 74 + boxShadow: "none", 75 + outline: "none !important", 76 + backgroundColor: "var(--color-background)", 77 + }, 78 + }, 79 + ListItem: { 80 + style: { 81 + backgroundColor: "var(--color-background)", 82 + color: "var(--color-text)", 83 + ":hover": { 84 + backgroundColor: "var(--color-menu-hover)", 85 + }, 86 + }, 87 + }, 88 + Option: { 89 + props: { 90 + getChildMenu: (item: { label: string }) => { 91 + if (item.label === "Add to Playlist") { 92 + return ( 93 + <div className="border-[var(--color-border)] w-[205px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 94 + <StatefulMenu 95 + items={{ 96 + __ungrouped: [ 97 + { 98 + label: "Create new playlist", 99 + }, 100 + ], 101 + }} 102 + overrides={{ 103 + List: { 104 + style: { 105 + boxShadow: "none", 106 + outline: "none !important", 107 + backgroundColor: "var(--color-background)", 108 + }, 109 + }, 110 + ListItem: { 111 + style: { 112 + backgroundColor: "var(--color-background)", 113 + color: "var(--color-text)", 114 + ":hover": { 115 + backgroundColor: 116 + "var(--color-menu-hover)", 117 + }, 118 + }, 119 + }, 120 + }} 121 + /> 122 + </div> 123 + ); 124 + } 125 + return null; 126 + }, 127 + }, 128 + }, 129 + }} 130 + /> 131 + </NestedMenus> 132 + </div> 133 + )} 134 + overrides={{ 135 + Inner: { 136 + style: { 137 + backgroundColor: "var(--color-background)", 138 + }, 139 + }, 140 + }} 141 + > 142 + <button className="text-[var(--color-text-muted)] cursor-pointer bg-transparent border-none hover:bg-transparent"> 143 + <EllipsisHorizontal size={24} /> 144 + </button> 145 + </StatefulPopover> 146 + ); 147 147 } 148 148 149 149 export default ContextMenu;
+94 -94
apps/web/src/components/Handle/Handle.tsx
··· 8 8 import { profilesAtom } from "../../atoms/profiles"; 9 9 import { statsAtom } from "../../atoms/stats"; 10 10 import { 11 - useProfileByDidQuery, 12 - useProfileStatsByDidQuery, 11 + useProfileByDidQuery, 12 + useProfileStatsByDidQuery, 13 13 } from "../../hooks/useProfile"; 14 14 import Stats from "../Stats"; 15 15 import NowPlaying from "./NowPlaying"; 16 16 17 17 export type HandleProps = { 18 - link: string; 19 - did: string; 18 + link: string; 19 + did: string; 20 20 }; 21 21 22 22 function Handle(props: HandleProps) { 23 - const { link, did } = props; 24 - const [profiles, setProfiles] = useAtom(profilesAtom); 25 - const profile = useProfileByDidQuery(did); 26 - const profileStats = useProfileStatsByDidQuery(did); 27 - const [stats, setStats] = useAtom(statsAtom); 23 + const { link, did } = props; 24 + const [profiles, setProfiles] = useAtom(profilesAtom); 25 + const profile = useProfileByDidQuery(did); 26 + const profileStats = useProfileStatsByDidQuery(did); 27 + const [stats, setStats] = useAtom(statsAtom); 28 28 29 - useEffect(() => { 30 - if (profile.isLoading || profile.isError) { 31 - return; 32 - } 29 + useEffect(() => { 30 + if (profile.isLoading || profile.isError) { 31 + return; 32 + } 33 33 34 - if (!profile.data || !did) { 35 - return; 36 - } 34 + if (!profile.data || !did) { 35 + return; 36 + } 37 37 38 - setProfiles((profiles) => ({ 39 - ...profiles, 40 - [did]: { 41 - avatar: profile.data.avatar, 42 - displayName: profile.data.displayName, 43 - handle: profile.data.handle, 44 - spotifyConnected: profile.data.spotifyConnected, 45 - createdAt: profile.data.createdAt, 46 - did, 47 - }, 48 - })); 38 + setProfiles((profiles) => ({ 39 + ...profiles, 40 + [did]: { 41 + avatar: profile.data.avatar, 42 + displayName: profile.data.displayName, 43 + handle: profile.data.handle, 44 + spotifyConnected: profile.data.spotifyConnected, 45 + createdAt: profile.data.createdAt, 46 + did, 47 + }, 48 + })); 49 49 50 - // eslint-disable-next-line react-hooks/exhaustive-deps 51 - }, [profile.data, profile.isLoading, profile.isError, did]); 50 + // eslint-disable-next-line react-hooks/exhaustive-deps 51 + }, [profile.data, profile.isLoading, profile.isError, did]); 52 52 53 - useEffect(() => { 54 - if (profileStats.isLoading || profileStats.isError) { 55 - return; 56 - } 53 + useEffect(() => { 54 + if (profileStats.isLoading || profileStats.isError) { 55 + return; 56 + } 57 57 58 - if (!profileStats.data || !did) { 59 - return; 60 - } 58 + if (!profileStats.data || !did) { 59 + return; 60 + } 61 61 62 - setStats((prev) => ({ 63 - ...prev, 64 - [did]: { 65 - scrobbles: profileStats.data.scrobbles, 66 - artists: profileStats.data.artists, 67 - lovedTracks: profileStats.data.lovedTracks, 68 - albums: profileStats.data.albums, 69 - tracks: profileStats.data.tracks, 70 - }, 71 - })); 72 - // eslint-disable-next-line react-hooks/exhaustive-deps 73 - }, [profileStats.data, profileStats.isLoading, profileStats.isError, did]); 62 + setStats((prev) => ({ 63 + ...prev, 64 + [did]: { 65 + scrobbles: profileStats.data.scrobbles, 66 + artists: profileStats.data.artists, 67 + lovedTracks: profileStats.data.lovedTracks, 68 + albums: profileStats.data.albums, 69 + tracks: profileStats.data.tracks, 70 + }, 71 + })); 72 + // eslint-disable-next-line react-hooks/exhaustive-deps 73 + }, [profileStats.data, profileStats.isLoading, profileStats.isError, did]); 74 74 75 - return ( 76 - <StatefulPopover 77 - content={() => ( 78 - <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 79 - <div className="flex flex-row items-center"> 80 - <Link to={link} className="no-underline"> 81 - <Avatar 82 - src={profiles[did]?.avatar} 83 - name={profiles[did]?.displayName} 84 - size={"60px"} 85 - /> 86 - </Link> 87 - <div className="ml-[16px]"> 88 - <Link to={link} className="no-underline"> 89 - <LabelMedium 90 - marginTop={"10px"} 91 - className="!text-[var(--color-text)]" 92 - > 93 - {profiles[did]?.displayName} 94 - </LabelMedium> 95 - </Link> 96 - <a 97 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 98 - className="no-underline text-[var(--color-primary)]" 99 - > 100 - <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 101 - @{did} 102 - </LabelSmall> 103 - </a> 104 - </div> 105 - </div> 75 + return ( 76 + <StatefulPopover 77 + content={() => ( 78 + <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 79 + <div className="flex flex-row items-center"> 80 + <Link to={link} className="no-underline"> 81 + <Avatar 82 + src={profiles[did]?.avatar} 83 + name={profiles[did]?.displayName} 84 + size={"60px"} 85 + /> 86 + </Link> 87 + <div className="ml-[16px]"> 88 + <Link to={link} className="no-underline"> 89 + <LabelMedium 90 + marginTop={"10px"} 91 + className="!text-[var(--color-text)]" 92 + > 93 + {profiles[did]?.displayName} 94 + </LabelMedium> 95 + </Link> 96 + <a 97 + href={`https://bsky.app/profile/${profiles[did]?.handle}`} 98 + className="no-underline text-[var(--color-primary)]" 99 + > 100 + <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 101 + @{did} 102 + </LabelSmall> 103 + </a> 104 + </div> 105 + </div> 106 106 107 - {stats[did] && <Stats stats={stats[did]} mb={1} />} 107 + {stats[did] && <Stats stats={stats[did]} mb={1} />} 108 108 109 - <NowPlaying did={did} /> 110 - </Block> 111 - )} 112 - triggerType={TRIGGER_TYPE.hover} 113 - autoFocus={false} 114 - focusLock={false} 115 - > 116 - <Link to={link} className="no-underline"> 117 - <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]"> 118 - @{did} 119 - </LabelMedium> 120 - </Link> 121 - </StatefulPopover> 122 - ); 109 + <NowPlaying did={did} /> 110 + </Block> 111 + )} 112 + triggerType={TRIGGER_TYPE.hover} 113 + autoFocus={false} 114 + focusLock={false} 115 + > 116 + <Link to={link} className="no-underline"> 117 + <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]"> 118 + @{did} 119 + </LabelMedium> 120 + </Link> 121 + </StatefulPopover> 122 + ); 123 123 } 124 124 125 125 export default Handle;
+18 -18
apps/web/src/components/SongCover/SongCover.tsx
··· 7 7 width: 240px; 8 8 margin-bottom: 10px; 9 9 ${(props) => 10 - props.size && 11 - css` 10 + props.size && 11 + css` 12 12 height: ${props.size}px; 13 13 width: ${props.size}px; 14 14 `} ··· 49 49 `; 50 50 51 51 export type SongCoverProps = { 52 - cover: string; 53 - title?: string; 54 - artist?: string; 55 - size?: number; 52 + cover: string; 53 + title?: string; 54 + artist?: string; 55 + size?: number; 56 56 }; 57 57 58 58 function SongCover(props: SongCoverProps) { 59 - const { title, artist, cover, size } = props; 60 - return ( 61 - <CoverWrapper> 62 - <Cover src={cover} size={size} /> 63 - <div className="mb-[13px]"> 64 - <SongTitle className="!text-[var(--color-text-primary)]"> 65 - {title} 66 - </SongTitle> 67 - <Artist>{artist}</Artist> 68 - </div> 69 - </CoverWrapper> 70 - ); 59 + const { title, artist, cover, size } = props; 60 + return ( 61 + <CoverWrapper> 62 + <Cover src={cover} size={size} /> 63 + <div className="mb-[13px]"> 64 + <SongTitle className="!text-[var(--color-text-primary)]"> 65 + {title} 66 + </SongTitle> 67 + <Artist>{artist}</Artist> 68 + </div> 69 + </CoverWrapper> 70 + ); 71 71 } 72 72 73 73 export default SongCover;
+79 -79
apps/web/src/hooks/useLibrary.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 2 import { 3 - getAlbum, 4 - getAlbums, 5 - getArtist, 6 - getArtistAlbums, 7 - getArtistListeners, 8 - getArtists, 9 - getArtistTracks, 10 - getLovedTracks, 11 - getSongByUri, 12 - getTracks, 3 + getAlbum, 4 + getAlbums, 5 + getArtist, 6 + getArtistAlbums, 7 + getArtistListeners, 8 + getArtists, 9 + getArtistTracks, 10 + getLovedTracks, 11 + getSongByUri, 12 + getTracks, 13 13 } from "../api/library"; 14 14 15 15 export const useSongByUriQuery = (uri: string) => 16 - useQuery({ 17 - queryKey: ["songByUri", uri], 18 - queryFn: () => getSongByUri(uri), 19 - enabled: !!uri, 20 - }); 16 + useQuery({ 17 + queryKey: ["songByUri", uri], 18 + queryFn: () => getSongByUri(uri), 19 + enabled: !!uri, 20 + }); 21 21 22 22 export const useArtistTracksQuery = (uri: string, limit = 10) => 23 - useQuery({ 24 - queryKey: ["artistTracks", uri, limit], 25 - queryFn: () => getArtistTracks(uri, limit), 26 - enabled: !!uri, 27 - }); 23 + useQuery({ 24 + queryKey: ["artistTracks", uri, limit], 25 + queryFn: () => getArtistTracks(uri, limit), 26 + enabled: !!uri, 27 + }); 28 28 29 29 export const useArtistAlbumsQuery = (uri: string, limit = 10) => 30 - useQuery({ 31 - queryKey: ["artistAlbums", uri, limit], 32 - queryFn: () => getArtistAlbums(uri, limit), 33 - enabled: !!uri, 34 - }); 30 + useQuery({ 31 + queryKey: ["artistAlbums", uri, limit], 32 + queryFn: () => getArtistAlbums(uri, limit), 33 + enabled: !!uri, 34 + }); 35 35 36 36 export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 37 - useQuery({ 38 - queryKey: ["artists", did, offset, limit], 39 - queryFn: () => getArtists(did, offset, limit), 40 - enabled: !!did, 41 - select: (data) => 42 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 - data?.artists.map((x: any) => ({ 44 - ...x, 45 - scrobbles: x.playCount, 46 - })), 47 - }); 37 + useQuery({ 38 + queryKey: ["artists", did, offset, limit], 39 + queryFn: () => getArtists(did, offset, limit), 40 + enabled: !!did, 41 + select: (data) => 42 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 + data?.artists.map((x: any) => ({ 44 + ...x, 45 + scrobbles: x.playCount, 46 + })), 47 + }); 48 48 49 49 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 50 - useQuery({ 51 - queryKey: ["albums", did, offset, limit], 52 - queryFn: () => getAlbums(did, offset, limit), 53 - enabled: !!did, 54 - select: (data) => 55 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 - data?.albums.map((x: any) => ({ 57 - ...x, 58 - scrobbles: x.playCount, 59 - })), 60 - }); 50 + useQuery({ 51 + queryKey: ["albums", did, offset, limit], 52 + queryFn: () => getAlbums(did, offset, limit), 53 + enabled: !!did, 54 + select: (data) => 55 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 + data?.albums.map((x: any) => ({ 57 + ...x, 58 + scrobbles: x.playCount, 59 + })), 60 + }); 61 61 62 62 export const useTracksQuery = (did: string, offset = 0, limit = 20) => 63 - useQuery({ 64 - queryKey: ["tracks", did, offset, limit], 65 - queryFn: () => getTracks(did, offset, limit), 66 - enabled: !!did, 67 - select: (data) => 68 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 - data?.tracks.map((x: any) => ({ 70 - ...x, 71 - scrobbles: x.playCount, 72 - })), 73 - }); 63 + useQuery({ 64 + queryKey: ["tracks", did, offset, limit], 65 + queryFn: () => getTracks(did, offset, limit), 66 + enabled: !!did, 67 + select: (data) => 68 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 + data?.tracks.map((x: any) => ({ 70 + ...x, 71 + scrobbles: x.playCount, 72 + })), 73 + }); 74 74 75 75 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => 76 - useQuery({ 77 - queryKey: ["lovedTracks", did, offset, limit], 78 - queryFn: () => getLovedTracks(did, offset, limit), 79 - enabled: !!did, 80 - }); 76 + useQuery({ 77 + queryKey: ["lovedTracks", did, offset, limit], 78 + queryFn: () => getLovedTracks(did, offset, limit), 79 + enabled: !!did, 80 + }); 81 81 82 82 export const useAlbumQuery = (did: string, rkey: string) => 83 - useQuery({ 84 - queryKey: ["album", did, rkey], 85 - queryFn: () => getAlbum(did, rkey), 86 - enabled: !!did && !!rkey, 87 - }); 83 + useQuery({ 84 + queryKey: ["album", did, rkey], 85 + queryFn: () => getAlbum(did, rkey), 86 + enabled: !!did && !!rkey, 87 + }); 88 88 89 89 export const useArtistQuery = (did: string, rkey: string) => 90 - useQuery({ 91 - queryKey: ["artist", did, rkey], 92 - queryFn: () => getArtist(did, rkey), 93 - enabled: !!did && !!rkey, 94 - }); 90 + useQuery({ 91 + queryKey: ["artist", did, rkey], 92 + queryFn: () => getArtist(did, rkey), 93 + enabled: !!did && !!rkey, 94 + }); 95 95 96 96 export const useArtistListenersQuery = (uri: string, limit = 10) => 97 - useQuery({ 98 - queryKey: ["artistListeners", uri, limit], 99 - queryFn: () => getArtistListeners(uri, limit), 100 - enabled: !!uri, 101 - select: (data) => data.listeners, 102 - }); 97 + useQuery({ 98 + queryKey: ["artistListeners", uri, limit], 99 + queryFn: () => getArtistListeners(uri, limit), 100 + enabled: !!uri, 101 + select: (data) => data.listeners, 102 + });
+210 -210
apps/web/src/layouts/Main.tsx
··· 51 51 `; 52 52 53 53 export type MainProps = { 54 - children: React.ReactNode; 55 - withRightPane?: boolean; 54 + children: React.ReactNode; 55 + withRightPane?: boolean; 56 56 }; 57 57 58 58 function Main(props: MainProps) { 59 - const { children } = props; 60 - const withRightPane = props.withRightPane ?? true; 61 - const [handle, setHandle] = useState(""); 62 - const jwt = localStorage.getItem("token"); 63 - const profile = useAtomValue(profileAtom); 64 - const [token, setToken] = useState<string | null>(null); 65 - const { did, cli } = useSearch({ strict: false }); 59 + const { children } = props; 60 + const withRightPane = props.withRightPane ?? true; 61 + const [handle, setHandle] = useState(""); 62 + const jwt = localStorage.getItem("token"); 63 + const profile = useAtomValue(profileAtom); 64 + const [token, setToken] = useState<string | null>(null); 65 + const { did, cli } = useSearch({ strict: false }); 66 66 67 - useEffect(() => { 68 - if (did && did !== "null") { 69 - localStorage.setItem("did", did); 67 + useEffect(() => { 68 + if (did && did !== "null") { 69 + localStorage.setItem("did", did); 70 70 71 - const fetchToken = async () => { 72 - try { 73 - const response = await fetch(`${API_URL}/token`, { 74 - method: "GET", 75 - headers: { 76 - "session-did": did, 77 - }, 78 - }); 79 - const data = await response.json(); 80 - localStorage.setItem("token", data.token); 81 - setToken(data.token); 71 + const fetchToken = async () => { 72 + try { 73 + const response = await fetch(`${API_URL}/token`, { 74 + method: "GET", 75 + headers: { 76 + "session-did": did, 77 + }, 78 + }); 79 + const data = await response.json(); 80 + localStorage.setItem("token", data.token); 81 + setToken(data.token); 82 82 83 - if (cli) { 84 - await fetch("http://localhost:6996/token", { 85 - method: "POST", 86 - headers: { 87 - "Content-Type": "application/json", 88 - }, 89 - body: JSON.stringify({ token: data.token }), 90 - }); 91 - } 83 + if (cli) { 84 + await fetch("http://localhost:6996/token", { 85 + method: "POST", 86 + headers: { 87 + "Content-Type": "application/json", 88 + }, 89 + body: JSON.stringify({ token: data.token }), 90 + }); 91 + } 92 92 93 - if (!jwt && data.token) { 94 - window.location.href = "/"; 95 - } 96 - } catch (e) { 97 - console.error(e); 98 - } 99 - }; 100 - fetchToken(); 101 - } 102 - // eslint-disable-next-line react-hooks/exhaustive-deps 103 - }, []); 93 + if (!jwt && data.token) { 94 + window.location.href = "/"; 95 + } 96 + } catch (e) { 97 + console.error(e); 98 + } 99 + }; 100 + fetchToken(); 101 + } 102 + // eslint-disable-next-line react-hooks/exhaustive-deps 103 + }, []); 104 104 105 - useProfile(token || localStorage.getItem("token")); 105 + useProfile(token || localStorage.getItem("token")); 106 106 107 - const onLogin = async () => { 108 - if (!handle.trim()) { 109 - return; 110 - } 107 + const onLogin = async () => { 108 + if (!handle.trim()) { 109 + return; 110 + } 111 111 112 - if (API_URL.includes("localhost")) { 113 - window.location.href = `${API_URL}/login?handle=${handle}`; 114 - return; 115 - } 112 + if (API_URL.includes("localhost")) { 113 + window.location.href = `${API_URL}/login?handle=${handle}`; 114 + return; 115 + } 116 116 117 - window.location.href = `https://rocksky.pages.dev/loading?handle=${handle}`; 118 - }; 117 + window.location.href = `https://rocksky.pages.dev/loading?handle=${handle}`; 118 + }; 119 119 120 - return ( 121 - <Container className="bg-[var(--color-background)] text-[var(--color-text)]"> 122 - <ToasterContainer 123 - placement={PLACEMENT.top} 124 - overrides={{ 125 - ToastBody: { 126 - style: { 127 - zIndex: 2, 128 - boxShadow: "none", 129 - }, 130 - }, 131 - }} 132 - /> 133 - <Flex style={{ width: withRightPane ? "770px" : "1090px" }}> 134 - <Navbar /> 135 - <div 136 - style={{ 137 - position: "relative", 138 - }} 139 - > 140 - {children} 141 - </div> 142 - </Flex> 143 - {withRightPane && ( 144 - <RightPane className="relative w-[300px]"> 145 - <div className="fixed top-[100px] w-[300px] bg-white p-[20px]"> 146 - <div className="mb-[30px]"> 147 - <Search /> 148 - </div> 149 - {jwt && profile && !profile.spotifyConnected && <SpotifyLogin />} 150 - {jwt && profile && <CloudDrive />} 151 - {!jwt && ( 152 - <div className="mt-[40px]"> 153 - <div className="mb-[20px]"> 154 - <div className="mb-[15px]"> 155 - <LabelMedium className="!text-[var(--color-text)]"> 156 - Bluesky handle 157 - </LabelMedium> 158 - </div> 159 - <Input 160 - name="handle" 161 - startEnhancer={ 162 - <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 163 - @ 164 - </div> 165 - } 166 - placeholder="<username>.bsky.social" 167 - value={handle} 168 - onChange={(e) => setHandle(e.target.value)} 169 - overrides={{ 170 - Root: { 171 - style: { 172 - backgroundColor: "var(--color-input-background)", 173 - borderColor: "var(--color-input-background)", 174 - }, 175 - }, 176 - StartEnhancer: { 177 - style: { 178 - backgroundColor: "var(--color-input-background)", 179 - }, 180 - }, 181 - InputContainer: { 182 - style: { 183 - backgroundColor: "var(--color-input-background)", 184 - }, 185 - }, 186 - Input: { 187 - style: { 188 - color: "var(--color-text)", 189 - caretColor: "var(--color-text)", 190 - }, 191 - }, 192 - }} 193 - /> 194 - </div> 195 - <Button 196 - onClick={onLogin} 197 - overrides={{ 198 - BaseButton: { 199 - style: { 200 - width: "100%", 201 - backgroundColor: "var(--color-primary)", 202 - ":hover": { 203 - backgroundColor: "var(--color-primary)", 204 - }, 205 - ":focus": { 206 - backgroundColor: "var(--color-primary)", 207 - }, 208 - }, 209 - }, 210 - }} 211 - > 212 - Sign In 213 - </Button> 214 - <LabelMedium className="text-center mt-[20px] !text-[var(--color-text-muted)]"> 215 - Don't have an account? 216 - </LabelMedium> 217 - <div className="text-center text-[var(--color-text-muted)] "> 218 - <a 219 - href="https://bsky.app" 220 - className="no-underline cursor-pointer !text-[var(--color-primary)]" 221 - target="_blank" 222 - > 223 - Sign up for Bluesky 224 - </a>{" "} 225 - to create one now! 226 - </div> 227 - </div> 228 - )} 120 + return ( 121 + <Container className="bg-[var(--color-background)] text-[var(--color-text)]"> 122 + <ToasterContainer 123 + placement={PLACEMENT.top} 124 + overrides={{ 125 + ToastBody: { 126 + style: { 127 + zIndex: 2, 128 + boxShadow: "none", 129 + }, 130 + }, 131 + }} 132 + /> 133 + <Flex style={{ width: withRightPane ? "770px" : "1090px" }}> 134 + <Navbar /> 135 + <div 136 + style={{ 137 + position: "relative", 138 + }} 139 + > 140 + {children} 141 + </div> 142 + </Flex> 143 + {withRightPane && ( 144 + <RightPane className="relative w-[300px]"> 145 + <div className="fixed top-[100px] w-[300px] bg-white p-[20px]"> 146 + <div className="mb-[30px]"> 147 + <Search /> 148 + </div> 149 + {jwt && profile && !profile.spotifyConnected && <SpotifyLogin />} 150 + {jwt && profile && <CloudDrive />} 151 + {!jwt && ( 152 + <div className="mt-[40px]"> 153 + <div className="mb-[20px]"> 154 + <div className="mb-[15px]"> 155 + <LabelMedium className="!text-[var(--color-text)]"> 156 + Bluesky handle 157 + </LabelMedium> 158 + </div> 159 + <Input 160 + name="handle" 161 + startEnhancer={ 162 + <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 163 + @ 164 + </div> 165 + } 166 + placeholder="<username>.bsky.social" 167 + value={handle} 168 + onChange={(e) => setHandle(e.target.value)} 169 + overrides={{ 170 + Root: { 171 + style: { 172 + backgroundColor: "var(--color-input-background)", 173 + borderColor: "var(--color-input-background)", 174 + }, 175 + }, 176 + StartEnhancer: { 177 + style: { 178 + backgroundColor: "var(--color-input-background)", 179 + }, 180 + }, 181 + InputContainer: { 182 + style: { 183 + backgroundColor: "var(--color-input-background)", 184 + }, 185 + }, 186 + Input: { 187 + style: { 188 + color: "var(--color-text)", 189 + caretColor: "var(--color-text)", 190 + }, 191 + }, 192 + }} 193 + /> 194 + </div> 195 + <Button 196 + onClick={onLogin} 197 + overrides={{ 198 + BaseButton: { 199 + style: { 200 + width: "100%", 201 + backgroundColor: "var(--color-primary)", 202 + ":hover": { 203 + backgroundColor: "var(--color-primary)", 204 + }, 205 + ":focus": { 206 + backgroundColor: "var(--color-primary)", 207 + }, 208 + }, 209 + }, 210 + }} 211 + > 212 + Sign In 213 + </Button> 214 + <LabelMedium className="text-center mt-[20px] !text-[var(--color-text-muted)]"> 215 + Don't have an account? 216 + </LabelMedium> 217 + <div className="text-center text-[var(--color-text-muted)] "> 218 + <a 219 + href="https://bsky.app" 220 + className="no-underline cursor-pointer !text-[var(--color-primary)]" 221 + target="_blank" 222 + > 223 + Sign up for Bluesky 224 + </a>{" "} 225 + to create one now! 226 + </div> 227 + </div> 228 + )} 229 229 230 - <div className="mt-[40px]"> 231 - <ScrobblesAreaChart /> 232 - </div> 233 - <ExternalLinks /> 234 - <div className="inline-flex mt-[40px]"> 235 - <Link 236 - href="https://docs.rocksky.app/introduction-918639m0" 237 - target="_blank" 238 - className="mr-[10px] text-[var(--color-primary)]" 239 - > 240 - About 241 - </Link> 242 - <Link 243 - href="https://docs.rocksky.app/faq-918661m0" 244 - target="_blank" 245 - className="mr-[10px] text-[var(--color-primary)]" 246 - > 247 - FAQ 248 - </Link> 249 - <Link 250 - href="https://doc.rocksky.app/" 251 - target="_blank" 252 - className="mr-[10px] text-[var(--color-primary)]" 253 - > 254 - API Docs 255 - </Link> 256 - <Link 257 - href="https://tangled.org/@rocksky.app/rocksky" 258 - target="_blank" 259 - className="mr-[10px] text-[var(--color-primary)]" 260 - > 261 - Source 262 - </Link> 263 - <Link 264 - href="https://discord.gg/EVcBy2fVa3" 265 - target="_blank" 266 - className="mr-[10px] text-[var(--color-primary)]" 267 - > 268 - Discord 269 - </Link> 270 - </div> 271 - </div> 272 - </RightPane> 273 - )} 274 - <StickyPlayer /> 275 - </Container> 276 - ); 230 + <div className="mt-[40px]"> 231 + <ScrobblesAreaChart /> 232 + </div> 233 + <ExternalLinks /> 234 + <div className="inline-flex mt-[40px]"> 235 + <Link 236 + href="https://docs.rocksky.app/introduction-918639m0" 237 + target="_blank" 238 + className="mr-[10px] text-[var(--color-primary)]" 239 + > 240 + About 241 + </Link> 242 + <Link 243 + href="https://docs.rocksky.app/faq-918661m0" 244 + target="_blank" 245 + className="mr-[10px] text-[var(--color-primary)]" 246 + > 247 + FAQ 248 + </Link> 249 + <Link 250 + href="https://doc.rocksky.app/" 251 + target="_blank" 252 + className="mr-[10px] text-[var(--color-primary)]" 253 + > 254 + API Docs 255 + </Link> 256 + <Link 257 + href="https://tangled.org/@rocksky.app/rocksky" 258 + target="_blank" 259 + className="mr-[10px] text-[var(--color-primary)]" 260 + > 261 + Source 262 + </Link> 263 + <Link 264 + href="https://discord.gg/EVcBy2fVa3" 265 + target="_blank" 266 + className="mr-[10px] text-[var(--color-primary)]" 267 + > 268 + Discord 269 + </Link> 270 + </div> 271 + </div> 272 + </RightPane> 273 + )} 274 + <StickyPlayer /> 275 + </Container> 276 + ); 277 277 } 278 278 279 279 export default Main;
+12 -3
apps/web/src/layouts/Navbar/Navbar.tsx
··· 86 86 setTimeout(() => { 87 87 setTooltipContent("Copy API Key"); 88 88 }, 1500); 89 - } 89 + }; 90 90 91 91 useEffect(() => { 92 92 if (profile?.spotifyConnected && !!localStorage.getItem("spotify")) { ··· 169 169 color="var(--color-text-muted)" 170 170 className="text-center !mr-[5px]" 171 171 > 172 - {numeral(profileStats.data.scrobbles).format("0,0")} 172 + {numeral(profileStats?.data?.scrobbles).format("0,0")} 173 173 </LabelMedium> 174 174 <LabelMedium color="var(--color-text-muted)"> 175 175 scrobbles ··· 363 363 extension settings as a custom API URL: 364 364 </LabelMedium> 365 365 <Code className="mt-[15px]">{webscrobblerWebhook}</Code> 366 - <StatefulTooltip content={tooltipContent}> 366 + <StatefulTooltip 367 + content={tooltipContent} 368 + overrides={{ 369 + Body: { 370 + style: { 371 + zIndex: 2, 372 + }, 373 + }, 374 + }} 375 + > 367 376 <Copy 368 377 onClick={handleCopyClick} 369 378 size={18}
+177 -177
apps/web/src/pages/artist/Artist.tsx
··· 10 10 import ArtistIcon from "../../components/Icons/Artist"; 11 11 import Shout from "../../components/Shout/Shout"; 12 12 import { 13 - useArtistAlbumsQuery, 14 - useArtistListenersQuery, 15 - useArtistQuery, 16 - useArtistTracksQuery, 13 + useArtistAlbumsQuery, 14 + useArtistListenersQuery, 15 + useArtistQuery, 16 + useArtistTracksQuery, 17 17 } from "../../hooks/useLibrary"; 18 18 import Main from "../../layouts/Main"; 19 19 import Albums from "./Albums"; ··· 28 28 `; 29 29 30 30 const Artist = () => { 31 - const { did, rkey } = useParams({ strict: false }); 31 + const { did, rkey } = useParams({ strict: false }); 32 32 33 - const uri = `at://${did}/app.rocksky.artist/${rkey}`; 34 - const artistResult = useArtistQuery(did!, rkey!); 35 - const artistTracksResult = useArtistTracksQuery(uri); 36 - const artistAlbumsResult = useArtistAlbumsQuery(uri); 37 - const artistListenersResult = useArtistListenersQuery(uri); 33 + const uri = `at://${did}/app.rocksky.artist/${rkey}`; 34 + const artistResult = useArtistQuery(did!, rkey!); 35 + const artistTracksResult = useArtistTracksQuery(uri); 36 + const artistAlbumsResult = useArtistAlbumsQuery(uri); 37 + const artistListenersResult = useArtistListenersQuery(uri); 38 38 39 - const artist = useAtomValue(artistAtom); 40 - const setArtist = useSetAtom(artistAtom); 41 - const [topTracks, setTopTracks] = useState< 42 - { 43 - id: string; 44 - title: string; 45 - artist: string; 46 - albumArtist: string; 47 - albumArt: string; 48 - uri: string; 49 - scrobbles: number; 50 - albumUri?: string; 51 - artistUri?: string; 52 - }[] 53 - >([]); 54 - const [topAlbums, setTopAlbums] = useState< 55 - { 56 - id: string; 57 - title: string; 58 - artist: string; 59 - albumArt: string; 60 - artistUri: string; 61 - uri: string; 62 - }[] 63 - >([]); 39 + const artist = useAtomValue(artistAtom); 40 + const setArtist = useSetAtom(artistAtom); 41 + const [topTracks, setTopTracks] = useState< 42 + { 43 + id: string; 44 + title: string; 45 + artist: string; 46 + albumArtist: string; 47 + albumArt: string; 48 + uri: string; 49 + scrobbles: number; 50 + albumUri?: string; 51 + artistUri?: string; 52 + }[] 53 + >([]); 54 + const [topAlbums, setTopAlbums] = useState< 55 + { 56 + id: string; 57 + title: string; 58 + artist: string; 59 + albumArt: string; 60 + artistUri: string; 61 + uri: string; 62 + }[] 63 + >([]); 64 64 65 - useEffect(() => { 66 - if (artistResult.isLoading || artistResult.isError) { 67 - return; 68 - } 65 + useEffect(() => { 66 + if (artistResult.isLoading || artistResult.isError) { 67 + return; 68 + } 69 69 70 - if (!artistResult.data || !did) { 71 - return; 72 - } 70 + if (!artistResult.data || !did) { 71 + return; 72 + } 73 73 74 - setArtist({ 75 - id: artistResult.data.id, 76 - name: artistResult.data.name, 77 - born: artistResult.data.born, 78 - bornIn: artistResult.data.bornIn, 79 - died: artistResult.data.died, 80 - listeners: artistResult.data.uniqueListeners, 81 - scrobbles: artistResult.data.playCount, 82 - picture: artistResult.data.picture, 83 - tags: artistResult.data.tags, 84 - uri: artistResult.data.uri, 85 - spotifyLink: artistResult.data.spotifyLink, 86 - }); 87 - // eslint-disable-next-line react-hooks/exhaustive-deps 88 - }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 74 + setArtist({ 75 + id: artistResult.data.id, 76 + name: artistResult.data.name, 77 + born: artistResult.data.born, 78 + bornIn: artistResult.data.bornIn, 79 + died: artistResult.data.died, 80 + listeners: artistResult.data.uniqueListeners, 81 + scrobbles: artistResult.data.playCount, 82 + picture: artistResult.data.picture, 83 + tags: artistResult.data.tags, 84 + uri: artistResult.data.uri, 85 + spotifyLink: artistResult.data.spotifyLink, 86 + }); 87 + // eslint-disable-next-line react-hooks/exhaustive-deps 88 + }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 89 89 90 - useEffect(() => { 91 - if (artistTracksResult.isLoading || artistTracksResult.isError) { 92 - return; 93 - } 90 + useEffect(() => { 91 + if (artistTracksResult.isLoading || artistTracksResult.isError) { 92 + return; 93 + } 94 94 95 - if (!artistTracksResult.data || !did) { 96 - return; 97 - } 95 + if (!artistTracksResult.data || !did) { 96 + return; 97 + } 98 98 99 - setTopTracks( 100 - artistTracksResult.data.map((track) => ({ 101 - ...track, 102 - scrobbles: track.playCount || 1, 103 - })), 104 - ); 105 - }, [ 106 - artistTracksResult.data, 107 - artistTracksResult.isLoading, 108 - artistTracksResult.isError, 109 - did, 110 - ]); 99 + setTopTracks( 100 + artistTracksResult.data.map((track) => ({ 101 + ...track, 102 + scrobbles: track.playCount || 1, 103 + })), 104 + ); 105 + }, [ 106 + artistTracksResult.data, 107 + artistTracksResult.isLoading, 108 + artistTracksResult.isError, 109 + did, 110 + ]); 111 111 112 - useEffect(() => { 113 - if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 114 - return; 115 - } 112 + useEffect(() => { 113 + if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 114 + return; 115 + } 116 116 117 - if (!artistAlbumsResult.data || !did) { 118 - return; 119 - } 117 + if (!artistAlbumsResult.data || !did) { 118 + return; 119 + } 120 120 121 - setTopAlbums(artistAlbumsResult.data); 122 - }, [ 123 - artistAlbumsResult.data, 124 - artistAlbumsResult.isLoading, 125 - artistAlbumsResult.isError, 126 - did, 127 - ]); 121 + setTopAlbums(artistAlbumsResult.data); 122 + }, [ 123 + artistAlbumsResult.data, 124 + artistAlbumsResult.isLoading, 125 + artistAlbumsResult.isError, 126 + did, 127 + ]); 128 128 129 - const loading = 130 - artistResult.isLoading || 131 - artistTracksResult.isLoading || 132 - artistAlbumsResult.isLoading; 133 - return ( 134 - <Main> 135 - <div className="pb-[100px] pt-[50px]"> 136 - <Group> 137 - <div className="mr-[20px]"> 138 - {artist?.picture && !loading && ( 139 - <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 140 - )} 141 - {!artist?.picture && !loading && ( 142 - <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 143 - <div 144 - style={{ 145 - height: 60, 146 - width: 60, 147 - }} 148 - > 149 - <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 150 - </div> 151 - </div> 152 - )} 153 - </div> 154 - {artist && !loading && ( 155 - <div style={{ flex: 1 }}> 156 - <HeadingMedium 157 - marginTop={"20px"} 158 - marginBottom={0} 159 - className="!text-[var(--color-text)]" 160 - > 161 - {artist?.name} 162 - </HeadingMedium> 163 - <div className="mt-[20px] flex flex-row"> 164 - <div className="mr-[20px]"> 165 - <LabelMedium 166 - margin={0} 167 - className="!text-[var(--color-text-muted)]" 168 - > 169 - Listeners 170 - </LabelMedium> 171 - <HeadingXSmall 172 - margin={0} 173 - className="!text-[var(--color-text)]" 174 - > 175 - {numeral(artist?.listeners).format("0,0")} 176 - </HeadingXSmall> 177 - </div> 178 - <div> 179 - <LabelMedium 180 - margin={0} 181 - className="!text-[var(--color-text-muted)]" 182 - > 183 - Scrobbles 184 - </LabelMedium> 185 - <HeadingXSmall 186 - margin={0} 187 - className="!text-[var(--color-text)]" 188 - > 189 - {numeral(artist?.scrobbles).format("0,0")} 190 - </HeadingXSmall> 191 - </div> 192 - <div className="flex items-center justify-end flex-1 mr-[10px]"> 193 - <a 194 - href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 195 - target="_blank" 196 - className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 197 - > 198 - <ExternalLink 199 - size={24} 200 - className="mr-[10px] text-[var(--color-text)]" 201 - /> 202 - View on PDSls 203 - </a> 204 - </div> 205 - </div> 206 - </div> 207 - )} 208 - </Group> 129 + const loading = 130 + artistResult.isLoading || 131 + artistTracksResult.isLoading || 132 + artistAlbumsResult.isLoading; 133 + return ( 134 + <Main> 135 + <div className="pb-[100px] pt-[50px]"> 136 + <Group> 137 + <div className="mr-[20px]"> 138 + {artist?.picture && !loading && ( 139 + <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 140 + )} 141 + {!artist?.picture && !loading && ( 142 + <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 143 + <div 144 + style={{ 145 + height: 60, 146 + width: 60, 147 + }} 148 + > 149 + <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 150 + </div> 151 + </div> 152 + )} 153 + </div> 154 + {artist && !loading && ( 155 + <div style={{ flex: 1 }}> 156 + <HeadingMedium 157 + marginTop={"20px"} 158 + marginBottom={0} 159 + className="!text-[var(--color-text)]" 160 + > 161 + {artist?.name} 162 + </HeadingMedium> 163 + <div className="mt-[20px] flex flex-row"> 164 + <div className="mr-[20px]"> 165 + <LabelMedium 166 + margin={0} 167 + className="!text-[var(--color-text-muted)]" 168 + > 169 + Listeners 170 + </LabelMedium> 171 + <HeadingXSmall 172 + margin={0} 173 + className="!text-[var(--color-text)]" 174 + > 175 + {numeral(artist?.listeners).format("0,0")} 176 + </HeadingXSmall> 177 + </div> 178 + <div> 179 + <LabelMedium 180 + margin={0} 181 + className="!text-[var(--color-text-muted)]" 182 + > 183 + Scrobbles 184 + </LabelMedium> 185 + <HeadingXSmall 186 + margin={0} 187 + className="!text-[var(--color-text)]" 188 + > 189 + {numeral(artist?.scrobbles).format("0,0")} 190 + </HeadingXSmall> 191 + </div> 192 + <div className="flex items-center justify-end flex-1 mr-[10px]"> 193 + <a 194 + href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 195 + target="_blank" 196 + className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 197 + > 198 + <ExternalLink 199 + size={24} 200 + className="mr-[10px] text-[var(--color-text)]" 201 + /> 202 + View on PDSls 203 + </a> 204 + </div> 205 + </div> 206 + </div> 207 + )} 208 + </Group> 209 209 210 - <PopularSongs topTracks={topTracks} /> 211 - <Albums topAlbums={topAlbums} /> 212 - <ArtistListeners listeners={artistListenersResult.data} /> 213 - <Shout type="artist" /> 214 - </div> 215 - </Main> 216 - ); 210 + <PopularSongs topTracks={topTracks} /> 211 + <Albums topAlbums={topAlbums} /> 212 + <ArtistListeners listeners={artistListenersResult.data} /> 213 + <Shout type="artist" /> 214 + </div> 215 + </Main> 216 + ); 217 217 }; 218 218 219 219 export default Artist;
+63 -63
apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx
··· 3 3 import { HeadingSmall } from "baseui/typography"; 4 4 5 5 interface ArtistListenersProps { 6 - listeners: { 7 - id: string; 8 - did: string; 9 - handle: string; 10 - displayName: string; 11 - avatar: string; 12 - mostListenedSong: { 13 - title: string; 14 - uri: string; 15 - playCount: number; 16 - }; 17 - totalPlays: number; 18 - rank: number; 19 - }[]; 6 + listeners: { 7 + id: string; 8 + did: string; 9 + handle: string; 10 + displayName: string; 11 + avatar: string; 12 + mostListenedSong: { 13 + title: string; 14 + uri: string; 15 + playCount: number; 16 + }; 17 + totalPlays: number; 18 + rank: number; 19 + }[]; 20 20 } 21 21 22 22 function ArtistListeners(props: ArtistListenersProps) { 23 - return ( 24 - <> 25 - <HeadingSmall 26 - marginBottom={"15px"} 27 - className="!text-[var(--color-text)] !mb-[30px]" 28 - > 29 - Listeners 30 - </HeadingSmall> 31 - {props.listeners?.map((item) => ( 32 - <div 33 - key={item.id} 34 - className="mb-[30px] flex flex-row items-center gap-[20px]" 35 - > 36 - <Link 37 - to={`/profile/${item.handle}` as string} 38 - className="no-underline" 39 - > 40 - <Avatar src={item.avatar} name={item.displayName} size={"60px"} /> 41 - </Link> 42 - <div> 43 - <Link 44 - to={`/profile/${item.handle}` as string} 45 - className="text-[var(--color-text)] hover:underline no-underline" 46 - style={{ fontWeight: 600 }} 47 - > 48 - @{item.handle} 49 - </Link> 50 - <div className="!text-[14px] mt-[5px]"> 51 - Listens to{" "} 52 - {item.mostListenedSong.uri && ( 53 - <Link 54 - to={`${item.mostListenedSong.uri?.split("at:/")[1].replace("app.rocksky.", "")}`} 55 - className="text-[var(--color-primary)] hover:underline no-underline" 56 - > 57 - {item.mostListenedSong.title} 58 - </Link> 59 - )} 60 - {!item.mostListenedSong.uri && ( 61 - <span style={{ fontWeight: 600 }}> 62 - {item.mostListenedSong.title} 63 - </span> 64 - )}{" "} 65 - a lot 66 - </div> 67 - </div> 68 - </div> 69 - ))} 70 - </> 71 - ); 23 + return ( 24 + <> 25 + <HeadingSmall 26 + marginBottom={"15px"} 27 + className="!text-[var(--color-text)] !mb-[30px]" 28 + > 29 + Listeners 30 + </HeadingSmall> 31 + {props.listeners?.map((item) => ( 32 + <div 33 + key={item.id} 34 + className="mb-[30px] flex flex-row items-center gap-[20px]" 35 + > 36 + <Link 37 + to={`/profile/${item.handle}` as string} 38 + className="no-underline" 39 + > 40 + <Avatar src={item.avatar} name={item.displayName} size={"60px"} /> 41 + </Link> 42 + <div> 43 + <Link 44 + to={`/profile/${item.handle}` as string} 45 + className="text-[var(--color-text)] hover:underline no-underline" 46 + style={{ fontWeight: 600 }} 47 + > 48 + @{item.handle} 49 + </Link> 50 + <div className="!text-[14px] mt-[5px]"> 51 + Listens to{" "} 52 + {item.mostListenedSong.uri && ( 53 + <Link 54 + to={`${item.mostListenedSong.uri?.split("at:/")[1].replace("app.rocksky.", "")}`} 55 + className="text-[var(--color-primary)] hover:underline no-underline" 56 + > 57 + {item.mostListenedSong.title} 58 + </Link> 59 + )} 60 + {!item.mostListenedSong.uri && ( 61 + <span style={{ fontWeight: 600 }}> 62 + {item.mostListenedSong.title} 63 + </span> 64 + )}{" "} 65 + a lot 66 + </div> 67 + </div> 68 + </div> 69 + ))} 70 + </> 71 + ); 72 72 } 73 73 74 74 export default ArtistListeners;
+99 -99
apps/web/src/pages/home/feed/Feed.tsx
··· 15 15 dayjs.extend(relativeTime); 16 16 17 17 const itemProps: BlockProps = { 18 - display: "flex", 19 - alignItems: "flex-start", 20 - flexDirection: "column", 18 + display: "flex", 19 + alignItems: "flex-start", 20 + flexDirection: "column", 21 21 }; 22 22 23 23 const Container = styled.div` ··· 28 28 `; 29 29 30 30 function Feed() { 31 - const { data, isLoading } = useFeedQuery(); 32 - console.log(data); 33 - return ( 34 - <Container> 35 - <HeadingMedium 36 - marginTop={"0px"} 37 - marginBottom={"25px"} 38 - className="!text-[var(--color-text)]" 39 - > 40 - Recently played 41 - </HeadingMedium> 31 + const { data, isLoading } = useFeedQuery(); 32 + console.log(data); 33 + return ( 34 + <Container> 35 + <HeadingMedium 36 + marginTop={"0px"} 37 + marginBottom={"25px"} 38 + className="!text-[var(--color-text)]" 39 + > 40 + Recently played 41 + </HeadingMedium> 42 42 43 - {isLoading && ( 44 - <ContentLoader 45 - width={800} 46 - height={575} 47 - viewBox="0 0 800 575" 48 - backgroundColor="var(--color-skeleton-background)" 49 - foregroundColor="var(--color-skeleton-foreground)" 50 - > 51 - <rect x="12" y="9" rx="2" ry="2" width="140" height="10" /> 52 - <rect x="14" y="30" rx="2" ry="2" width="667" height="11" /> 53 - <rect x="12" y="58" rx="2" ry="2" width="211" height="211" /> 54 - <rect x="240" y="57" rx="2" ry="2" width="211" height="211" /> 55 - <rect x="467" y="56" rx="2" ry="2" width="211" height="211" /> 56 - <rect x="12" y="283" rx="2" ry="2" width="211" height="211" /> 57 - <rect x="240" y="281" rx="2" ry="2" width="211" height="211" /> 58 - <rect x="468" y="279" rx="2" ry="2" width="211" height="211" /> 59 - <circle cx="286" cy="536" r="12" /> 60 - <circle cx="319" cy="535" r="12" /> 61 - <circle cx="353" cy="535" r="12" /> 62 - <rect x="378" y="524" rx="0" ry="0" width="52" height="24" /> 63 - <rect x="210" y="523" rx="0" ry="0" width="52" height="24" /> 64 - <circle cx="210" cy="535" r="12" /> 65 - <circle cx="428" cy="536" r="12" /> 66 - </ContentLoader> 67 - )} 43 + {isLoading && ( 44 + <ContentLoader 45 + width={800} 46 + height={575} 47 + viewBox="0 0 800 575" 48 + backgroundColor="var(--color-skeleton-background)" 49 + foregroundColor="var(--color-skeleton-foreground)" 50 + > 51 + <rect x="12" y="9" rx="2" ry="2" width="140" height="10" /> 52 + <rect x="14" y="30" rx="2" ry="2" width="667" height="11" /> 53 + <rect x="12" y="58" rx="2" ry="2" width="211" height="211" /> 54 + <rect x="240" y="57" rx="2" ry="2" width="211" height="211" /> 55 + <rect x="467" y="56" rx="2" ry="2" width="211" height="211" /> 56 + <rect x="12" y="283" rx="2" ry="2" width="211" height="211" /> 57 + <rect x="240" y="281" rx="2" ry="2" width="211" height="211" /> 58 + <rect x="468" y="279" rx="2" ry="2" width="211" height="211" /> 59 + <circle cx="286" cy="536" r="12" /> 60 + <circle cx="319" cy="535" r="12" /> 61 + <circle cx="353" cy="535" r="12" /> 62 + <rect x="378" y="524" rx="0" ry="0" width="52" height="24" /> 63 + <rect x="210" y="523" rx="0" ry="0" width="52" height="24" /> 64 + <circle cx="210" cy="535" r="12" /> 65 + <circle cx="428" cy="536" r="12" /> 66 + </ContentLoader> 67 + )} 68 68 69 - {!isLoading && ( 70 - <div className="pb-[100px]"> 71 - <FlexGrid 72 - flexGridColumnCount={[1, 2, 3]} 73 - flexGridColumnGap="scale800" 74 - flexGridRowGap="scale1000" 75 - > 76 - { 77 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 78 - data.map((song: any) => ( 79 - <FlexGridItem {...itemProps} key={song.id}> 80 - <Link 81 - to="/$did/scrobble/$rkey" 82 - params={{ 83 - did: song.uri?.split("at://")[1]?.split("/")[0] || "", 84 - rkey: song.uri?.split("/").pop() || "", 85 - }} 86 - className="no-underline text-[var(--color-text-primary)]" 87 - > 88 - <SongCover 89 - cover={song.cover} 90 - artist={song.artist} 91 - title={song.title} 92 - /> 93 - </Link> 94 - <div className="flex"> 95 - <div className="mr-[8px]"> 96 - <Avatar 97 - src={song.userAvatar} 98 - name={song.userDisplayName} 99 - size={"20px"} 100 - /> 101 - </div> 102 - <Handle 103 - link={`/profile/${song.user}`} 104 - did={song.user} 105 - />{" "} 106 - </div> 107 - <LabelSmall className="!text-[var(--color-text-primary)]"> 108 - recently played this song 109 - </LabelSmall> 110 - <StatefulTooltip 111 - content={dayjs(song.date).format( 112 - "MMMM D, YYYY [at] HH:mm A", 113 - )} 114 - returnFocus 115 - autoFocus 116 - > 117 - <LabelSmall className="!text-[var(--color-text-muted)]"> 118 - {dayjs(song.date).fromNow()} 119 - </LabelSmall> 120 - </StatefulTooltip> 121 - </FlexGridItem> 122 - )) 123 - } 124 - </FlexGrid> 125 - </div> 126 - )} 127 - </Container> 128 - ); 69 + {!isLoading && ( 70 + <div className="pb-[100px]"> 71 + <FlexGrid 72 + flexGridColumnCount={[1, 2, 3]} 73 + flexGridColumnGap="scale800" 74 + flexGridRowGap="scale1000" 75 + > 76 + { 77 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 78 + data.map((song: any) => ( 79 + <FlexGridItem {...itemProps} key={song.id}> 80 + <Link 81 + to="/$did/scrobble/$rkey" 82 + params={{ 83 + did: song.uri?.split("at://")[1]?.split("/")[0] || "", 84 + rkey: song.uri?.split("/").pop() || "", 85 + }} 86 + className="no-underline text-[var(--color-text-primary)]" 87 + > 88 + <SongCover 89 + cover={song.cover} 90 + artist={song.artist} 91 + title={song.title} 92 + /> 93 + </Link> 94 + <div className="flex"> 95 + <div className="mr-[8px]"> 96 + <Avatar 97 + src={song.userAvatar} 98 + name={song.userDisplayName} 99 + size={"20px"} 100 + /> 101 + </div> 102 + <Handle 103 + link={`/profile/${song.user}`} 104 + did={song.user} 105 + />{" "} 106 + </div> 107 + <LabelSmall className="!text-[var(--color-text-primary)]"> 108 + recently played this song 109 + </LabelSmall> 110 + <StatefulTooltip 111 + content={dayjs(song.date).format( 112 + "MMMM D, YYYY [at] HH:mm A", 113 + )} 114 + returnFocus 115 + autoFocus 116 + > 117 + <LabelSmall className="!text-[var(--color-text-muted)]"> 118 + {dayjs(song.date).fromNow()} 119 + </LabelSmall> 120 + </StatefulTooltip> 121 + </FlexGridItem> 122 + )) 123 + } 124 + </FlexGrid> 125 + </div> 126 + )} 127 + </Container> 128 + ); 129 129 } 130 130 131 131 export default Feed;
+182 -182
apps/web/src/pages/profile/Profile.tsx
··· 26 26 `; 27 27 28 28 export type ProfileProps = { 29 - activeKey?: string; 29 + activeKey?: string; 30 30 }; 31 31 32 32 function Profile(props: ProfileProps) { 33 - const [profiles, setProfiles] = useAtom(profilesAtom); 34 - const [activeKey, setActiveKey] = useState<Key>( 35 - _.get(props, "activeKey", "0").split("/")[0], 36 - ); 37 - const { did } = useParams({ strict: false }); 38 - const profile = useProfileByDidQuery(did!); 39 - const setUser = useSetAtom(userAtom); 40 - const { tab } = useSearch({ strict: false }); 33 + const [profiles, setProfiles] = useAtom(profilesAtom); 34 + const [activeKey, setActiveKey] = useState<Key>( 35 + _.get(props, "activeKey", "0").split("/")[0], 36 + ); 37 + const { did } = useParams({ strict: false }); 38 + const profile = useProfileByDidQuery(did!); 39 + const setUser = useSetAtom(userAtom); 40 + const { tab } = useSearch({ strict: false }); 41 41 42 - useEffect(() => { 43 - if (tab === undefined) { 44 - return; 45 - } 42 + useEffect(() => { 43 + if (tab === undefined) { 44 + return; 45 + } 46 46 47 - setActiveKey(1); 48 - }, [tab]); 47 + setActiveKey(1); 48 + }, [tab]); 49 49 50 - // biome-ignore lint/correctness/useExhaustiveDependencies: <reason>want to run only on profile.data changes</reason> 51 - useEffect(() => { 52 - if (profile.isLoading || profile.isError) { 53 - return; 54 - } 50 + // biome-ignore lint/correctness/useExhaustiveDependencies: <reason>want to run only on profile.data changes</reason> 51 + useEffect(() => { 52 + if (profile.isLoading || profile.isError) { 53 + return; 54 + } 55 55 56 - if (!profile.data || !did) { 57 - return; 58 - } 56 + if (!profile.data || !did) { 57 + return; 58 + } 59 59 60 - setUser({ 61 - avatar: profile.data.avatar, 62 - displayName: profile.data.displayName, 63 - handle: profile.data.handle, 64 - spotifyUser: { 65 - isBeta: profile.data.spotifyUser?.isBetaUser, 66 - }, 67 - spotifyConnected: profile.data.spotifyConnected, 68 - did: profile.data.did, 69 - }); 60 + setUser({ 61 + avatar: profile.data.avatar, 62 + displayName: profile.data.displayName, 63 + handle: profile.data.handle, 64 + spotifyUser: { 65 + isBeta: profile.data.spotifyUser?.isBetaUser, 66 + }, 67 + spotifyConnected: profile.data.spotifyConnected, 68 + did: profile.data.did, 69 + }); 70 70 71 - setProfiles((profiles) => ({ 72 - ...profiles, 73 - [did]: { 74 - avatar: profile.data.avatar, 75 - displayName: profile.data.displayName, 76 - handle: profile.data.handle, 77 - spotifyConnected: profile.data.spotifyConnected, 78 - createdAt: profile.data.createdAt, 79 - did, 80 - }, 81 - })); 82 - // eslint-disable-next-line react-hooks/exhaustive-deps 83 - }, [profile.data, profile.isLoading, profile.isError, did]); 71 + setProfiles((profiles) => ({ 72 + ...profiles, 73 + [did]: { 74 + avatar: profile.data.avatar, 75 + displayName: profile.data.displayName, 76 + handle: profile.data.handle, 77 + spotifyConnected: profile.data.spotifyConnected, 78 + createdAt: profile.data.createdAt, 79 + did, 80 + }, 81 + })); 82 + // eslint-disable-next-line react-hooks/exhaustive-deps 83 + }, [profile.data, profile.isLoading, profile.isError, did]); 84 84 85 - if (!did) { 86 - return; 87 - } 85 + if (!did) { 86 + return; 87 + } 88 88 89 - return ( 90 - <Main> 91 - <div className="pb-[100px] pt-[75px]"> 92 - <Group> 93 - <div className="mr-[20px]"> 94 - <Avatar 95 - name={profiles[did]?.displayName} 96 - src={profiles[did]?.avatar} 97 - size="150px" 98 - /> 99 - </div> 100 - <div style={{ marginTop: profiles[did]?.displayName ? 10 : 30 }}> 101 - <HeadingMedium 102 - marginTop="0px" 103 - marginBottom={0} 104 - className="!text-[var(--color-text)]" 105 - > 106 - {profiles[did]?.displayName} 107 - </HeadingMedium> 108 - <LabelLarge> 109 - <a 110 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 111 - className="no-underline text-[var(--color-primary)]" 112 - > 113 - @{profiles[did]?.handle} 114 - </a> 115 - <span className="text-[var(--color-text-muted)] text-[15px]"> 116 - {" "} 117 - • scrobbling since{" "} 118 - {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 119 - </span> 120 - </LabelLarge> 121 - <div className="flex-1 mt-[30px] mr-[10px]"> 122 - <a 123 - href={`https://pdsls.dev/at/${profiles[did]?.did}`} 124 - target="_blank" 125 - className="no-underline text-[var(--color-text)] bg-[var(--color-default-button)] p-[16px] rounded-[10px] pl-[25px] pr-[25px]" 126 - > 127 - <ExternalLink size={24} style={{ marginRight: 10 }} /> 128 - View on PDSls 129 - </a> 130 - </div> 131 - </div> 132 - </Group> 89 + return ( 90 + <Main> 91 + <div className="pb-[100px] pt-[75px]"> 92 + <Group> 93 + <div className="mr-[20px]"> 94 + <Avatar 95 + name={profiles[did]?.displayName} 96 + src={profiles[did]?.avatar} 97 + size="150px" 98 + /> 99 + </div> 100 + <div style={{ marginTop: profiles[did]?.displayName ? 10 : 30 }}> 101 + <HeadingMedium 102 + marginTop="0px" 103 + marginBottom={0} 104 + className="!text-[var(--color-text)]" 105 + > 106 + {profiles[did]?.displayName} 107 + </HeadingMedium> 108 + <LabelLarge> 109 + <a 110 + href={`https://bsky.app/profile/${profiles[did]?.handle}`} 111 + className="no-underline text-[var(--color-primary)]" 112 + > 113 + @{profiles[did]?.handle} 114 + </a> 115 + <span className="text-[var(--color-text-muted)] text-[15px]"> 116 + {" "} 117 + • scrobbling since{" "} 118 + {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 119 + </span> 120 + </LabelLarge> 121 + <div className="flex-1 mt-[30px] mr-[10px]"> 122 + <a 123 + href={`https://pdsls.dev/at/${profiles[did]?.did}`} 124 + target="_blank" 125 + className="no-underline text-[var(--color-text)] bg-[var(--color-default-button)] p-[16px] rounded-[10px] pl-[25px] pr-[25px]" 126 + > 127 + <ExternalLink size={24} style={{ marginRight: 10 }} /> 128 + View on PDSls 129 + </a> 130 + </div> 131 + </div> 132 + </Group> 133 133 134 - <Tabs 135 - activeKey={activeKey} 136 - onChange={({ activeKey }) => { 137 - setActiveKey(activeKey); 138 - }} 139 - overrides={{ 140 - TabHighlight: { 141 - style: { 142 - backgroundColor: "var(--color-purple)", 143 - }, 144 - }, 145 - TabBorder: { 146 - style: { 147 - display: "none", 148 - }, 149 - }, 150 - }} 151 - activateOnFocus 152 - > 153 - <Tab 154 - title="Overview" 155 - overrides={{ 156 - Tab: { 157 - style: { 158 - color: "var(--color-text)", 159 - backgroundColor: "var(--color-background) !important", 160 - }, 161 - }, 162 - }} 163 - > 164 - <Overview /> 165 - </Tab> 166 - <Tab 167 - title="Library" 168 - overrides={{ 169 - Tab: { 170 - style: { 171 - color: "var(--color-text)", 172 - backgroundColor: "var(--color-background) !important", 173 - }, 174 - }, 175 - }} 176 - > 177 - <Library 178 - activeKey={_.get(props, "activeKey", "0").split("/")[1] || "0"} 179 - /> 180 - </Tab> 181 - <Tab 182 - title="Playlists" 183 - overrides={{ 184 - Tab: { 185 - style: { 186 - color: "var(--color-text)", 187 - backgroundColor: "var(--color-background) !important", 188 - }, 189 - }, 190 - }} 191 - > 192 - <Playlists /> 193 - </Tab> 194 - <Tab 195 - title="Loved Tracks" 196 - overrides={{ 197 - Tab: { 198 - style: { 199 - color: "var(--color-text)", 200 - backgroundColor: "var(--color-background) !important", 201 - }, 202 - }, 203 - }} 204 - > 205 - <LovedTracks /> 206 - </Tab> 207 - <Tab 208 - title="Tags" 209 - overrides={{ 210 - Tab: { 211 - style: { 212 - color: "var(--color-text)", 213 - backgroundColor: "var(--color-background) !important", 214 - }, 215 - }, 216 - }} 217 - ></Tab> 218 - </Tabs> 219 - <Shout type="profile" /> 220 - </div> 221 - </Main> 222 - ); 134 + <Tabs 135 + activeKey={activeKey} 136 + onChange={({ activeKey }) => { 137 + setActiveKey(activeKey); 138 + }} 139 + overrides={{ 140 + TabHighlight: { 141 + style: { 142 + backgroundColor: "var(--color-purple)", 143 + }, 144 + }, 145 + TabBorder: { 146 + style: { 147 + display: "none", 148 + }, 149 + }, 150 + }} 151 + activateOnFocus 152 + > 153 + <Tab 154 + title="Overview" 155 + overrides={{ 156 + Tab: { 157 + style: { 158 + color: "var(--color-text)", 159 + backgroundColor: "var(--color-background) !important", 160 + }, 161 + }, 162 + }} 163 + > 164 + <Overview /> 165 + </Tab> 166 + <Tab 167 + title="Library" 168 + overrides={{ 169 + Tab: { 170 + style: { 171 + color: "var(--color-text)", 172 + backgroundColor: "var(--color-background) !important", 173 + }, 174 + }, 175 + }} 176 + > 177 + <Library 178 + activeKey={_.get(props, "activeKey", "0").split("/")[1] || "0"} 179 + /> 180 + </Tab> 181 + <Tab 182 + title="Playlists" 183 + overrides={{ 184 + Tab: { 185 + style: { 186 + color: "var(--color-text)", 187 + backgroundColor: "var(--color-background) !important", 188 + }, 189 + }, 190 + }} 191 + > 192 + <Playlists /> 193 + </Tab> 194 + <Tab 195 + title="Loved Tracks" 196 + overrides={{ 197 + Tab: { 198 + style: { 199 + color: "var(--color-text)", 200 + backgroundColor: "var(--color-background) !important", 201 + }, 202 + }, 203 + }} 204 + > 205 + <LovedTracks /> 206 + </Tab> 207 + <Tab 208 + title="Tags" 209 + overrides={{ 210 + Tab: { 211 + style: { 212 + color: "var(--color-text)", 213 + backgroundColor: "var(--color-background) !important", 214 + }, 215 + }, 216 + }} 217 + ></Tab> 218 + </Tabs> 219 + <Shout type="profile" /> 220 + </div> 221 + </Main> 222 + ); 223 223 } 224 224 225 225 export default Profile;
+1 -1
crates/analytics/src/handlers/albums.rs
··· 135 135 a.year, 136 136 a.uri, 137 137 a.sha256, 138 - COUNT(*) AS play_count, 138 + COUNT(DISTINCT s.created_at) AS play_count, 139 139 COUNT(DISTINCT s.user_id) AS unique_listeners 140 140 FROM 141 141 scrobbles s
+1 -1
crates/analytics/src/handlers/artists.rs
··· 136 136 ar.sha256 AS sha256, 137 137 ar.uri AS uri, 138 138 ar.genres AS genres, 139 - COUNT(*) AS play_count, 139 + COUNT(DISTINCT s.created_at) AS play_count, 140 140 COUNT(DISTINCT s.user_id) AS unique_listeners 141 141 FROM 142 142 scrobbles s
+2 -2
crates/analytics/src/handlers/stats.rs
··· 38 38 ar.picture AS picture, 39 39 ar.sha256 AS sha256, 40 40 ar.uri AS uri, 41 - COUNT(*) AS play_count, 41 + COUNT(DISTINCT s.created_at) AS play_count, 42 42 COUNT(DISTINCT s.user_id) AS unique_listeners 43 43 FROM 44 44 scrobbles s ··· 70 70 a.year, 71 71 a.uri, 72 72 a.sha256, 73 - COUNT(*) AS play_count, 73 + COUNT(DISTINCT s.created_at) AS play_count, 74 74 COUNT(DISTINCT s.user_id) AS unique_listeners 75 75 FROM 76 76 scrobbles s
+1 -1
crates/analytics/src/handlers/tracks.rs
··· 284 284 t.album_uri, 285 285 t.sha256, 286 286 t.created_at, 287 - COUNT(*) AS play_count, 287 + COUNT(DISTINCT s.created_at) AS play_count, 288 288 COUNT(DISTINCT s.user_id) AS unique_listeners 289 289 FROM scrobbles s 290 290 LEFT JOIN tracks t ON s.track_id = t.id