A decentralized music tracking and discovery platform built on AT Protocol 🎵

feat: display user avatar at the bottom of each scrobble

+331 -323
+137 -141
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 - <> 18 - <StatefulPopover 19 - autoFocus={false} 20 - content={({ close }) => ( 21 - <div className="border-[var(--color-border)] w-[240px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 22 - <div 23 - className="h-[54px] flex flex-row items-center pl-[5px] pr-[5px]" 24 - style={{ 25 - borderBottom: "1px solid var(--color-border)", 26 - }} 27 - > 28 - <div className="h-[43px] flex items-center justify-center ml-[10px] mr-[10px] text-[var(--color-text)]"> 29 - {file.type == "folder" && ( 30 - <div> 31 - <Folder2 size={20} /> 32 - </div> 33 - )} 34 - {file.type !== "folder" && ( 35 - <div> 36 - <MusicNoteBeamed size={20} /> 37 - </div> 38 - )} 39 - </div> 40 - <div className="text-[var(--color-text)] whitespace-nowrap text-ellipsis overflow-hidden"> 41 - {file.name} 42 - </div> 43 - </div> 44 - <NestedMenus> 45 - <StatefulMenu 46 - items={[ 47 - { 48 - id: "0", 49 - label: "Play", 50 - }, 51 - { 52 - id: "1", 53 - label: "Play Next", 54 - }, 55 - { 56 - id: "2", 57 - label: "Add to Playlist", 58 - }, 59 - { 60 - id: "3", 61 - label: "Play Last", 62 - }, 63 - { 64 - id: "4", 65 - label: "Add Shuffled", 66 - }, 67 - ]} 68 - onItemSelect={({ item }) => { 69 - console.log(`Selected item: ${item.label}`); 70 - close(); 71 - }} 72 - overrides={{ 73 - List: { 74 - style: { 75 - boxShadow: "none", 76 - outline: "none !important", 77 - backgroundColor: "var(--color-background)", 78 - }, 79 - }, 80 - ListItem: { 81 - style: { 82 - backgroundColor: "var(--color-background)", 83 - color: "var(--color-text)", 84 - ":hover": { 85 - backgroundColor: "var(--color-menu-hover)", 86 - }, 87 - }, 88 - }, 89 - Option: { 90 - props: { 91 - getChildMenu: (item: { label: string }) => { 92 - if (item.label === "Add to Playlist") { 93 - return ( 94 - <div className="border-[var(--color-border)] w-[205px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 95 - <StatefulMenu 96 - items={{ 97 - __ungrouped: [ 98 - { 99 - label: "Create new playlist", 100 - }, 101 - ], 102 - }} 103 - overrides={{ 104 - List: { 105 - style: { 106 - boxShadow: "none", 107 - outline: "none !important", 108 - backgroundColor: 109 - "var(--color-background)", 110 - }, 111 - }, 112 - ListItem: { 113 - style: { 114 - backgroundColor: 115 - "var(--color-background)", 116 - color: "var(--color-text)", 117 - ":hover": { 118 - backgroundColor: 119 - "var(--color-menu-hover)", 120 - }, 121 - }, 122 - }, 123 - }} 124 - /> 125 - </div> 126 - ); 127 - } 128 - return null; 129 - }, 130 - }, 131 - }, 132 - }} 133 - /> 134 - </NestedMenus> 135 - </div> 136 - )} 137 - overrides={{ 138 - Inner: { 139 - style: { 140 - backgroundColor: "var(--color-background)", 141 - }, 142 - }, 143 - }} 144 - > 145 - <button className="text-[var(--color-text-muted)] cursor-pointer bg-transparent border-none hover:bg-transparent"> 146 - <EllipsisHorizontal size={24} /> 147 - </button> 148 - </StatefulPopover> 149 - </> 150 - ); 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 + ); 151 147 } 152 148 153 149 export default ContextMenu;
+94 -96
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 - <> 77 - <StatefulPopover 78 - content={() => ( 79 - <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 80 - <div className="flex flex-row items-center"> 81 - <Link to={link} className="no-underline"> 82 - <Avatar 83 - src={profiles[did]?.avatar} 84 - name={profiles[did]?.displayName} 85 - size={"60px"} 86 - /> 87 - </Link> 88 - <div className="ml-[16px]"> 89 - <Link to={link} className="no-underline"> 90 - <LabelMedium 91 - marginTop={"10px"} 92 - className="!text-[var(--color-text)]" 93 - > 94 - {profiles[did]?.displayName} 95 - </LabelMedium> 96 - </Link> 97 - <a 98 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 99 - className="no-underline text-[var(--color-primary)]" 100 - > 101 - <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 102 - @{did} 103 - </LabelSmall> 104 - </a> 105 - </div> 106 - </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> 107 106 108 - {stats[did] && <Stats stats={stats[did]} mb={1} />} 107 + {stats[did] && <Stats stats={stats[did]} mb={1} />} 109 108 110 - <NowPlaying did={did} /> 111 - </Block> 112 - )} 113 - triggerType={TRIGGER_TYPE.hover} 114 - autoFocus={false} 115 - focusLock={false} 116 - > 117 - <Link to={link} className="no-underline"> 118 - <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[250px]"> 119 - @{did} 120 - </LabelMedium> 121 - </Link> 122 - </StatefulPopover> 123 - </> 124 - ); 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 + ); 125 123 } 126 124 127 125 export default Handle;
+100 -86
apps/web/src/pages/home/feed/Feed.tsx
··· 1 1 import styled from "@emotion/styled"; 2 2 import { Link } from "@tanstack/react-router"; 3 + import { Avatar } from "baseui/avatar"; 3 4 import type { BlockProps } from "baseui/block"; 4 5 import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; 5 6 import { StatefulTooltip } from "baseui/tooltip"; 6 - import { HeadingMedium, LabelMedium } from "baseui/typography"; 7 + import { HeadingMedium, LabelSmall } from "baseui/typography"; 7 8 import dayjs from "dayjs"; 8 9 import relativeTime from "dayjs/plugin/relativeTime"; 9 10 import ContentLoader from "react-content-loader"; ··· 14 15 dayjs.extend(relativeTime); 15 16 16 17 const itemProps: BlockProps = { 17 - display: "flex", 18 - alignItems: "flex-start", 19 - flexDirection: "column", 18 + display: "flex", 19 + alignItems: "flex-start", 20 + flexDirection: "column", 20 21 }; 21 22 22 23 const Container = styled.div` ··· 27 28 `; 28 29 29 30 function Feed() { 30 - const { data, isLoading } = useFeedQuery(); 31 - return ( 32 - <Container> 33 - <HeadingMedium 34 - marginTop={"0px"} 35 - marginBottom={"20px"} 36 - className="!text-[var(--color-text)]" 37 - > 38 - Recently played 39 - </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> 40 42 41 - {isLoading && ( 42 - <ContentLoader 43 - width={800} 44 - height={575} 45 - viewBox="0 0 800 575" 46 - backgroundColor="var(--color-skeleton-background)" 47 - foregroundColor="var(--color-skeleton-foreground)" 48 - > 49 - <rect x="12" y="9" rx="2" ry="2" width="140" height="10" /> 50 - <rect x="14" y="30" rx="2" ry="2" width="667" height="11" /> 51 - <rect x="12" y="58" rx="2" ry="2" width="211" height="211" /> 52 - <rect x="240" y="57" rx="2" ry="2" width="211" height="211" /> 53 - <rect x="467" y="56" rx="2" ry="2" width="211" height="211" /> 54 - <rect x="12" y="283" rx="2" ry="2" width="211" height="211" /> 55 - <rect x="240" y="281" rx="2" ry="2" width="211" height="211" /> 56 - <rect x="468" y="279" rx="2" ry="2" width="211" height="211" /> 57 - <circle cx="286" cy="536" r="12" /> 58 - <circle cx="319" cy="535" r="12" /> 59 - <circle cx="353" cy="535" r="12" /> 60 - <rect x="378" y="524" rx="0" ry="0" width="52" height="24" /> 61 - <rect x="210" y="523" rx="0" ry="0" width="52" height="24" /> 62 - <circle cx="210" cy="535" r="12" /> 63 - <circle cx="428" cy="536" r="12" /> 64 - </ContentLoader> 65 - )} 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 + )} 66 68 67 - {!isLoading && ( 68 - <div className="pb-[100px]"> 69 - <FlexGrid 70 - flexGridColumnCount={[1, 2, 3]} 71 - flexGridColumnGap="scale800" 72 - flexGridRowGap="scale800" 73 - > 74 - { 75 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 - data.map((song: any) => ( 77 - <FlexGridItem {...itemProps} key={song.id}> 78 - <Link 79 - to="/$did/scrobble/$rkey" 80 - params={{ 81 - did: song.uri?.split("at://")[1]?.split("/")[0] || "", 82 - rkey: song.uri?.split("/").pop() || "", 83 - }} 84 - > 85 - <SongCover 86 - cover={song.cover} 87 - artist={song.artist} 88 - title={song.title} 89 - /> 90 - </Link> 91 - <Handle link={`/profile/${song.user}`} did={song.user} />{" "} 92 - <LabelMedium className="!text-[var(--color-text-primary)]"> 93 - recently played this song 94 - </LabelMedium> 95 - <StatefulTooltip 96 - content={dayjs(song.date).format( 97 - "MMMM D, YYYY [at] HH:mm A", 98 - )} 99 - returnFocus 100 - autoFocus 101 - > 102 - <LabelMedium className="!text-[var(--color-text-muted)]"> 103 - {dayjs(song.date).fromNow()} 104 - </LabelMedium> 105 - </StatefulTooltip> 106 - </FlexGridItem> 107 - )) 108 - } 109 - </FlexGrid> 110 - </div> 111 - )} 112 - </Container> 113 - ); 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 + > 87 + <SongCover 88 + cover={song.cover} 89 + artist={song.artist} 90 + title={song.title} 91 + /> 92 + </Link> 93 + <div className="flex"> 94 + <div className="mr-[8px]"> 95 + <Avatar 96 + src={song.userAvatar} 97 + name={song.userDisplayName} 98 + size={"20px"} 99 + /> 100 + </div> 101 + <Handle 102 + link={`/profile/${song.user}`} 103 + did={song.user} 104 + />{" "} 105 + </div> 106 + <LabelSmall className="!text-[var(--color-text-primary)]"> 107 + recently played this song 108 + </LabelSmall> 109 + <StatefulTooltip 110 + content={dayjs(song.date).format( 111 + "MMMM D, YYYY [at] HH:mm A", 112 + )} 113 + returnFocus 114 + autoFocus 115 + > 116 + <LabelSmall className="!text-[var(--color-text-muted)]"> 117 + {dayjs(song.date).fromNow()} 118 + </LabelSmall> 119 + </StatefulTooltip> 120 + </FlexGridItem> 121 + )) 122 + } 123 + </FlexGrid> 124 + </div> 125 + )} 126 + </Container> 127 + ); 114 128 } 115 129 116 130 export default Feed;