feat: display user avatar at the bottom of each scrobble #4

merged
opened by tsiry-sandratraina.com targeting main from feat/scrobble-user-avatar
Changed files
+331 -323
apps
web
src
components
ContextMenu
Handle
pages
home
feed
+137 -141
apps/web/src/components/ContextMenu/ContextMenu.tsx
···
import { StatefulPopover } from "baseui/popover";
export type ContextMenuProps = {
-
file: {
-
id: string;
-
name: string;
-
type: string;
-
};
+
file: {
+
id: string;
+
name: string;
+
type: string;
+
};
};
function ContextMenu(props: ContextMenuProps) {
-
const { file } = props;
-
return (
-
<>
-
<StatefulPopover
-
autoFocus={false}
-
content={({ close }) => (
-
<div className="border-[var(--color-border)] w-[240px] border-[1px] bg-[var(--color-background)] rounded-[6px]">
-
<div
-
className="h-[54px] flex flex-row items-center pl-[5px] pr-[5px]"
-
style={{
-
borderBottom: "1px solid var(--color-border)",
-
}}
-
>
-
<div className="h-[43px] flex items-center justify-center ml-[10px] mr-[10px] text-[var(--color-text)]">
-
{file.type == "folder" && (
-
<div>
-
<Folder2 size={20} />
-
</div>
-
)}
-
{file.type !== "folder" && (
-
<div>
-
<MusicNoteBeamed size={20} />
-
</div>
-
)}
-
</div>
-
<div className="text-[var(--color-text)] whitespace-nowrap text-ellipsis overflow-hidden">
-
{file.name}
-
</div>
-
</div>
-
<NestedMenus>
-
<StatefulMenu
-
items={[
-
{
-
id: "0",
-
label: "Play",
-
},
-
{
-
id: "1",
-
label: "Play Next",
-
},
-
{
-
id: "2",
-
label: "Add to Playlist",
-
},
-
{
-
id: "3",
-
label: "Play Last",
-
},
-
{
-
id: "4",
-
label: "Add Shuffled",
-
},
-
]}
-
onItemSelect={({ item }) => {
-
console.log(`Selected item: ${item.label}`);
-
close();
-
}}
-
overrides={{
-
List: {
-
style: {
-
boxShadow: "none",
-
outline: "none !important",
-
backgroundColor: "var(--color-background)",
-
},
-
},
-
ListItem: {
-
style: {
-
backgroundColor: "var(--color-background)",
-
color: "var(--color-text)",
-
":hover": {
-
backgroundColor: "var(--color-menu-hover)",
-
},
-
},
-
},
-
Option: {
-
props: {
-
getChildMenu: (item: { label: string }) => {
-
if (item.label === "Add to Playlist") {
-
return (
-
<div className="border-[var(--color-border)] w-[205px] border-[1px] bg-[var(--color-background)] rounded-[6px]">
-
<StatefulMenu
-
items={{
-
__ungrouped: [
-
{
-
label: "Create new playlist",
-
},
-
],
-
}}
-
overrides={{
-
List: {
-
style: {
-
boxShadow: "none",
-
outline: "none !important",
-
backgroundColor:
-
"var(--color-background)",
-
},
-
},
-
ListItem: {
-
style: {
-
backgroundColor:
-
"var(--color-background)",
-
color: "var(--color-text)",
-
":hover": {
-
backgroundColor:
-
"var(--color-menu-hover)",
-
},
-
},
-
},
-
}}
-
/>
-
</div>
-
);
-
}
-
return null;
-
},
-
},
-
},
-
}}
-
/>
-
</NestedMenus>
-
</div>
-
)}
-
overrides={{
-
Inner: {
-
style: {
-
backgroundColor: "var(--color-background)",
-
},
-
},
-
}}
-
>
-
<button className="text-[var(--color-text-muted)] cursor-pointer bg-transparent border-none hover:bg-transparent">
-
<EllipsisHorizontal size={24} />
-
</button>
-
</StatefulPopover>
-
</>
-
);
+
const { file } = props;
+
return (
+
<StatefulPopover
+
autoFocus={false}
+
content={({ close }) => (
+
<div className="border-[var(--color-border)] w-[240px] border-[1px] bg-[var(--color-background)] rounded-[6px]">
+
<div
+
className="h-[54px] flex flex-row items-center pl-[5px] pr-[5px]"
+
style={{
+
borderBottom: "1px solid var(--color-border)",
+
}}
+
>
+
<div className="h-[43px] flex items-center justify-center ml-[10px] mr-[10px] text-[var(--color-text)]">
+
{file.type == "folder" && (
+
<div>
+
<Folder2 size={20} />
+
</div>
+
)}
+
{file.type !== "folder" && (
+
<div>
+
<MusicNoteBeamed size={20} />
+
</div>
+
)}
+
</div>
+
<div className="text-[var(--color-text)] whitespace-nowrap text-ellipsis overflow-hidden">
+
{file.name}
+
</div>
+
</div>
+
<NestedMenus>
+
<StatefulMenu
+
items={[
+
{
+
id: "0",
+
label: "Play",
+
},
+
{
+
id: "1",
+
label: "Play Next",
+
},
+
{
+
id: "2",
+
label: "Add to Playlist",
+
},
+
{
+
id: "3",
+
label: "Play Last",
+
},
+
{
+
id: "4",
+
label: "Add Shuffled",
+
},
+
]}
+
onItemSelect={({ item }) => {
+
console.log(`Selected item: ${item.label}`);
+
close();
+
}}
+
overrides={{
+
List: {
+
style: {
+
boxShadow: "none",
+
outline: "none !important",
+
backgroundColor: "var(--color-background)",
+
},
+
},
+
ListItem: {
+
style: {
+
backgroundColor: "var(--color-background)",
+
color: "var(--color-text)",
+
":hover": {
+
backgroundColor: "var(--color-menu-hover)",
+
},
+
},
+
},
+
Option: {
+
props: {
+
getChildMenu: (item: { label: string }) => {
+
if (item.label === "Add to Playlist") {
+
return (
+
<div className="border-[var(--color-border)] w-[205px] border-[1px] bg-[var(--color-background)] rounded-[6px]">
+
<StatefulMenu
+
items={{
+
__ungrouped: [
+
{
+
label: "Create new playlist",
+
},
+
],
+
}}
+
overrides={{
+
List: {
+
style: {
+
boxShadow: "none",
+
outline: "none !important",
+
backgroundColor: "var(--color-background)",
+
},
+
},
+
ListItem: {
+
style: {
+
backgroundColor: "var(--color-background)",
+
color: "var(--color-text)",
+
":hover": {
+
backgroundColor:
+
"var(--color-menu-hover)",
+
},
+
},
+
},
+
}}
+
/>
+
</div>
+
);
+
}
+
return null;
+
},
+
},
+
},
+
}}
+
/>
+
</NestedMenus>
+
</div>
+
)}
+
overrides={{
+
Inner: {
+
style: {
+
backgroundColor: "var(--color-background)",
+
},
+
},
+
}}
+
>
+
<button className="text-[var(--color-text-muted)] cursor-pointer bg-transparent border-none hover:bg-transparent">
+
<EllipsisHorizontal size={24} />
+
</button>
+
</StatefulPopover>
+
);
}
export default ContextMenu;
+94 -96
apps/web/src/components/Handle/Handle.tsx
···
import { profilesAtom } from "../../atoms/profiles";
import { statsAtom } from "../../atoms/stats";
import {
-
useProfileByDidQuery,
-
useProfileStatsByDidQuery,
+
useProfileByDidQuery,
+
useProfileStatsByDidQuery,
} from "../../hooks/useProfile";
import Stats from "../Stats";
import NowPlaying from "./NowPlaying";
export type HandleProps = {
-
link: string;
-
did: string;
+
link: string;
+
did: string;
};
function Handle(props: HandleProps) {
-
const { link, did } = props;
-
const [profiles, setProfiles] = useAtom(profilesAtom);
-
const profile = useProfileByDidQuery(did);
-
const profileStats = useProfileStatsByDidQuery(did);
-
const [stats, setStats] = useAtom(statsAtom);
+
const { link, did } = props;
+
const [profiles, setProfiles] = useAtom(profilesAtom);
+
const profile = useProfileByDidQuery(did);
+
const profileStats = useProfileStatsByDidQuery(did);
+
const [stats, setStats] = useAtom(statsAtom);
-
useEffect(() => {
-
if (profile.isLoading || profile.isError) {
-
return;
-
}
+
useEffect(() => {
+
if (profile.isLoading || profile.isError) {
+
return;
+
}
-
if (!profile.data || !did) {
-
return;
-
}
+
if (!profile.data || !did) {
+
return;
+
}
-
setProfiles((profiles) => ({
-
...profiles,
-
[did]: {
-
avatar: profile.data.avatar,
-
displayName: profile.data.displayName,
-
handle: profile.data.handle,
-
spotifyConnected: profile.data.spotifyConnected,
-
createdAt: profile.data.createdAt,
-
did,
-
},
-
}));
+
setProfiles((profiles) => ({
+
...profiles,
+
[did]: {
+
avatar: profile.data.avatar,
+
displayName: profile.data.displayName,
+
handle: profile.data.handle,
+
spotifyConnected: profile.data.spotifyConnected,
+
createdAt: profile.data.createdAt,
+
did,
+
},
+
}));
-
// eslint-disable-next-line react-hooks/exhaustive-deps
-
}, [profile.data, profile.isLoading, profile.isError, did]);
+
// eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [profile.data, profile.isLoading, profile.isError, did]);
-
useEffect(() => {
-
if (profileStats.isLoading || profileStats.isError) {
-
return;
-
}
+
useEffect(() => {
+
if (profileStats.isLoading || profileStats.isError) {
+
return;
+
}
-
if (!profileStats.data || !did) {
-
return;
-
}
+
if (!profileStats.data || !did) {
+
return;
+
}
-
setStats((prev) => ({
-
...prev,
-
[did]: {
-
scrobbles: profileStats.data.scrobbles,
-
artists: profileStats.data.artists,
-
lovedTracks: profileStats.data.lovedTracks,
-
albums: profileStats.data.albums,
-
tracks: profileStats.data.tracks,
-
},
-
}));
-
// eslint-disable-next-line react-hooks/exhaustive-deps
-
}, [profileStats.data, profileStats.isLoading, profileStats.isError, did]);
+
setStats((prev) => ({
+
...prev,
+
[did]: {
+
scrobbles: profileStats.data.scrobbles,
+
artists: profileStats.data.artists,
+
lovedTracks: profileStats.data.lovedTracks,
+
albums: profileStats.data.albums,
+
tracks: profileStats.data.tracks,
+
},
+
}));
+
// eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [profileStats.data, profileStats.isLoading, profileStats.isError, did]);
-
return (
-
<>
-
<StatefulPopover
-
content={() => (
-
<Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]">
-
<div className="flex flex-row items-center">
-
<Link to={link} className="no-underline">
-
<Avatar
-
src={profiles[did]?.avatar}
-
name={profiles[did]?.displayName}
-
size={"60px"}
-
/>
-
</Link>
-
<div className="ml-[16px]">
-
<Link to={link} className="no-underline">
-
<LabelMedium
-
marginTop={"10px"}
-
className="!text-[var(--color-text)]"
-
>
-
{profiles[did]?.displayName}
-
</LabelMedium>
-
</Link>
-
<a
-
href={`https://bsky.app/profile/${profiles[did]?.handle}`}
-
className="no-underline text-[var(--color-primary)]"
-
>
-
<LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]">
-
@{did}
-
</LabelSmall>
-
</a>
-
</div>
-
</div>
+
return (
+
<StatefulPopover
+
content={() => (
+
<Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]">
+
<div className="flex flex-row items-center">
+
<Link to={link} className="no-underline">
+
<Avatar
+
src={profiles[did]?.avatar}
+
name={profiles[did]?.displayName}
+
size={"60px"}
+
/>
+
</Link>
+
<div className="ml-[16px]">
+
<Link to={link} className="no-underline">
+
<LabelMedium
+
marginTop={"10px"}
+
className="!text-[var(--color-text)]"
+
>
+
{profiles[did]?.displayName}
+
</LabelMedium>
+
</Link>
+
<a
+
href={`https://bsky.app/profile/${profiles[did]?.handle}`}
+
className="no-underline text-[var(--color-primary)]"
+
>
+
<LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]">
+
@{did}
+
</LabelSmall>
+
</a>
+
</div>
+
</div>
-
{stats[did] && <Stats stats={stats[did]} mb={1} />}
+
{stats[did] && <Stats stats={stats[did]} mb={1} />}
-
<NowPlaying did={did} />
-
</Block>
-
)}
-
triggerType={TRIGGER_TYPE.hover}
-
autoFocus={false}
-
focusLock={false}
-
>
-
<Link to={link} className="no-underline">
-
<LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[250px]">
-
@{did}
-
</LabelMedium>
-
</Link>
-
</StatefulPopover>
-
</>
-
);
+
<NowPlaying did={did} />
+
</Block>
+
)}
+
triggerType={TRIGGER_TYPE.hover}
+
autoFocus={false}
+
focusLock={false}
+
>
+
<Link to={link} className="no-underline">
+
<LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]">
+
@{did}
+
</LabelMedium>
+
</Link>
+
</StatefulPopover>
+
);
}
export default Handle;
+100 -86
apps/web/src/pages/home/feed/Feed.tsx
···
import styled from "@emotion/styled";
import { Link } from "@tanstack/react-router";
+
import { Avatar } from "baseui/avatar";
import type { BlockProps } from "baseui/block";
import { FlexGrid, FlexGridItem } from "baseui/flex-grid";
import { StatefulTooltip } from "baseui/tooltip";
-
import { HeadingMedium, LabelMedium } from "baseui/typography";
+
import { HeadingMedium, LabelSmall } from "baseui/typography";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import ContentLoader from "react-content-loader";
···
dayjs.extend(relativeTime);
const itemProps: BlockProps = {
-
display: "flex",
-
alignItems: "flex-start",
-
flexDirection: "column",
+
display: "flex",
+
alignItems: "flex-start",
+
flexDirection: "column",
};
const Container = styled.div`
···
`;
function Feed() {
-
const { data, isLoading } = useFeedQuery();
-
return (
-
<Container>
-
<HeadingMedium
-
marginTop={"0px"}
-
marginBottom={"20px"}
-
className="!text-[var(--color-text)]"
-
>
-
Recently played
-
</HeadingMedium>
+
const { data, isLoading } = useFeedQuery();
+
console.log(data);
+
return (
+
<Container>
+
<HeadingMedium
+
marginTop={"0px"}
+
marginBottom={"25px"}
+
className="!text-[var(--color-text)]"
+
>
+
Recently played
+
</HeadingMedium>
-
{isLoading && (
-
<ContentLoader
-
width={800}
-
height={575}
-
viewBox="0 0 800 575"
-
backgroundColor="var(--color-skeleton-background)"
-
foregroundColor="var(--color-skeleton-foreground)"
-
>
-
<rect x="12" y="9" rx="2" ry="2" width="140" height="10" />
-
<rect x="14" y="30" rx="2" ry="2" width="667" height="11" />
-
<rect x="12" y="58" rx="2" ry="2" width="211" height="211" />
-
<rect x="240" y="57" rx="2" ry="2" width="211" height="211" />
-
<rect x="467" y="56" rx="2" ry="2" width="211" height="211" />
-
<rect x="12" y="283" rx="2" ry="2" width="211" height="211" />
-
<rect x="240" y="281" rx="2" ry="2" width="211" height="211" />
-
<rect x="468" y="279" rx="2" ry="2" width="211" height="211" />
-
<circle cx="286" cy="536" r="12" />
-
<circle cx="319" cy="535" r="12" />
-
<circle cx="353" cy="535" r="12" />
-
<rect x="378" y="524" rx="0" ry="0" width="52" height="24" />
-
<rect x="210" y="523" rx="0" ry="0" width="52" height="24" />
-
<circle cx="210" cy="535" r="12" />
-
<circle cx="428" cy="536" r="12" />
-
</ContentLoader>
-
)}
+
{isLoading && (
+
<ContentLoader
+
width={800}
+
height={575}
+
viewBox="0 0 800 575"
+
backgroundColor="var(--color-skeleton-background)"
+
foregroundColor="var(--color-skeleton-foreground)"
+
>
+
<rect x="12" y="9" rx="2" ry="2" width="140" height="10" />
+
<rect x="14" y="30" rx="2" ry="2" width="667" height="11" />
+
<rect x="12" y="58" rx="2" ry="2" width="211" height="211" />
+
<rect x="240" y="57" rx="2" ry="2" width="211" height="211" />
+
<rect x="467" y="56" rx="2" ry="2" width="211" height="211" />
+
<rect x="12" y="283" rx="2" ry="2" width="211" height="211" />
+
<rect x="240" y="281" rx="2" ry="2" width="211" height="211" />
+
<rect x="468" y="279" rx="2" ry="2" width="211" height="211" />
+
<circle cx="286" cy="536" r="12" />
+
<circle cx="319" cy="535" r="12" />
+
<circle cx="353" cy="535" r="12" />
+
<rect x="378" y="524" rx="0" ry="0" width="52" height="24" />
+
<rect x="210" y="523" rx="0" ry="0" width="52" height="24" />
+
<circle cx="210" cy="535" r="12" />
+
<circle cx="428" cy="536" r="12" />
+
</ContentLoader>
+
)}
-
{!isLoading && (
-
<div className="pb-[100px]">
-
<FlexGrid
-
flexGridColumnCount={[1, 2, 3]}
-
flexGridColumnGap="scale800"
-
flexGridRowGap="scale800"
-
>
-
{
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-
data.map((song: any) => (
-
<FlexGridItem {...itemProps} key={song.id}>
-
<Link
-
to="/$did/scrobble/$rkey"
-
params={{
-
did: song.uri?.split("at://")[1]?.split("/")[0] || "",
-
rkey: song.uri?.split("/").pop() || "",
-
}}
-
>
-
<SongCover
-
cover={song.cover}
-
artist={song.artist}
-
title={song.title}
-
/>
-
</Link>
-
<Handle link={`/profile/${song.user}`} did={song.user} />{" "}
-
<LabelMedium className="!text-[var(--color-text-primary)]">
-
recently played this song
-
</LabelMedium>
-
<StatefulTooltip
-
content={dayjs(song.date).format(
-
"MMMM D, YYYY [at] HH:mm A",
-
)}
-
returnFocus
-
autoFocus
-
>
-
<LabelMedium className="!text-[var(--color-text-muted)]">
-
{dayjs(song.date).fromNow()}
-
</LabelMedium>
-
</StatefulTooltip>
-
</FlexGridItem>
-
))
-
}
-
</FlexGrid>
-
</div>
-
)}
-
</Container>
-
);
+
{!isLoading && (
+
<div className="pb-[100px]">
+
<FlexGrid
+
flexGridColumnCount={[1, 2, 3]}
+
flexGridColumnGap="scale800"
+
flexGridRowGap="scale1000"
+
>
+
{
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
data.map((song: any) => (
+
<FlexGridItem {...itemProps} key={song.id}>
+
<Link
+
to="/$did/scrobble/$rkey"
+
params={{
+
did: song.uri?.split("at://")[1]?.split("/")[0] || "",
+
rkey: song.uri?.split("/").pop() || "",
+
}}
+
>
+
<SongCover
+
cover={song.cover}
+
artist={song.artist}
+
title={song.title}
+
/>
+
</Link>
+
<div className="flex">
+
<div className="mr-[8px]">
+
<Avatar
+
src={song.userAvatar}
+
name={song.userDisplayName}
+
size={"20px"}
+
/>
+
</div>
+
<Handle
+
link={`/profile/${song.user}`}
+
did={song.user}
+
/>{" "}
+
</div>
+
<LabelSmall className="!text-[var(--color-text-primary)]">
+
recently played this song
+
</LabelSmall>
+
<StatefulTooltip
+
content={dayjs(song.date).format(
+
"MMMM D, YYYY [at] HH:mm A",
+
)}
+
returnFocus
+
autoFocus
+
>
+
<LabelSmall className="!text-[var(--color-text-muted)]">
+
{dayjs(song.date).fromNow()}
+
</LabelSmall>
+
</StatefulTooltip>
+
</FlexGridItem>
+
))
+
}
+
</FlexGrid>
+
</div>
+
)}
+
</Container>
+
);
}
export default Feed;