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

Configure Feed

Select the types of activity you want to include in your feed.

Add date range filtering and LastDays menu

+420 -42
+42 -6
apps/web/src/api/library.ts
··· 70 70 return response.data.albums; 71 71 }; 72 72 73 - export const getArtists = async (did: string, offset = 0, limit = 30) => { 73 + export const getArtists = async ( 74 + did: string, 75 + offset = 0, 76 + limit = 30, 77 + startDate?: Date, 78 + endDate?: Date, 79 + ) => { 74 80 const response = await client.get("/xrpc/app.rocksky.actor.getActorArtists", { 75 - params: { did, limit, offset }, 81 + params: { 82 + did, 83 + limit, 84 + offset, 85 + startDate: startDate?.toISOString(), 86 + endDate: endDate?.toISOString(), 87 + }, 76 88 }); 77 89 return response.data; 78 90 }; 79 91 80 - export const getAlbums = async (did: string, offset = 0, limit = 12) => { 92 + export const getAlbums = async ( 93 + did: string, 94 + offset = 0, 95 + limit = 12, 96 + startDate?: Date, 97 + endDate?: Date, 98 + ) => { 81 99 const response = await client.get("/xrpc/app.rocksky.actor.getActorAlbums", { 82 - params: { did, limit, offset }, 100 + params: { 101 + did, 102 + limit, 103 + offset, 104 + startDate: startDate?.toISOString(), 105 + endDate: endDate?.toISOString(), 106 + }, 83 107 }); 84 108 return response.data; 85 109 }; 86 110 87 - export const getTracks = async (did: string, offset = 0, limit = 20) => { 111 + export const getTracks = async ( 112 + did: string, 113 + offset = 0, 114 + limit = 20, 115 + startDate?: Date, 116 + endDate?: Date, 117 + ) => { 88 118 const response = await client.get("/xrpc/app.rocksky.actor.getActorSongs", { 89 - params: { did, limit, offset }, 119 + params: { 120 + did, 121 + limit, 122 + offset, 123 + startDate: startDate?.toISOString(), 124 + endDate: endDate?.toISOString(), 125 + }, 90 126 }); 91 127 return response.data; 92 128 };
+137
apps/web/src/components/LastDaysMenu/LastDaysMenu.tsx
··· 1 + import { NestedMenus, StatefulMenu } from "baseui/menu"; 2 + import { StatefulPopover } from "baseui/popover"; 3 + import { 4 + ALL_TIME, 5 + LAST_180_DAYS, 6 + LAST_30_DAYS, 7 + LAST_365_DAYS, 8 + LAST_7_DAYS, 9 + LAST_90_DAYS, 10 + } from "../../consts"; 11 + 12 + export interface LastDaysMenuProps { 13 + children: React.ReactNode; 14 + onSelect: (id: string) => void; 15 + } 16 + 17 + function LastDaysMenu(props: LastDaysMenuProps) { 18 + return ( 19 + <StatefulPopover 20 + autoFocus={false} 21 + placement="bottomRight" 22 + content={({ close }) => ( 23 + <div className="border-[var(--color-border)] w-[200px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 24 + <NestedMenus> 25 + <StatefulMenu 26 + items={[ 27 + { 28 + id: LAST_7_DAYS, 29 + label: "Last 7 days", 30 + }, 31 + { 32 + id: LAST_30_DAYS, 33 + label: "Last 30 days", 34 + }, 35 + { 36 + id: LAST_90_DAYS, 37 + label: "Last 90 days", 38 + }, 39 + { 40 + id: LAST_180_DAYS, 41 + label: "Last 180 days", 42 + }, 43 + { 44 + id: LAST_365_DAYS, 45 + label: "Last 365 days", 46 + }, 47 + { 48 + id: ALL_TIME, 49 + label: "All time", 50 + }, 51 + ]} 52 + onItemSelect={({ item }) => { 53 + props.onSelect(item.id); 54 + close(); 55 + }} 56 + overrides={{ 57 + List: { 58 + style: { 59 + boxShadow: "none", 60 + outline: "none !important", 61 + backgroundColor: "var(--color-background)", 62 + }, 63 + }, 64 + ListItem: { 65 + style: { 66 + backgroundColor: "var(--color-background)", 67 + color: "var(--color-text)", 68 + ":hover": { 69 + backgroundColor: "var(--color-menu-hover)", 70 + }, 71 + }, 72 + }, 73 + Option: { 74 + props: { 75 + getChildMenu: (item: { label: string }) => { 76 + if (item.label === "Add to Playlist") { 77 + return ( 78 + <div className="border-[var(--color-border)] w-[205px] border-[1px] bg-[var(--color-background)] rounded-[6px]"> 79 + <StatefulMenu 80 + items={{ 81 + __ungrouped: [ 82 + { 83 + label: "Create new playlist", 84 + }, 85 + ], 86 + }} 87 + overrides={{ 88 + List: { 89 + style: { 90 + boxShadow: "none", 91 + outline: "none !important", 92 + backgroundColor: "var(--color-background)", 93 + }, 94 + }, 95 + ListItem: { 96 + style: { 97 + backgroundColor: "var(--color-background)", 98 + color: "var(--color-text)", 99 + ":hover": { 100 + backgroundColor: 101 + "var(--color-menu-hover)", 102 + }, 103 + }, 104 + }, 105 + }} 106 + /> 107 + </div> 108 + ); 109 + } 110 + return null; 111 + }, 112 + }, 113 + }, 114 + }} 115 + /> 116 + </NestedMenus> 117 + </div> 118 + )} 119 + overrides={{ 120 + Arrow: { 121 + style: { 122 + backgroundColor: "var(--color-border)", 123 + }, 124 + }, 125 + Inner: { 126 + style: { 127 + backgroundColor: "var(--color-background)", 128 + }, 129 + }, 130 + }} 131 + > 132 + {props.children} 133 + </StatefulPopover> 134 + ); 135 + } 136 + 137 + export default LastDaysMenu;
+3
apps/web/src/components/LastDaysMenu/index.tsx
··· 1 + import LastDaysMenu from "./LastDaysMenu"; 2 + 3 + export default LastDaysMenu;
+16
apps/web/src/consts.ts
··· 18 18 "opus", 19 19 "wma", 20 20 ]; 21 + 22 + export const LAST_7_DAYS = "LAST_7_DAYS"; 23 + export const LAST_30_DAYS = "LAST_30_DAYS"; 24 + export const LAST_90_DAYS = "LAST_90_DAYS"; 25 + export const LAST_180_DAYS = "LAST_180_DAYS"; 26 + export const LAST_365_DAYS = "LAST_365_DAYS"; 27 + export const ALL_TIME = "ALL_TIME"; 28 + 29 + export const LAST_DAYS_LABELS: Record<string, string> = { 30 + [LAST_7_DAYS]: "Last 7 days", 31 + [LAST_30_DAYS]: "Last 30 days", 32 + [LAST_90_DAYS]: "Last 90 days", 33 + [LAST_180_DAYS]: "Last 180 days", 34 + [LAST_365_DAYS]: "Last 365 days", 35 + [ALL_TIME]: "All Time", 36 + };
+27 -9
apps/web/src/hooks/useLibrary.tsx
··· 33 33 enabled: !!uri, 34 34 }); 35 35 36 - export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 36 + export const useArtistsQuery = ( 37 + did: string, 38 + offset = 0, 39 + limit = 30, 40 + startDate?: Date, 41 + endDate?: Date, 42 + ) => 37 43 useQuery({ 38 - queryKey: ["artists", did, offset, limit], 39 - queryFn: () => getArtists(did, offset, limit), 44 + queryKey: ["artists", did, offset, limit, startDate, endDate], 45 + queryFn: () => getArtists(did, offset, limit, startDate, endDate), 40 46 enabled: !!did, 41 47 select: (data) => 42 48 // eslint-disable-next-line @typescript-eslint/no-explicit-any ··· 46 52 })), 47 53 }); 48 54 49 - export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 55 + export const useAlbumsQuery = ( 56 + did: string, 57 + offset = 0, 58 + limit = 12, 59 + startDate?: Date, 60 + endDate?: Date, 61 + ) => 50 62 useQuery({ 51 - queryKey: ["albums", did, offset, limit], 52 - queryFn: () => getAlbums(did, offset, limit), 63 + queryKey: ["albums", did, offset, limit, startDate, endDate], 64 + queryFn: () => getAlbums(did, offset, limit, startDate, endDate), 53 65 enabled: !!did, 54 66 select: (data) => 55 67 // eslint-disable-next-line @typescript-eslint/no-explicit-any ··· 59 71 })), 60 72 }); 61 73 62 - export const useTracksQuery = (did: string, offset = 0, limit = 20) => 74 + export const useTracksQuery = ( 75 + did: string, 76 + offset = 0, 77 + limit = 20, 78 + startDate?: Date, 79 + endDate?: Date, 80 + ) => 63 81 useQuery({ 64 - queryKey: ["tracks", did, offset, limit], 65 - queryFn: () => getTracks(did, offset, limit), 82 + queryKey: ["tracks", did, offset, limit, startDate, endDate], 83 + queryFn: () => getTracks(did, offset, limit, startDate, endDate), 66 84 enabled: !!did, 67 85 select: (data) => 68 86 // eslint-disable-next-line @typescript-eslint/no-explicit-any
+25
apps/web/src/lib/date.ts
··· 1 + import dayjs from "dayjs"; 2 + 3 + export const getLastDays = (days: number): [Date, Date] => { 4 + const start = dayjs().subtract(days, "day").startOf("day").toDate(); 5 + const end = dayjs().endOf("day").toDate(); 6 + return [start, end]; 7 + }; 8 + 9 + export const getLastWeek = (): [Date, Date] => { 10 + const start = dayjs().subtract(1, "week").startOf("week").toDate(); 11 + const end = dayjs().subtract(1, "week").endOf("week").toDate(); 12 + return [start, end]; 13 + }; 14 + 15 + export const getLastMonth = (): [Date, Date] => { 16 + const start = dayjs().subtract(1, "month").startOf("month").toDate(); 17 + const end = dayjs().subtract(1, "month").endOf("month").toDate(); 18 + return [start, end]; 19 + }; 20 + 21 + export const getLastYear = (): [Date, Date] => { 22 + const start = dayjs().subtract(1, "year").startOf("year").toDate(); 23 + const end = dayjs().subtract(1, "year").endOf("year").toDate(); 24 + return [start, end]; 25 + };
+2 -2
apps/web/src/pages/profile/overview/Overview.tsx
··· 50 50 <RecentTracks /> 51 51 </div> 52 52 <div className="mb-20"> 53 - <TopArtists /> 53 + <TopArtists withDateRange /> 54 54 </div> 55 55 <div className="mb-20"> 56 56 <TopAlbums /> 57 57 </div> 58 58 <div className="mb-20"> 59 - <TopTracks /> 59 + <TopTracks withDateRange /> 60 60 </div> 61 61 </> 62 62 );
+2 -1
apps/web/src/pages/profile/overview/recenttracks/RecentTracks.tsx
··· 128 128 </HeadingSmall> 129 129 <a 130 130 href={`/profile/${user?.handle}?tab=0`} 131 - className="no-underline mt-[40px] text-[var(--color-primary)]" 131 + className="no-underline mt-[40px] text-[var(--color-text)] text-[13px] opacity-70 hover:opacity-100" 132 + style={{ fontFamily: "RockfordSansMedium" }} 132 133 > 133 134 See All 134 135 </a>
+55 -10
apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx
··· 4 4 import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; 5 5 import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; 6 6 import { useAtomValue, useSetAtom } from "jotai"; 7 - import { useEffect } from "react"; 7 + import { useEffect, useState } from "react"; 8 8 import { topAlbumsAtom } from "../../../../atoms/topAlbums"; 9 9 import { userAtom } from "../../../../atoms/user"; 10 10 import SongCover from "../../../../components/SongCover"; 11 11 import { useAlbumsQuery } from "../../../../hooks/useLibrary"; 12 + import { IconChevronDown } from "@tabler/icons-react"; 13 + import { getLastDays } from "../../../../lib/date"; 14 + import { 15 + ALL_TIME, 16 + LAST_180_DAYS, 17 + LAST_30_DAYS, 18 + LAST_365_DAYS, 19 + LAST_7_DAYS, 20 + LAST_90_DAYS, 21 + LAST_DAYS_LABELS, 22 + } from "../../../../consts"; 23 + import LastDaysMenu from "../../../../components/LastDaysMenu"; 12 24 13 25 const itemProps: BlockProps = { 14 26 display: "flex", ··· 25 37 `; 26 38 27 39 function TopAlbums() { 40 + const [topAlbumsRange, setTopAlbumsRange] = useState<string>(LAST_7_DAYS); 28 41 const setTopAlbums = useSetAtom(topAlbumsAtom); 29 42 const topAlbums = useAtomValue(topAlbumsAtom); 43 + const [range, setRange] = useState<[Date, Date] | []>(getLastDays(7)); 30 44 const { did } = useParams({ strict: false }); 31 - const albumsResult = useAlbumsQuery(did!); 45 + const albumsResult = useAlbumsQuery(did!, 0, 12, ...range); 32 46 const user = useAtomValue(userAtom); 33 47 34 48 useEffect(() => { ··· 44 58 // eslint-disable-next-line react-hooks/exhaustive-deps 45 59 }, [albumsResult.data, albumsResult.isLoading, albumsResult.isError, did]); 46 60 61 + const onSelectLastDays = (id: string) => { 62 + setTopAlbumsRange(id); 63 + switch (id) { 64 + case LAST_7_DAYS: 65 + setRange(getLastDays(7)); 66 + break; 67 + case LAST_30_DAYS: 68 + setRange(getLastDays(30)); 69 + break; 70 + case LAST_90_DAYS: 71 + setRange(getLastDays(90)); 72 + break; 73 + case LAST_180_DAYS: 74 + setRange(getLastDays(180)); 75 + break; 76 + case LAST_365_DAYS: 77 + setRange(getLastDays(365)); 78 + break; 79 + case ALL_TIME: 80 + setRange([]); 81 + break; 82 + default: 83 + setRange([]); 84 + } 85 + }; 86 + 47 87 return ( 48 88 <> 49 89 <div className="flex flex-row justify-between items-center"> ··· 53 93 > 54 94 Top Albums 55 95 </HeadingSmall> 56 - <a 57 - href={`/profile/${user?.handle}?tab=2`} 58 - className="no-underline text-[var(--color-primary)] mt-[40px]" 59 - > 60 - See All 61 - </a> 96 + <LastDaysMenu onSelect={onSelectLastDays}> 97 + <button className="mt-[40px] mb-[10px] bg-transparent text-[var(--color-text)] border-none cursor-pointer opacity-70 hover:opacity-100"> 98 + <span> 99 + <span>{LAST_DAYS_LABELS[topAlbumsRange]}</span> 100 + </span> 101 + <IconChevronDown 102 + size={16} 103 + className="ml-[4px] h-[18px] mb-[-5px]" 104 + /> 105 + </button> 106 + </LastDaysMenu> 62 107 </div> 63 108 {!topAlbums.length && ( 64 109 <div className="text-[var(--color-text-muted)] text-[14px]"> ··· 73 118 > 74 119 { 75 120 // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 - topAlbums.map((album: any) => ( 77 - <FlexGridItem {...itemProps} key={album.id}> 121 + topAlbums.map((album: any, index: number) => ( 122 + <FlexGridItem {...itemProps} key={index}> 78 123 <Link 79 124 to={`/${album.uri?.split("at://")[1].replace("app.rocksky.", "")}`} 80 125 >
+59 -8
apps/web/src/pages/profile/overview/topartists/TopArtists.tsx
··· 14 14 import { useArtistsQuery } from "../../../../hooks/useLibrary"; 15 15 import { useProfileStatsByDidQuery } from "../../../../hooks/useProfile"; 16 16 import styles from "./styles"; 17 + import { IconChevronDown } from "@tabler/icons-react"; 18 + import { getLastDays } from "../../../../lib/date"; 19 + import LastDaysMenu from "../../../../components/LastDaysMenu"; 20 + import { 21 + LAST_180_DAYS, 22 + LAST_30_DAYS, 23 + LAST_365_DAYS, 24 + LAST_7_DAYS, 25 + LAST_90_DAYS, 26 + LAST_DAYS_LABELS, 27 + } from "../../../../consts"; 17 28 18 29 const Group = styled.div<{ mb?: number }>` 19 30 display: flex; ··· 40 51 offset?: number; 41 52 size?: number; 42 53 showPagination?: boolean; 54 + withDateRange?: boolean; 43 55 } 44 56 45 57 function TopArtists(props: TopArtistsProps) { 46 - const { showTitle = true, size = 30, showPagination } = props; 58 + const { showTitle = true, size = 30, showPagination, withDateRange } = props; 59 + const [topArtistsRange, setTopArtistsRange] = useState<string | undefined>( 60 + withDateRange ? LAST_7_DAYS : undefined, 61 + ); 62 + const [range, setRange] = useState<[Date, Date] | []>( 63 + withDateRange ? getLastDays(7) : [], 64 + ); 47 65 const setTopArtists = useSetAtom(topArtistsAtom); 48 66 const topArtists = useAtomValue(topArtistsAtom); 49 67 const { darkMode } = useAtomValue(themeAtom); 50 68 const { did } = useParams({ strict: false }); 51 69 const profileStats = useProfileStatsByDidQuery(did!); 52 70 const [currentPage, setCurrentPage] = useState(1); 53 - const artistsResult = useArtistsQuery(did!, (currentPage - 1) * size, size); 71 + const artistsResult = useArtistsQuery( 72 + did!, 73 + (currentPage - 1) * size, 74 + size, 75 + ...range, 76 + ); 54 77 const user = useAtomValue(userAtom); 55 78 const pages = useMemo(() => { 56 79 if (!did || !profileStats.data || !props.size) { ··· 59 82 return Math.ceil(profileStats.data.artists / props.size) || 1; 60 83 }, [profileStats.data, did, props.size]); 61 84 85 + const onSelectLastDays = (id: string) => { 86 + setTopArtistsRange(id); 87 + switch (id) { 88 + case LAST_7_DAYS: 89 + setRange(getLastDays(7)); 90 + break; 91 + case LAST_30_DAYS: 92 + setRange(getLastDays(30)); 93 + break; 94 + case LAST_90_DAYS: 95 + setRange(getLastDays(90)); 96 + break; 97 + case LAST_180_DAYS: 98 + setRange(getLastDays(180)); 99 + break; 100 + case LAST_365_DAYS: 101 + setRange(getLastDays(365)); 102 + break; 103 + default: 104 + setRange([]); 105 + } 106 + }; 107 + 62 108 useEffect(() => { 63 109 if (artistsResult.isLoading || artistsResult.isError) { 64 110 return; ··· 84 130 > 85 131 Top Artists 86 132 </HeadingSmall> 87 - <a 88 - href={`/profile/${user?.handle}?tab=1`} 89 - className="no-underline mt-[40px] text-[var(--color-primary)]" 90 - > 91 - See All 92 - </a> 133 + <LastDaysMenu onSelect={onSelectLastDays}> 134 + <button className="mt-[40px] bg-transparent text-[var(--color-text)] border-none cursor-pointer opacity-70 hover:opacity-100"> 135 + {topArtistsRange && ( 136 + <span>{LAST_DAYS_LABELS[topArtistsRange]}</span> 137 + )} 138 + <IconChevronDown 139 + size={16} 140 + className="ml-[4px] h-[18px] mb-[-5px]" 141 + /> 142 + </button> 143 + </LastDaysMenu> 93 144 </div> 94 145 )} 95 146
+52 -6
apps/web/src/pages/profile/overview/toptracks/TopTracks.tsx
··· 13 13 import { useTracksQuery } from "../../../../hooks/useLibrary"; 14 14 import { useProfileStatsByDidQuery } from "../../../../hooks/useProfile"; 15 15 import styles from "./styles"; 16 + import { IconChevronDown } from "@tabler/icons-react"; 17 + import { getLastDays } from "../../../../lib/date"; 18 + import { 19 + LAST_7_DAYS, 20 + LAST_30_DAYS, 21 + LAST_90_DAYS, 22 + LAST_180_DAYS, 23 + LAST_365_DAYS, 24 + LAST_DAYS_LABELS, 25 + } from "../../../../consts"; 26 + import LastDaysMenu from "../../../../components/LastDaysMenu"; 16 27 17 28 type Row = { 18 29 id: string; ··· 51 62 offset?: number; 52 63 size?: number; 53 64 showPagination?: boolean; 65 + withDateRange?: boolean; 54 66 } 55 67 56 68 function TopTracks(props: TopTracksProps) { ··· 60 72 showPagination: false, 61 73 ...props, 62 74 }; 75 + 76 + const [range, setRange] = useState<[Date, Date] | []>( 77 + props.withDateRange ? getLastDays(7) : [], 78 + ); 79 + const [topTracksRange, setTopTracksRange] = useState<string>(LAST_7_DAYS); 63 80 const setTopTracks = useSetAtom(topTracksAtom); 64 81 const topTracks = useAtomValue(topTracksAtom); 65 82 const { darkMode } = useAtomValue(themeAtom); ··· 70 87 did!, 71 88 (currentPage - 1) * props.size!, 72 89 props.size!, 90 + ...range, 73 91 ); 74 92 const user = useAtomValue(userAtom); 75 93 const pages = useMemo(() => { ··· 92 110 // eslint-disable-next-line react-hooks/exhaustive-deps 93 111 }, [tracksResult.data, tracksResult.isLoading, tracksResult.isError, did]); 94 112 113 + const onSelectLastDays = (id: string) => { 114 + setTopTracksRange(id); 115 + switch (id) { 116 + case LAST_7_DAYS: 117 + setRange(getLastDays(7)); 118 + break; 119 + case LAST_30_DAYS: 120 + setRange(getLastDays(30)); 121 + break; 122 + case LAST_90_DAYS: 123 + setRange(getLastDays(90)); 124 + break; 125 + case LAST_180_DAYS: 126 + setRange(getLastDays(180)); 127 + break; 128 + case LAST_365_DAYS: 129 + setRange(getLastDays(365)); 130 + break; 131 + default: 132 + setRange([]); 133 + } 134 + }; 135 + 95 136 const maxScrobbles = topTracks.length > 0 ? topTracks[0].scrobbles || 1 : 0; 96 137 97 138 return ( ··· 104 145 > 105 146 Top Tracks 106 147 </HeadingSmall> 107 - <a 108 - href={`/profile/${user?.handle}?tab=3`} 109 - className="no-underline mt-[40px] text-[var(--color-primary)]" 110 - > 111 - See All 112 - </a> 148 + <LastDaysMenu onSelect={onSelectLastDays}> 149 + <button className="mt-[40px] bg-transparent text-[var(--color-text)] border-none cursor-pointer opacity-70 hover:opacity-100"> 150 + {topTracksRange && ( 151 + <span>{LAST_DAYS_LABELS[topTracksRange]}</span> 152 + )} 153 + <IconChevronDown 154 + size={16} 155 + className="ml-[4px] h-[18px] mb-[-5px]" 156 + /> 157 + </button> 158 + </LastDaysMenu> 113 159 </div> 114 160 )} 115 161