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

Add skeleton loaders for artist and popular songs

Render ContentLoader placeholders during loading states.
Add isLoading prop to PopularSongs and import react-content-loader.

+279 -179
+104 -74
apps/web/src/pages/artist/Artist.tsx
··· 19 19 import Albums from "./Albums"; 20 20 import ArtistListeners from "./ArtistListeners"; 21 21 import PopularSongs from "./PopularSongs"; 22 + import ContentLoader from "react-content-loader"; 22 23 23 24 const Group = styled.div` 24 25 display: flex; ··· 134 135 <Main> 135 136 <div className="pb-[100px] pt-[50px]"> 136 137 <div className="mb-[50px]"> 137 - <Group> 138 - <div className="mr-[20px]"> 139 - {artist?.picture && !loading && ( 140 - <Avatar 141 - name={artist?.name} 142 - src={artist?.picture} 143 - size="150px" 144 - /> 145 - )} 146 - {!artist?.picture && !loading && ( 147 - <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 148 - <div 149 - style={{ 150 - height: 60, 151 - width: 60, 152 - }} 153 - > 154 - <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 155 - </div> 156 - </div> 157 - )} 158 - </div> 159 - {artist && !loading && ( 160 - <div style={{ flex: 1 }}> 161 - <HeadingMedium 162 - marginTop={"20px"} 163 - marginBottom={0} 164 - className="!text-[var(--color-text)]" 165 - > 166 - {artist?.name} 167 - </HeadingMedium> 168 - <div className="mt-[20px] flex flex-row"> 169 - <div className="mr-[20px]"> 170 - <LabelMedium 171 - margin={0} 172 - className="!text-[var(--color-text-muted)]" 173 - > 174 - Listeners 175 - </LabelMedium> 176 - <HeadingXSmall 177 - margin={0} 178 - className="!text-[var(--color-text)]" 179 - > 180 - {numeral(artist?.listeners).format("0,0")} 181 - </HeadingXSmall> 182 - </div> 183 - <div> 184 - <LabelMedium 185 - margin={0} 186 - className="!text-[var(--color-text-muted)]" 187 - > 188 - Scrobbles 189 - </LabelMedium> 190 - <HeadingXSmall 191 - margin={0} 192 - className="!text-[var(--color-text)]" 138 + {loading && ( 139 + <ContentLoader 140 + width="100%" 141 + height={200} 142 + viewBox="0 0 800 200" 143 + backgroundColor="var(--color-skeleton-background)" 144 + foregroundColor="var(--color-skeleton-foreground)" 145 + > 146 + {/* Avatar circle */} 147 + <circle cx="75" cy="75" r="75" /> 148 + {/* Artist name */} 149 + <rect x="180" y="40" rx="4" ry="4" width="300" height="24" /> 150 + {/* Listeners label */} 151 + <rect x="180" y="90" rx="3" ry="3" width="80" height="12" /> 152 + {/* Listeners count */} 153 + <rect x="180" y="110" rx="3" ry="3" width="100" height="20" /> 154 + {/* Scrobbles label */} 155 + <rect x="300" y="90" rx="3" ry="3" width="80" height="12" /> 156 + {/* Scrobbles count */} 157 + <rect x="300" y="110" rx="3" ry="3" width="100" height="20" /> 158 + {/* View on PDSls button */} 159 + <rect x="620" y="100" rx="8" ry="8" width="180" height="48" /> 160 + </ContentLoader> 161 + )} 162 + {!loading && ( 163 + <Group> 164 + <div className="mr-[20px]"> 165 + {artist?.picture && ( 166 + <Avatar 167 + name={artist?.name} 168 + src={artist?.picture} 169 + size="150px" 170 + /> 171 + )} 172 + {!artist?.picture && ( 173 + <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 174 + <div 175 + style={{ 176 + height: 60, 177 + width: 60, 178 + }} 193 179 > 194 - {numeral(artist?.scrobbles).format("0,0")} 195 - </HeadingXSmall> 180 + <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 181 + </div> 196 182 </div> 197 - <div className="flex items-center justify-end flex-1 mr-[10px]"> 198 - <a 199 - href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 200 - target="_blank" 201 - className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 202 - > 203 - <ExternalLink 204 - size={24} 205 - className="mr-[10px] text-[var(--color-text)]" 206 - /> 207 - View on PDSls 208 - </a> 183 + )} 184 + </div> 185 + {artist && ( 186 + <div style={{ flex: 1 }}> 187 + <HeadingMedium 188 + marginTop={"20px"} 189 + marginBottom={0} 190 + className="!text-[var(--color-text)]" 191 + > 192 + {artist?.name} 193 + </HeadingMedium> 194 + <div className="mt-[20px] flex flex-row"> 195 + <div className="mr-[20px]"> 196 + <LabelMedium 197 + margin={0} 198 + className="!text-[var(--color-text-muted)]" 199 + > 200 + Listeners 201 + </LabelMedium> 202 + <HeadingXSmall 203 + margin={0} 204 + className="!text-[var(--color-text)]" 205 + > 206 + {numeral(artist?.listeners).format("0,0")} 207 + </HeadingXSmall> 208 + </div> 209 + <div> 210 + <LabelMedium 211 + margin={0} 212 + className="!text-[var(--color-text-muted)]" 213 + > 214 + Scrobbles 215 + </LabelMedium> 216 + <HeadingXSmall 217 + margin={0} 218 + className="!text-[var(--color-text)]" 219 + > 220 + {numeral(artist?.scrobbles).format("0,0")} 221 + </HeadingXSmall> 222 + </div> 223 + <div className="flex items-center justify-end flex-1 mr-[10px]"> 224 + <a 225 + href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 226 + target="_blank" 227 + className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 228 + > 229 + <ExternalLink 230 + size={24} 231 + className="mr-[10px] text-[var(--color-text)]" 232 + /> 233 + View on PDSls 234 + </a> 235 + </div> 209 236 </div> 210 237 </div> 211 - </div> 212 - )} 213 - </Group> 238 + )} 239 + </Group> 240 + )} 214 241 215 242 {artist && ( 216 243 <div className="mt-[30px]"> ··· 226 253 </div> 227 254 )} 228 255 </div> 229 - <PopularSongs topTracks={topTracks} /> 256 + <PopularSongs 257 + topTracks={topTracks} 258 + isLoading={artistTracksResult.isLoading} 259 + /> 230 260 <Albums topAlbums={topAlbums} /> 231 261 <ArtistListeners listeners={artistListenersResult.data} /> 232 262 <Shout type="artist" />
+175 -105
apps/web/src/pages/artist/PopularSongs/PopularSongs.tsx
··· 2 2 import { Link as DefaultLink } from "@tanstack/react-router"; 3 3 import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; 4 4 import { HeadingSmall } from "baseui/typography"; 5 + import ContentLoader from "react-content-loader"; 5 6 6 7 const Link = styled(DefaultLink)` 7 8 color: inherit; ··· 36 37 albumUri?: string; 37 38 artistUri?: string; 38 39 }[]; 40 + isLoading: boolean; 39 41 } 40 42 41 43 function PopularSongs(props: PopularSongsProps) { 42 44 return ( 43 45 <> 44 - <HeadingSmall marginBottom={"15px"} className="!text-[var(--color-text)]"> 45 - Popular Songs 46 - </HeadingSmall> 47 - <TableBuilder 48 - data={props.topTracks.map((x, index) => ({ 49 - id: x.id, 50 - title: x.title, 51 - artist: x.artist, 52 - albumArtist: x.albumArtist, 53 - albumArt: x.albumArt, 54 - uri: x.uri, 55 - scrobbles: x.scrobbles, 56 - albumUri: x.albumUri, 57 - artistUri: x.artistUri, 58 - index, 59 - }))} 60 - emptyMessage="You haven't listened to any music yet." 61 - divider="clean" 62 - overrides={{ 63 - TableHeadRow: { 64 - style: { 65 - display: "none", 66 - }, 67 - }, 68 - TableBodyCell: { 69 - style: { 70 - verticalAlign: "center", 71 - }, 72 - }, 73 - TableBodyRow: { 74 - style: { 75 - backgroundColor: "var(--color-background)", 76 - ":hover": { 77 - backgroundColor: "var(--color-menu-hover)", 46 + {props.isLoading && ( 47 + <> 48 + <HeadingSmall 49 + marginBottom={"15px"} 50 + className="!text-[var(--color-text)]" 51 + > 52 + Popular Songs 53 + </HeadingSmall> 54 + <div className="ml-[-170px] mt-[20px]"> 55 + <ContentLoader 56 + width="100%" 57 + height={500} 58 + viewBox="0 0 600 500" 59 + backgroundColor="var(--color-skeleton-background)" 60 + foregroundColor="var(--color-skeleton-foreground)" 61 + > 62 + {/* Row 1 */} 63 + <rect x="0" y="10" rx="3" ry="3" width="30" height="15" /> 64 + <rect x="50" y="5" rx="4" ry="4" width="60" height="60" /> 65 + <rect x="130" y="15" rx="3" ry="3" width="200" height="15" /> 66 + <rect x="130" y="40" rx="3" ry="3" width="150" height="12" /> 67 + <rect x="500" y="20" rx="3" ry="3" width="60" height="15" /> 68 + 69 + {/* Row 2 */} 70 + <rect x="0" y="90" rx="3" ry="3" width="30" height="15" /> 71 + <rect x="50" y="85" rx="4" ry="4" width="60" height="60" /> 72 + <rect x="130" y="95" rx="3" ry="3" width="200" height="15" /> 73 + <rect x="130" y="120" rx="3" ry="3" width="150" height="12" /> 74 + <rect x="500" y="100" rx="3" ry="3" width="60" height="15" /> 75 + 76 + {/* Row 3 */} 77 + <rect x="0" y="170" rx="3" ry="3" width="30" height="15" /> 78 + <rect x="50" y="165" rx="4" ry="4" width="60" height="60" /> 79 + <rect x="130" y="175" rx="3" ry="3" width="200" height="15" /> 80 + <rect x="130" y="200" rx="3" ry="3" width="150" height="12" /> 81 + <rect x="500" y="180" rx="3" ry="3" width="60" height="15" /> 82 + 83 + {/* Row 4 */} 84 + <rect x="0" y="250" rx="3" ry="3" width="30" height="15" /> 85 + <rect x="50" y="245" rx="4" ry="4" width="60" height="60" /> 86 + <rect x="130" y="255" rx="3" ry="3" width="200" height="15" /> 87 + <rect x="130" y="280" rx="3" ry="3" width="150" height="12" /> 88 + <rect x="500" y="260" rx="3" ry="3" width="60" height="15" /> 89 + 90 + {/* Row 5 */} 91 + <rect x="0" y="330" rx="3" ry="3" width="30" height="15" /> 92 + <rect x="50" y="325" rx="4" ry="4" width="60" height="60" /> 93 + <rect x="130" y="335" rx="3" ry="3" width="200" height="15" /> 94 + <rect x="130" y="360" rx="3" ry="3" width="150" height="12" /> 95 + <rect x="500" y="340" rx="3" ry="3" width="60" height="15" /> 96 + 97 + {/* Row 6 */} 98 + <rect x="0" y="410" rx="3" ry="3" width="30" height="15" /> 99 + <rect x="50" y="405" rx="4" ry="4" width="60" height="60" /> 100 + <rect x="130" y="415" rx="3" ry="3" width="200" height="15" /> 101 + <rect x="130" y="440" rx="3" ry="3" width="150" height="12" /> 102 + <rect x="500" y="420" rx="3" ry="3" width="60" height="15" /> 103 + </ContentLoader> 104 + </div> 105 + </> 106 + )} 107 + {!props.isLoading && ( 108 + <> 109 + <HeadingSmall 110 + marginBottom={"15px"} 111 + className="!text-[var(--color-text)]" 112 + > 113 + Popular Songs 114 + </HeadingSmall> 115 + <TableBuilder 116 + data={props.topTracks.map((x, index) => ({ 117 + id: x.id, 118 + title: x.title, 119 + artist: x.artist, 120 + albumArtist: x.albumArtist, 121 + albumArt: x.albumArt, 122 + uri: x.uri, 123 + scrobbles: x.scrobbles, 124 + albumUri: x.albumUri, 125 + artistUri: x.artistUri, 126 + index, 127 + }))} 128 + emptyMessage="You haven't listened to any music yet." 129 + divider="clean" 130 + overrides={{ 131 + TableHeadRow: { 132 + style: { 133 + display: "none", 134 + }, 78 135 }, 79 - }, 80 - }, 81 - Table: { 82 - style: { 83 - backgroundColor: "var(--color-background)", 84 - }, 85 - }, 86 - }} 87 - > 88 - <TableBuilderColumn header="Name"> 89 - {(row: Row) => ( 90 - <div className="flex flex-row items-center"> 91 - <div> 92 - <div className="mr-[20px] text-[var(--color-text)]"> 93 - {row.index + 1} 94 - </div> 95 - </div> 96 - {row.albumUri && ( 97 - <Link 98 - to={`/${row.albumUri?.split("at://")[1].replace("app.rocksky.", "")}`} 99 - > 100 - {!!row.albumArt && ( 101 - <img 102 - src={row.albumArt} 103 - alt={row.title} 104 - className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 105 - /> 106 - )} 107 - {!row.albumArt && ( 108 - <div className="w-[60px] h-[60px] rounded-[5px] mr-[20px] bg-[rgba(243, 243, 243, 0.725)]" /> 109 - )} 110 - </Link> 111 - )} 112 - {!row.albumUri && ( 113 - <div> 114 - {!!row.albumArt && ( 115 - <img 116 - src={row.albumArt} 117 - alt={row.title} 118 - className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 119 - /> 136 + TableBodyCell: { 137 + style: { 138 + verticalAlign: "center", 139 + }, 140 + }, 141 + TableBodyRow: { 142 + style: { 143 + backgroundColor: "var(--color-background)", 144 + ":hover": { 145 + backgroundColor: "var(--color-menu-hover)", 146 + }, 147 + }, 148 + }, 149 + Table: { 150 + style: { 151 + backgroundColor: "var(--color-background)", 152 + }, 153 + }, 154 + }} 155 + > 156 + <TableBuilderColumn header="Name"> 157 + {(row: Row) => ( 158 + <div className="flex flex-row items-center"> 159 + <div> 160 + <div className="mr-[20px] text-[var(--color-text)]"> 161 + {row.index + 1} 162 + </div> 163 + </div> 164 + {row.albumUri && ( 165 + <Link 166 + to={`/${row.albumUri?.split("at://")[1].replace("app.rocksky.", "")}`} 167 + > 168 + {!!row.albumArt && ( 169 + <img 170 + src={row.albumArt} 171 + alt={row.title} 172 + className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 173 + /> 174 + )} 175 + {!row.albumArt && ( 176 + <div className="w-[60px] h-[60px] rounded-[5px] mr-[20px] bg-[rgba(243, 243, 243, 0.725)]" /> 177 + )} 178 + </Link> 120 179 )} 121 - {!row.albumArt && ( 122 - <div className="w-[60px] h-[60px] rounded-[5px] mr-[20px] bg-[rgba(243, 243, 243, 0.725)]" /> 180 + {!row.albumUri && ( 181 + <div> 182 + {!!row.albumArt && ( 183 + <img 184 + src={row.albumArt} 185 + alt={row.title} 186 + className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 187 + /> 188 + )} 189 + {!row.albumArt && ( 190 + <div className="w-[60px] h-[60px] rounded-[5px] mr-[20px] bg-[rgba(243, 243, 243, 0.725)]" /> 191 + )} 192 + </div> 123 193 )} 194 + <div className="flex flex-col"> 195 + <Link 196 + to={`/${row.uri?.split("at://")[1].replace("app.rocksky.", "")}`} 197 + className="!text-[var(--color-text)]" 198 + > 199 + {row.title} 200 + </Link> 201 + {row.artistUri && ( 202 + <Link 203 + to={`/${row.artistUri?.split("at://")[1].replace("app.rocksky.", "")}`} 204 + className="!text-[var(--color-text-muted)]" 205 + > 206 + {row.albumArtist} 207 + </Link> 208 + )} 209 + {!row.artistUri && ( 210 + <div className="!text-[var(--color-text-muted)]"> 211 + {row.albumArtist} 212 + </div> 213 + )} 214 + </div> 124 215 </div> 125 216 )} 126 - <div className="flex flex-col"> 127 - <Link 128 - to={`/${row.uri?.split("at://")[1].replace("app.rocksky.", "")}`} 129 - className="!text-[var(--color-text)]" 130 - > 131 - {row.title} 132 - </Link> 133 - {row.artistUri && ( 134 - <Link 135 - to={`/${row.artistUri?.split("at://")[1].replace("app.rocksky.", "")}`} 136 - className="!text-[var(--color-text-muted)]" 137 - > 138 - {row.albumArtist} 139 - </Link> 140 - )} 141 - {!row.artistUri && ( 142 - <div className="!text-[var(--color-text-muted)]"> 143 - {row.albumArtist} 144 - </div> 145 - )} 146 - </div> 147 - </div> 148 - )} 149 - </TableBuilderColumn> 150 - <TableBuilderColumn header="Scrobbles"> 151 - {(row: Row) => <div>{row.scrobbles}</div>} 152 - </TableBuilderColumn> 153 - </TableBuilder> 217 + </TableBuilderColumn> 218 + <TableBuilderColumn header="Scrobbles"> 219 + {(row: Row) => <div>{row.scrobbles}</div>} 220 + </TableBuilderColumn> 221 + </TableBuilder> 222 + </> 223 + )} 154 224 </> 155 225 ); 156 226 }