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

Add genre pages and link tags to genres

Add /genre/$id route and Genre page with Artists, Albums, and Tracks
tabs. Introduce use*ByGenre hooks and wire up get*ByGenre API calls.
Add Album and Track types. Replace tag spans with router Links so tags
navigate to genre pages and update related components and styles.

+725 -25
+56
apps/web/src/api/library.ts
··· 1 1 import { client } from "."; 2 + import { Album } from "../types/album"; 2 3 import { Artist } from "../types/artist"; 4 + import { Track } from "../types/track"; 3 5 4 6 export const getSongByUri = async (uri: string) => { 5 7 if (uri.includes("app.rocksky.scrobble")) { ··· 162 164 ); 163 165 return response.data; 164 166 }; 167 + 168 + export const getAlbumsByGenre = async ( 169 + genre: string, 170 + offset = 0, 171 + limit = 20, 172 + ) => { 173 + const response = await client.get<{ albums: Album[] }>( 174 + "/xrpc/app.rocksky.album.getAlbums", 175 + { 176 + params: { 177 + genre, 178 + limit, 179 + offset, 180 + }, 181 + }, 182 + ); 183 + return response.data; 184 + }; 185 + 186 + export const getArtistsByGenre = async ( 187 + genre: string, 188 + offset = 0, 189 + limit = 20, 190 + ) => { 191 + const response = await client.get<{ artists: Artist[] }>( 192 + "/xrpc/app.rocksky.artist.getArtists", 193 + { 194 + params: { 195 + genre, 196 + limit, 197 + offset, 198 + }, 199 + }, 200 + ); 201 + return response.data; 202 + }; 203 + 204 + export const getTracksByGenre = async ( 205 + genre: string, 206 + offset = 0, 207 + limit = 20, 208 + ) => { 209 + const response = await client.get<{ tracks: Track[] }>( 210 + "/xrpc/app.rocksky.song.getSongs", 211 + { 212 + params: { 213 + genre, 214 + limit, 215 + offset, 216 + }, 217 + }, 218 + ); 219 + return response.data; 220 + };
+4 -3
apps/web/src/components/Handle/Handle.tsx
··· 261 261 {tags.length > 0 && ( 262 262 <div className="mt-[5px] flex flex-wrap gap-y-[2px]"> 263 263 {tags.map((genre) => ( 264 - <span 265 - className="mr-[15px] text-[var(--color-genre)] text-[13px] whitespace-nowrap" 264 + <Link 265 + to={`/genre/${genre}` as string} 266 + className="mr-[15px] text-[var(--color-genre)] text-[13px] whitespace-nowrap no-underline" 266 267 style={{ fontFamily: "RockfordSansRegular" }} 267 268 > 268 269 # {genre} 269 - </span> 270 + </Link> 270 271 ))} 271 272 </div> 272 273 )}
+40
apps/web/src/hooks/useLibrary.tsx
··· 2 2 import { 3 3 getAlbum, 4 4 getAlbums, 5 + getAlbumsByGenre, 5 6 getArtist, 6 7 getArtistAlbums, 7 8 getArtistListeners, 8 9 getArtists, 10 + getArtistsByGenre, 9 11 getArtistTracks, 10 12 getLovedTracks, 11 13 getSongByUri, 12 14 getTracks, 15 + getTracksByGenre, 13 16 } from "../api/library"; 14 17 15 18 export const useSongByUriQuery = (uri: string) => ··· 117 120 enabled: !!uri, 118 121 select: (data) => data.listeners, 119 122 }); 123 + 124 + export const useTracksByGenreQuery = (genre: string, offset = 0, limit = 20) => 125 + useQuery({ 126 + queryKey: ["tracks", genre, offset, limit], 127 + queryFn: () => getTracksByGenre(genre, offset, limit), 128 + enabled: !!genre, 129 + select: (data) => 130 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 131 + data?.tracks.map((x: any) => ({ 132 + ...x, 133 + scrobbles: x.playCount, 134 + })), 135 + }); 136 + 137 + export const useAlbumsByGenreQuery = (genre: string, offset = 0, limit = 20) => 138 + useQuery({ 139 + queryKey: ["albums", genre, offset, limit], 140 + queryFn: () => getAlbumsByGenre(genre, offset, limit), 141 + enabled: !!genre, 142 + select: (data) => 143 + data?.albums.map((x) => ({ 144 + ...x, 145 + scrobbles: x.playCount, 146 + })), 147 + }); 148 + 149 + export const useArtistsByGenreQuery = (genre: string, offset = 0, limit = 20) => 150 + useQuery({ 151 + queryKey: ["artists", genre, offset, limit], 152 + queryFn: () => getArtistsByGenre(genre, offset, limit), 153 + enabled: !!genre, 154 + select: (data) => 155 + data?.artists.map((x) => ({ 156 + ...x, 157 + scrobbles: x.playCount, 158 + })), 159 + });
+4 -3
apps/web/src/pages/album/Album.tsx
··· 198 198 </div> 199 199 <div className="mt-[10px]"> 200 200 {(album?.tags || []).map((genre) => ( 201 - <span 202 - className="mr-[15px] text-[var(--color-genre)] text-[13px]" 201 + <Link 202 + to={`/genre/${genre}` as string} 203 + className="mr-[15px] !text-[var(--color-genre)] text-[13px] !no-underline" 203 204 style={{ fontFamily: "RockfordSansRegular" }} 204 205 > 205 206 # {genre} 206 - </span> 207 + </Link> 207 208 ))} 208 209 </div> 209 210 </div>
+5 -4
apps/web/src/pages/artist/Artist.tsx
··· 1 1 import styled from "@emotion/styled"; 2 2 import { ExternalLink } from "@styled-icons/evaicons-solid"; 3 - import { useParams } from "@tanstack/react-router"; 3 + import { Link, useParams } from "@tanstack/react-router"; 4 4 import { Avatar } from "baseui/avatar"; 5 5 import { HeadingMedium, HeadingXSmall, LabelMedium } from "baseui/typography"; 6 6 import { useAtomValue, useSetAtom } from "jotai"; ··· 215 215 {artist && ( 216 216 <div className="mt-[30px]"> 217 217 {(artist?.tags || []).map((genre) => ( 218 - <span 219 - className="mr-[15px] text-[var(--color-genre)] text-[13px]" 218 + <Link 219 + to={`/genre/${genre}` as string} 220 + className="mr-[15px] text-[var(--color-genre)] text-[13px] no-underline" 220 221 style={{ fontFamily: "RockfordSansRegular" }} 221 222 > 222 223 # {genre} 223 - </span> 224 + </Link> 224 225 ))} 225 226 </div> 226 227 )}
+87
apps/web/src/pages/genre/Genre.tsx
··· 1 + import { HeadingMedium } from "baseui/typography"; 2 + import Main from "../../layouts/Main"; 3 + import { Tab, Tabs } from "baseui/tabs-motion"; 4 + import React, { useState } from "react"; 5 + import Artists from "./artists"; 6 + import Albums from "./albums"; 7 + import Tracks from "./tracks"; 8 + import _ from "lodash"; 9 + import { useParams } from "@tanstack/react-router"; 10 + 11 + export default function Genre() { 12 + const { id: genre } = useParams({ strict: false }); 13 + const [activeKey, setActiveKey] = useState<React.Key>("0"); 14 + return ( 15 + <Main> 16 + <div className="mt-[60px]"> 17 + <HeadingMedium 18 + marginTop="0px" 19 + marginBottom={"35px"} 20 + className="!text-[var(--color-text)]" 21 + > 22 + {_.upperFirst(genre)} music 23 + </HeadingMedium> 24 + 25 + <Tabs 26 + activeKey={activeKey} 27 + onChange={({ activeKey }) => { 28 + setActiveKey(activeKey); 29 + }} 30 + overrides={{ 31 + TabHighlight: { 32 + style: { 33 + backgroundColor: "var(--color-purple)", 34 + }, 35 + }, 36 + TabBorder: { 37 + style: { 38 + display: "none", 39 + }, 40 + }, 41 + }} 42 + activateOnFocus 43 + > 44 + <Tab 45 + title="Artists" 46 + overrides={{ 47 + Tab: { 48 + style: { 49 + color: "var(--color-text)", 50 + backgroundColor: "var(--color-background) !important", 51 + }, 52 + }, 53 + }} 54 + > 55 + <Artists /> 56 + </Tab> 57 + <Tab 58 + title="Albums" 59 + overrides={{ 60 + Tab: { 61 + style: { 62 + color: "var(--color-text)", 63 + backgroundColor: "var(--color-background) !important", 64 + }, 65 + }, 66 + }} 67 + > 68 + <Albums /> 69 + </Tab> 70 + <Tab 71 + title="Tracks" 72 + overrides={{ 73 + Tab: { 74 + style: { 75 + color: "var(--color-text)", 76 + backgroundColor: "var(--color-background) !important", 77 + }, 78 + }, 79 + }} 80 + > 81 + <Tracks /> 82 + </Tab> 83 + </Tabs> 84 + </div> 85 + </Main> 86 + ); 87 + }
+77
apps/web/src/pages/genre/albums/Albums.tsx
··· 1 + import { Link, useParams } from "@tanstack/react-router"; 2 + import { useAlbumsByGenreQuery } from "../../../hooks/useLibrary"; 3 + import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; 4 + import { BlockProps } from "baseui/block"; 5 + import numeral from "numeral"; 6 + import dayjs from "dayjs"; 7 + 8 + const itemProps: BlockProps = { 9 + display: "flex", 10 + alignItems: "flex-start", 11 + flexDirection: "column", 12 + }; 13 + 14 + function Albums() { 15 + const { id: genre } = useParams({ strict: false }); 16 + const { data, isLoading } = useAlbumsByGenreQuery(genre!, 0, 20); 17 + return ( 18 + <> 19 + {!isLoading && ( 20 + <FlexGrid 21 + flexGridColumnCount={[1, 2, 3]} 22 + flexGridColumnGap="scale800" 23 + flexGridRowGap="scale1000" 24 + className="mt-[50px]" 25 + > 26 + {data?.map((album) => ( 27 + <FlexGridItem {...itemProps} key={album.id}> 28 + <div className="flex flex-col items-start"> 29 + <Link 30 + to={ 31 + `/${album.uri.split("at://")[1]?.replace("app.rocksky.", "")}` as string 32 + } 33 + className="text-initial" 34 + > 35 + <img 36 + src={album.albumArt} 37 + alt={album.title} 38 + className="w-[230px] h-[230px] mb-[20px]" 39 + key={album.id} 40 + /> 41 + </Link> 42 + <Link 43 + to={ 44 + `/${album.uri.split("at://")[1]?.replace("app.rocksky.", "")}` as string 45 + } 46 + className="!text-[var(--color-text)] no-underline line-clamp-2 text-start max-w-[230px]" 47 + > 48 + <b>{album.title}</b> 49 + </Link> 50 + <Link 51 + to={ 52 + `/${album.artistUri.split("at://")[1]?.replace("app.rocksky.", "")}` as string 53 + } 54 + className="!text-[var(--color-text)] no-underline" 55 + > 56 + <span className="text-[14px] line-clamp-2 text-start max-w-[230px]"> 57 + {album.artist} 58 + </span> 59 + </Link> 60 + <span className="!text-[var(--color-text-muted)] text-[14px] mt-[5px]"> 61 + {numeral(album.playCount).format("0,0")} plays 62 + </span> 63 + <span className="!text-[var(--color-text-muted)] text-[14px]"> 64 + {album.releaseDate 65 + ? dayjs(album.releaseDate).format("MMMM D, YYYY") 66 + : album.year} 67 + </span> 68 + </div> 69 + </FlexGridItem> 70 + ))} 71 + </FlexGrid> 72 + )} 73 + </> 74 + ); 75 + } 76 + 77 + export default Albums;
+3
apps/web/src/pages/genre/albums/index.tsx
··· 1 + import Albums from "./Albums"; 2 + 3 + export default Albums;
+61
apps/web/src/pages/genre/artists/Artists.tsx
··· 1 + import { Link, useParams } from "@tanstack/react-router"; 2 + import { useArtistsByGenreQuery } from "../../../hooks/useLibrary"; 3 + import { FlexGrid, FlexGridItem } from "baseui/flex-grid"; 4 + import { BlockProps } from "baseui/block"; 5 + import numeral from "numeral"; 6 + 7 + const itemProps: BlockProps = { 8 + display: "flex", 9 + alignItems: "flex-start", 10 + flexDirection: "column", 11 + }; 12 + 13 + function Artists() { 14 + const { id: genre } = useParams({ strict: false }); 15 + const { data, isLoading } = useArtistsByGenreQuery(genre!, 0, 20); 16 + return ( 17 + <> 18 + {!isLoading && ( 19 + <FlexGrid 20 + flexGridColumnCount={[1, 2, 3]} 21 + flexGridColumnGap="scale800" 22 + flexGridRowGap="scale1000" 23 + className="mt-[50px]" 24 + > 25 + {data?.map((artist) => ( 26 + <FlexGridItem {...itemProps} key={artist.id}> 27 + <div className="flex flex-col items-center"> 28 + <Link 29 + to={ 30 + `/${artist.uri.split("at://")[1]?.replace("app.rocksky.", "")}` as string 31 + } 32 + className="text-initial" 33 + > 34 + <img 35 + src={artist.picture} 36 + alt={artist.name} 37 + className="w-[200px] h-[200px] rounded-full mb-[20px]" 38 + key={artist.id} 39 + /> 40 + </Link> 41 + <Link 42 + to={ 43 + `/${artist.uri.split("at://")[1]?.replace("app.rocksky.", "")}` as string 44 + } 45 + className="!text-[var(--color-text)] no-underline" 46 + > 47 + <b>{artist.name}</b> 48 + </Link> 49 + <span className="!text-[var(--color-text-muted)] text-[14px]"> 50 + {numeral(artist.playCount).format("0,0")} plays 51 + </span> 52 + </div> 53 + </FlexGridItem> 54 + ))} 55 + </FlexGrid> 56 + )} 57 + </> 58 + ); 59 + } 60 + 61 + export default Artists;
+3
apps/web/src/pages/genre/artists/index.tsx
··· 1 + import Artists from "./Artists"; 2 + 3 + export default Artists;
+3
apps/web/src/pages/genre/index.tsx
··· 1 + import Genre from "./Genre"; 2 + 3 + export default Genre;
+166
apps/web/src/pages/genre/tracks/Tracks.tsx
··· 1 + import { Link, useParams } from "@tanstack/react-router"; 2 + import { useTracksByGenreQuery } from "../../../hooks/useLibrary"; 3 + import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; 4 + import numeral from "numeral"; 5 + 6 + type Row = { 7 + id: string; 8 + title: string; 9 + artist: string; 10 + albumArtist: string; 11 + albumArt: string; 12 + albumUri?: string; 13 + artistUri?: string; 14 + uri: string; 15 + scrobbles: number; 16 + index: number; 17 + }; 18 + 19 + function Tracks() { 20 + const { id: genre } = useParams({ strict: false }); 21 + const { data, isLoading } = useTracksByGenreQuery(genre!, 0, 20); 22 + return ( 23 + <> 24 + {!isLoading && ( 25 + <> 26 + <TableBuilder 27 + data={data?.map((x, index) => ({ 28 + id: x.id, 29 + title: x.title, 30 + artist: x.artist, 31 + albumArtist: x.albumArtist, 32 + albumArt: x.albumArt, 33 + uri: x.uri, 34 + scrobbles: x.scrobbles, 35 + albumUri: x.albumUri, 36 + artistUri: x.artistUri, 37 + index, 38 + }))} 39 + divider="clean" 40 + overrides={{ 41 + TableHeadRow: { 42 + style: { 43 + display: "none", 44 + }, 45 + }, 46 + TableBodyCell: { 47 + style: { 48 + verticalAlign: "center", 49 + }, 50 + }, 51 + TableBodyRow: { 52 + style: { 53 + backgroundColor: "var(--color-background)", 54 + ":hover": { 55 + backgroundColor: "var(--color-menu-hover)", 56 + }, 57 + }, 58 + }, 59 + TableEmptyMessage: { 60 + style: { 61 + backgroundColor: "var(--color-background)", 62 + }, 63 + }, 64 + Table: { 65 + style: { 66 + backgroundColor: "var(--color-background)", 67 + }, 68 + }, 69 + }} 70 + > 71 + <TableBuilderColumn header="Name"> 72 + {(row: Row) => ( 73 + <div className="flex flex-row items-center"> 74 + <div> 75 + <div className="text-[var(--color-text)] mr-[20px]"> 76 + {row.index + 1} 77 + </div> 78 + </div> 79 + {row.albumUri && ( 80 + <Link 81 + to={ 82 + `/${row.albumUri?.split("at://")[1].replace("app.rocksky.", "")}` as string 83 + } 84 + > 85 + {!!row.albumArt && ( 86 + <img 87 + src={row.albumArt} 88 + alt={row.title} 89 + className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 90 + key={row.id} 91 + /> 92 + )} 93 + {!row.albumArt && ( 94 + <div className="w-[60px] h-[60px] rounded-[5px] bg-[rgba(243, 243, 243, 0.725)]" /> 95 + )} 96 + </Link> 97 + )} 98 + {!row.albumUri && ( 99 + <div> 100 + {!!row.albumArt && ( 101 + <img 102 + src={row.albumArt} 103 + alt={row.title} 104 + className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 105 + key={row.id} 106 + /> 107 + )} 108 + {!row.albumArt && ( 109 + <div className="w-[60px] h-[60px] rounded-[5px] bg-[rgba(243, 243, 243, 0.725)]" /> 110 + )} 111 + </div> 112 + )} 113 + <div className="flex flex-col"> 114 + <Link 115 + to={ 116 + `/${row.uri?.split("at://")[1]?.replace("app.rocksky.", "")}` as string 117 + } 118 + className="!text-[var(--color-text)] no-underline" 119 + > 120 + {row.title} 121 + </Link> 122 + {row.artistUri && ( 123 + <Link 124 + to={ 125 + `/${row.artistUri?.split("at://")[1]?.replace("app.rocksky.", "")}` as string 126 + } 127 + className="!text-[var(--color-text-muted)] no-underline" 128 + > 129 + {row.albumArtist} 130 + </Link> 131 + )} 132 + {!row.artistUri && ( 133 + <div className="!text-[var(--color-text-muted)]"> 134 + {row.albumArtist} 135 + </div> 136 + )} 137 + </div> 138 + </div> 139 + )} 140 + </TableBuilderColumn> 141 + <TableBuilderColumn header="Scrobbles"> 142 + {(row: Row, index?: number) => ( 143 + <div className="relative w-[250px] mt-[-20px]"> 144 + <div 145 + className={`absolute w-full top-[10px] left-[10px] z-[1]`} 146 + > 147 + {numeral(row.scrobbles).format("0,0")}{" "} 148 + {index == 0 && " scrobbles"} 149 + </div> 150 + <span 151 + style={{ 152 + backgroundColor: "var(--color-bar)", 153 + }} 154 + className="absolute h-[40px]" 155 + ></span> 156 + </div> 157 + )} 158 + </TableBuilderColumn> 159 + </TableBuilder> 160 + </> 161 + )} 162 + </> 163 + ); 164 + } 165 + 166 + export default Tracks;
+3
apps/web/src/pages/genre/tracks/index.tsx
··· 1 + import Tracks from "./Tracks"; 2 + 3 + export default Tracks;
+135
apps/web/src/pages/genre/tracks/styles.tsx
··· 1 + export default { 2 + pagination: { 3 + Root: { 4 + style: { 5 + justifyContent: "center", 6 + marginTop: "30px", 7 + }, 8 + }, 9 + DropdownContainer: { 10 + style: { 11 + backgroundColor: "var(--color-background)", 12 + }, 13 + }, 14 + Select: { 15 + props: { 16 + overrides: { 17 + Root: { 18 + style: { 19 + backgroundColor: "var(--color-background)", 20 + color: "var(--color-text)", 21 + ":hover": { 22 + backgroundColor: "var(--color-background)", 23 + color: "var(--color-text)", 24 + }, 25 + }, 26 + }, 27 + ControlContainer: { 28 + style: { 29 + outline: "none", 30 + backgroundColor: "var(--color-background)", 31 + color: "var(--color-text) !important", 32 + ":hover": { 33 + backgroundColor: "var(--color-background)", 34 + color: "var(--color-text) !important", 35 + }, 36 + }, 37 + }, 38 + SelectArrow: { 39 + props: { 40 + overrides: { 41 + Svg: { 42 + style: { color: "var(--color-text)" }, 43 + }, 44 + }, 45 + }, 46 + }, 47 + DropdownListItem: { 48 + style: { 49 + backgroundColor: "var(--color-background)", 50 + color: "var(--color-text)", 51 + ":hover": { 52 + backgroundColor: "var(--color-menu-hover)", 53 + color: "var(--color-text)", 54 + }, 55 + }, 56 + }, 57 + SingleValue: { 58 + style: { 59 + color: "var(--color-text)", 60 + }, 61 + }, 62 + Dropdown: { 63 + style: { 64 + outline: "none", 65 + backgroundColor: "var(--color-background)", 66 + }, 67 + }, 68 + DropdownContainer: { 69 + style: { 70 + backgroundColor: "var(--color-background)", 71 + color: "var(--color-text)", 72 + }, 73 + }, 74 + Input: { 75 + style: { 76 + backgroundColor: "var(--color-background)", 77 + color: "var(--color-text)", 78 + ":hover": { 79 + backgroundColor: "var(--color-background) !important", 80 + color: "var(--color-text) !important", 81 + }, 82 + }, 83 + }, 84 + InputContainer: { 85 + style: { 86 + backgroundColor: "var(--color-background)", 87 + ":hover": { 88 + backgroundColor: "var(--color-background)", 89 + color: "var(--color-text)", 90 + }, 91 + }, 92 + }, 93 + Popover: { 94 + style: { 95 + backgroundColor: "var(--color-background)", 96 + color: "var(--color-text)", 97 + }, 98 + }, 99 + OptionContent: { 100 + style: { 101 + backgroundColor: "var(--color-background)", 102 + color: "var(--color-text)", 103 + }, 104 + }, 105 + }, 106 + }, 107 + }, 108 + PrevButton: { 109 + style: { 110 + backgroundColor: "var(--color-background) !important", 111 + color: "var(--color-text) !important", 112 + ":hover": { 113 + backgroundColor: "var(--color-background) !important", 114 + color: "var(--color-text) !important", 115 + }, 116 + }, 117 + }, 118 + NextButton: { 119 + style: { 120 + backgroundColor: "var(--color-background)", 121 + color: "var(--color-text)", 122 + ":hover": { 123 + backgroundColor: "var(--color-background)", 124 + color: "var(--color-text) ", 125 + }, 126 + }, 127 + }, 128 + MaxLabel: { 129 + style: { 130 + backgroundColor: "var(--color-background)", 131 + color: "var(--color-text)", 132 + }, 133 + }, 134 + }, 135 + };
+4 -3
apps/web/src/pages/home/feed/Feed.tsx
··· 206 206 {(song?.tags || []).length > 0 && ( 207 207 <div className="mb-[10px] flex flex-wrap gap-x-[10px] gap-y-[4px]"> 208 208 {(song?.tags || []).map((genre: string) => ( 209 - <span 210 - className="text-[var(--color-genre)] text-[13px]" 209 + <Link 210 + to={`/genre/${genre}` as string} 211 + className="text-[var(--color-genre)] text-[13px] no-underline" 211 212 style={{ fontFamily: "RockfordSansRegular" }} 212 213 > 213 214 # {genre} 214 - </span> 215 + </Link> 215 216 ))} 216 217 </div> 217 218 )}
+5 -3
apps/web/src/pages/profile/Profile.tsx
··· 33 33 import TopTrack from "./toptrack"; 34 34 import { useArtistsQuery } from "../../hooks/useLibrary"; 35 35 import { getLastDays } from "../../lib/date"; 36 + import { Link } from "@tanstack/react-router"; 36 37 37 38 const Group = styled.div` 38 39 display: flex; ··· 306 307 {tags.length > 0 && ( 307 308 <div className="mt-[30px] mb-[35px] flex flex-wrap"> 308 309 {tags.map((genre) => ( 309 - <span 310 - className="mr-[15px] mb-[5px] text-[var(--color-genre)] text-[13px] whitespace-nowrap" 310 + <Link 311 + to={`/genre/${genre}` as string} 312 + className="mr-[15px] mb-[5px] text-[var(--color-genre)] text-[13px] whitespace-nowrap no-underline" 311 313 style={{ fontFamily: "RockfordSansRegular" }} 312 314 > 313 315 # {genre} 314 - </span> 316 + </Link> 315 317 ))} 316 318 </div> 317 319 )}
+6 -6
apps/web/src/pages/profile/overview/topalbums/TopAlbums.tsx
··· 144 144 <Link 145 145 to={`/${album.uri?.split("at://")[1].replace("app.rocksky.", "")}`} 146 146 > 147 - <LabelMedium className="!text-[var(--color-text)]"> 147 + <b className="!text-[var(--color-text)] text-[15px]"> 148 148 {album.title} 149 - </LabelMedium> 149 + </b> 150 150 </Link> 151 151 {album.artistUri && ( 152 152 <Link 153 153 to={`/${album.artistUri.split("at://")[1].replace("app.rocksky.", "")}`} 154 154 > 155 - <LabelSmall className="!text-[var(--color-text-muted)]"> 155 + <span className="!text-[var(--color-text)] text-[14px]"> 156 156 {album.artist} 157 - </LabelSmall> 157 + </span> 158 158 </Link> 159 159 )} 160 160 {!album.artistUri && ( 161 - <LabelSmall className="!text-[var(--color-text-muted)]"> 161 + <span className="!text-[var(--color-text)] text-[14px]"> 162 162 {album.artist} 163 - </LabelSmall> 163 + </span> 164 164 )} 165 165 <LabelSmall className="!text-[var(--color-text-muted)]"> 166 166 {album.scrobbles} plays
+4 -3
apps/web/src/pages/song/Song.tsx
··· 320 320 {(song?.tags || []).length > 0 && ( 321 321 <div> 322 322 {(song?.tags || []).map((genre) => ( 323 - <span 324 - className="mr-[15px] text-[var(--color-genre)] text-[13px]" 323 + <Link 324 + to={`/genre/${genre}` as string} 325 + className="mr-[15px] !text-[var(--color-genre)] text-[13px] !no-underline" 325 326 style={{ fontFamily: "RockfordSansRegular" }} 326 327 > 327 328 # {genre} 328 - </span> 329 + </Link> 329 330 ))} 330 331 </div> 331 332 )}
+21
apps/web/src/routeTree.gen.ts
··· 16 16 import { Route as GoogledriveIndexRouteImport } from './routes/googledrive/index' 17 17 import { Route as DropboxIndexRouteImport } from './routes/dropbox/index' 18 18 import { Route as GoogledriveIdRouteImport } from './routes/googledrive/$id' 19 + import { Route as GenreIdRouteImport } from './routes/genre/$id' 19 20 import { Route as DropboxIdRouteImport } from './routes/dropbox/$id' 20 21 import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index' 21 22 import { Route as ProfileDidTracksRouteImport } from './routes/profile/$did/tracks' ··· 65 66 const GoogledriveIdRoute = GoogledriveIdRouteImport.update({ 66 67 id: '/googledrive/$id', 67 68 path: '/googledrive/$id', 69 + getParentRoute: () => rootRouteImport, 70 + } as any) 71 + const GenreIdRoute = GenreIdRouteImport.update({ 72 + id: '/genre/$id', 73 + path: '/genre/$id', 68 74 getParentRoute: () => rootRouteImport, 69 75 } as any) 70 76 const DropboxIdRoute = DropboxIdRouteImport.update({ ··· 149 155 '/loading': typeof LoadingRoute 150 156 '/scrobble': typeof ScrobbleRoute 151 157 '/dropbox/$id': typeof DropboxIdRoute 158 + '/genre/$id': typeof GenreIdRoute 152 159 '/googledrive/$id': typeof GoogledriveIdRoute 153 160 '/dropbox': typeof DropboxIndexRoute 154 161 '/googledrive': typeof GoogledriveIndexRoute ··· 173 180 '/loading': typeof LoadingRoute 174 181 '/scrobble': typeof ScrobbleRoute 175 182 '/dropbox/$id': typeof DropboxIdRoute 183 + '/genre/$id': typeof GenreIdRoute 176 184 '/googledrive/$id': typeof GoogledriveIdRoute 177 185 '/dropbox': typeof DropboxIndexRoute 178 186 '/googledrive': typeof GoogledriveIndexRoute ··· 198 206 '/loading': typeof LoadingRoute 199 207 '/scrobble': typeof ScrobbleRoute 200 208 '/dropbox/$id': typeof DropboxIdRoute 209 + '/genre/$id': typeof GenreIdRoute 201 210 '/googledrive/$id': typeof GoogledriveIdRoute 202 211 '/dropbox/': typeof DropboxIndexRoute 203 212 '/googledrive/': typeof GoogledriveIndexRoute ··· 224 233 | '/loading' 225 234 | '/scrobble' 226 235 | '/dropbox/$id' 236 + | '/genre/$id' 227 237 | '/googledrive/$id' 228 238 | '/dropbox' 229 239 | '/googledrive' ··· 248 258 | '/loading' 249 259 | '/scrobble' 250 260 | '/dropbox/$id' 261 + | '/genre/$id' 251 262 | '/googledrive/$id' 252 263 | '/dropbox' 253 264 | '/googledrive' ··· 272 283 | '/loading' 273 284 | '/scrobble' 274 285 | '/dropbox/$id' 286 + | '/genre/$id' 275 287 | '/googledrive/$id' 276 288 | '/dropbox/' 277 289 | '/googledrive/' ··· 297 309 LoadingRoute: typeof LoadingRoute 298 310 ScrobbleRoute: typeof ScrobbleRoute 299 311 DropboxIdRoute: typeof DropboxIdRoute 312 + GenreIdRoute: typeof GenreIdRoute 300 313 GoogledriveIdRoute: typeof GoogledriveIdRoute 301 314 DropboxIndexRoute: typeof DropboxIndexRoute 302 315 GoogledriveIndexRoute: typeof GoogledriveIndexRoute ··· 365 378 path: '/googledrive/$id' 366 379 fullPath: '/googledrive/$id' 367 380 preLoaderRoute: typeof GoogledriveIdRouteImport 381 + parentRoute: typeof rootRouteImport 382 + } 383 + '/genre/$id': { 384 + id: '/genre/$id' 385 + path: '/genre/$id' 386 + fullPath: '/genre/$id' 387 + preLoaderRoute: typeof GenreIdRouteImport 368 388 parentRoute: typeof rootRouteImport 369 389 } 370 390 '/dropbox/$id': { ··· 481 501 LoadingRoute: LoadingRoute, 482 502 ScrobbleRoute: ScrobbleRoute, 483 503 DropboxIdRoute: DropboxIdRoute, 504 + GenreIdRoute: GenreIdRoute, 484 505 GoogledriveIdRoute: GoogledriveIdRoute, 485 506 DropboxIndexRoute: DropboxIndexRoute, 486 507 GoogledriveIndexRoute: GoogledriveIndexRoute,
+6
apps/web/src/routes/genre/$id.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import GenrePage from "../../pages/genre"; 3 + 4 + export const Route = createFileRoute("/genre/$id")({ 5 + component: GenrePage, 6 + });
+13
apps/web/src/types/album.ts
··· 1 + export type Album = { 2 + id: string; 3 + uri: string; 4 + title: string; 5 + artist: string; 6 + artistUri: string; 7 + year: number; 8 + albumArt: string; 9 + releaseDate: string; 10 + sha256: string; 11 + playCount: number; 12 + uniqueListeners: number; 13 + };
+19
apps/web/src/types/track.ts
··· 1 + export type Track = { 2 + id: string; 3 + uri: string; 4 + unique_listeners: number; 5 + play_count: number; 6 + title: string; 7 + artist: string; 8 + artist_uri: string; 9 + album: string; 10 + album_uri: string; 11 + album_art: string; 12 + album_artist: string; 13 + copyright_message: string; 14 + disc_number: number; 15 + duration: number; 16 + sha256: string; 17 + track_number: number; 18 + created_at: string; 19 + };