Demo using Slices Network GraphQL Relay API to make a teal.fm client

use routes for tabs, fix album art

+37
src/AlbumArt.tsx
··· 1 + import { useState } from "react"; 2 + 3 + interface AlbumArtProps { 4 + releaseMbId: string | null | undefined; 5 + alt: string; 6 + } 7 + 8 + export default function AlbumArt({ releaseMbId, alt }: AlbumArtProps) { 9 + const [hasError, setHasError] = useState(false); 10 + const [isLoading, setIsLoading] = useState(true); 11 + 12 + if (!releaseMbId || hasError) { 13 + return ( 14 + <div className="w-10 h-10 bg-zinc-800 flex items-center justify-center"> 15 + <svg className="w-5 h-5 text-zinc-600" fill="currentColor" viewBox="0 0 20 20"> 16 + <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 17 + </svg> 18 + </div> 19 + ); 20 + } 21 + 22 + return ( 23 + <> 24 + {isLoading && <div className="w-10 h-10 bg-zinc-800 animate-pulse" />} 25 + <img 26 + src={`https://coverartarchive.org/release/${releaseMbId}/front-250`} 27 + alt={alt} 28 + className={`w-10 h-10 object-cover ${isLoading ? 'hidden' : ''}`} 29 + onLoad={() => setIsLoading(false)} 30 + onError={() => { 31 + setIsLoading(false); 32 + setHasError(true); 33 + }} 34 + /> 35 + </> 36 + ); 37 + }
+2 -22
src/AlbumItem.tsx
··· 1 - import { useAlbumArt } from "./useAlbumArt"; 1 + import AlbumArt from "./AlbumArt"; 2 2 3 3 interface Artist { 4 4 artistName: string; ··· 21 21 rank, 22 22 maxCount, 23 23 }: AlbumItemProps) { 24 - const { albumArtUrl, isLoading } = useAlbumArt(releaseMbId); 25 24 const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0; 26 25 27 26 // Parse artists JSON ··· 54 53 </div> 55 54 56 55 <div className="flex-shrink-0"> 57 - {isLoading ? ( 58 - <div className="w-10 h-10 bg-zinc-800 animate-pulse" /> 59 - ) : albumArtUrl ? ( 60 - <img 61 - src={albumArtUrl} 62 - alt={`${releaseName} album art`} 63 - className="w-10 h-10 object-cover" 64 - loading="lazy" 65 - /> 66 - ) : ( 67 - <div className="w-10 h-10 bg-zinc-800 flex items-center justify-center"> 68 - <svg 69 - className="w-5 h-5 text-zinc-600" 70 - fill="currentColor" 71 - viewBox="0 0 20 20" 72 - > 73 - <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 74 - </svg> 75 - </div> 76 - )} 56 + <AlbumArt releaseMbId={releaseMbId} alt={`${releaseName} album art`} /> 77 57 </div> 78 58 79 59 <div className="flex-1 min-w-0">
+32 -84
src/App.tsx
··· 1 1 import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay"; 2 - import { useEffect, useRef, useState } from "react"; 2 + import { useEffect, useRef } from "react"; 3 3 import type { AppQuery } from "./__generated__/AppQuery.graphql"; 4 4 import type { App_plays$key } from "./__generated__/App_plays.graphql"; 5 5 import TrackItem from "./TrackItem"; 6 - import TopAlbums from "./TopAlbums"; 7 - import TopTracks from "./TopTracks"; 6 + import Layout from "./Layout"; 8 7 9 8 export default function App() { 10 - const [activeTab, setActiveTab] = useState<"recent" | "tracks" | "albums">("recent"); 11 9 const queryData = useLazyLoadQuery<AppQuery>( 12 10 graphql` 13 11 query AppQuery { ··· 58 56 .filter((n) => n != null) || []; 59 57 60 58 useEffect(() => { 61 - if (!loadMoreRef.current || !hasNext || activeTab !== "recent") return; 59 + if (!loadMoreRef.current || !hasNext) return; 62 60 63 61 const observer = new IntersectionObserver( 64 62 (entries) => { ··· 72 70 observer.observe(loadMoreRef.current); 73 71 74 72 return () => observer.disconnect(); 75 - }, [hasNext, isLoadingNext, loadNext, activeTab]); 73 + }, [hasNext, isLoadingNext, loadNext]); 76 74 77 75 // Group plays by date 78 76 const groupedPlays: { date: string; plays: typeof plays }[] = []; ··· 97 95 }); 98 96 99 97 return ( 100 - <div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono"> 101 - <div className="max-w-4xl mx-auto px-6 py-12"> 102 - <div className="mb-12 flex items-end justify-between border-b border-zinc-800 pb-6"> 103 - <div> 104 - <h1 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Listening History</h1> 105 - <p className="text-xs text-zinc-600 mt-1">fm.teal.alpha.feed.play</p> 106 - </div> 107 - 108 - <div className="flex gap-4 text-xs"> 109 - <button 110 - onClick={() => setActiveTab("recent")} 111 - className={`px-2 py-1 transition-colors ${ 112 - activeTab === "recent" 113 - ? "text-zinc-400" 114 - : "text-zinc-500 hover:text-zinc-300" 115 - }`} 116 - > 117 - Recent 118 - </button> 119 - <button 120 - onClick={() => setActiveTab("tracks")} 121 - className={`px-2 py-1 transition-colors ${ 122 - activeTab === "tracks" 123 - ? "text-zinc-400" 124 - : "text-zinc-500 hover:text-zinc-300" 125 - }`} 126 - > 127 - Top Tracks 128 - </button> 129 - <button 130 - onClick={() => setActiveTab("albums")} 131 - className={`px-2 py-1 transition-colors ${ 132 - activeTab === "albums" 133 - ? "text-zinc-400" 134 - : "text-zinc-500 hover:text-zinc-300" 135 - }`} 136 - > 137 - Top Albums 138 - </button> 139 - </div> 140 - </div> 141 - 142 - {activeTab === "recent" ? ( 143 - <> 144 - <div className="mb-8"> 145 - <p className="text-xs text-zinc-500 uppercase tracking-wider"> 146 - {data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles 147 - </p> 148 - </div> 98 + <Layout> 99 + <div className="mb-8"> 100 + <p className="text-xs text-zinc-500 uppercase tracking-wider"> 101 + {data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles 102 + </p> 103 + </div> 149 104 150 - <div> 151 - {groupedPlays.map((group, groupIndex) => ( 152 - <div key={groupIndex} className="mb-12"> 153 - <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider"> 154 - {group.date} 155 - </h2> 156 - <div className="space-y-1"> 157 - {group.plays.map((play, index) => ( 158 - <TrackItem key={index} play={play} /> 159 - ))} 160 - </div> 161 - </div> 105 + <div> 106 + {groupedPlays.map((group, groupIndex) => ( 107 + <div key={groupIndex} className="mb-12"> 108 + <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider"> 109 + {group.date} 110 + </h2> 111 + <div className="space-y-1"> 112 + {group.plays.map((play, index) => ( 113 + <TrackItem key={index} play={play} /> 162 114 ))} 163 115 </div> 164 - 165 - {hasNext && ( 166 - <div ref={loadMoreRef} className="py-12 text-center"> 167 - {isLoadingNext ? ( 168 - <p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p> 169 - ) : ( 170 - <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p> 171 - )} 172 - </div> 173 - )} 174 - </> 175 - ) : activeTab === "tracks" ? ( 176 - <TopTracks /> 177 - ) : ( 178 - <TopAlbums /> 179 - )} 116 + </div> 117 + ))} 180 118 </div> 181 - </div> 119 + 120 + {hasNext && ( 121 + <div ref={loadMoreRef} className="py-12 text-center"> 122 + {isLoadingNext ? ( 123 + <p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p> 124 + ) : ( 125 + <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p> 126 + )} 127 + </div> 128 + )} 129 + </Layout> 182 130 ); 183 131 }
+57
src/Layout.tsx
··· 1 + import { Link, useLocation } from "react-router-dom"; 2 + 3 + interface LayoutProps { 4 + children: React.ReactNode; 5 + } 6 + 7 + export default function Layout({ children }: LayoutProps) { 8 + const location = useLocation(); 9 + 10 + return ( 11 + <div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono"> 12 + <div className="max-w-4xl mx-auto px-6 py-12"> 13 + <div className="mb-12 flex items-end justify-between border-b border-zinc-800 pb-6"> 14 + <div> 15 + <h1 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Listening History</h1> 16 + <p className="text-xs text-zinc-600 mt-1">fm.teal.alpha.feed.play</p> 17 + </div> 18 + 19 + <div className="flex gap-4 text-xs"> 20 + <Link 21 + to="/" 22 + className={`px-2 py-1 transition-colors ${ 23 + location.pathname === "/" 24 + ? "text-zinc-400" 25 + : "text-zinc-500 hover:text-zinc-300" 26 + }`} 27 + > 28 + Recent 29 + </Link> 30 + <Link 31 + to="/tracks" 32 + className={`px-2 py-1 transition-colors ${ 33 + location.pathname === "/tracks" 34 + ? "text-zinc-400" 35 + : "text-zinc-500 hover:text-zinc-300" 36 + }`} 37 + > 38 + Top Tracks 39 + </Link> 40 + <Link 41 + to="/albums" 42 + className={`px-2 py-1 transition-colors ${ 43 + location.pathname === "/albums" 44 + ? "text-zinc-400" 45 + : "text-zinc-500 hover:text-zinc-300" 46 + }`} 47 + > 48 + Top Albums 49 + </Link> 50 + </div> 51 + </div> 52 + 53 + {children} 54 + </div> 55 + </div> 56 + ); 57 + }
+16 -13
src/TopAlbums.tsx
··· 1 1 import { graphql, useLazyLoadQuery } from "react-relay"; 2 2 import type { TopAlbumsQuery } from "./__generated__/TopAlbumsQuery.graphql"; 3 3 import AlbumItem from "./AlbumItem"; 4 + import Layout from "./Layout"; 4 5 5 6 export default function TopAlbums() { 6 7 const data = useLazyLoadQuery<TopAlbumsQuery>( ··· 48 49 const maxCount = dedupedAlbums.length > 0 ? dedupedAlbums[0].count : 0; 49 50 50 51 return ( 51 - <div className="space-y-1"> 52 - {dedupedAlbums.map((album, index) => ( 53 - <AlbumItem 54 - key={album.releaseMbId || index} 55 - releaseName={album.releaseName || "Unknown Album"} 56 - releaseMbId={album.releaseMbId} 57 - artists={album.artists} 58 - count={album.count} 59 - rank={index + 1} 60 - maxCount={maxCount} 61 - /> 62 - ))} 63 - </div> 52 + <Layout> 53 + <div className="space-y-1"> 54 + {dedupedAlbums.map((album, index) => ( 55 + <AlbumItem 56 + key={album.releaseMbId || index} 57 + releaseName={album.releaseName || "Unknown Album"} 58 + releaseMbId={album.releaseMbId} 59 + artists={album.artists} 60 + count={album.count} 61 + rank={index + 1} 62 + maxCount={maxCount} 63 + /> 64 + ))} 65 + </div> 66 + </Layout> 64 67 ); 65 68 }
+2 -22
src/TopTrackItem.tsx
··· 1 - import { useAlbumArt } from "./useAlbumArt"; 1 + import AlbumArt from "./AlbumArt"; 2 2 3 3 interface Artist { 4 4 artistName: string; ··· 21 21 rank, 22 22 maxCount, 23 23 }: TopTrackItemProps) { 24 - const { albumArtUrl, isLoading } = useAlbumArt(releaseMbId); 25 24 const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0; 26 25 27 26 // Parse artists JSON ··· 51 50 </div> 52 51 53 52 <div className="flex-shrink-0"> 54 - {isLoading ? ( 55 - <div className="w-10 h-10 bg-zinc-800 animate-pulse" /> 56 - ) : albumArtUrl ? ( 57 - <img 58 - src={albumArtUrl} 59 - alt={`${trackName} album art`} 60 - className="w-10 h-10 object-cover" 61 - loading="lazy" 62 - /> 63 - ) : ( 64 - <div className="w-10 h-10 bg-zinc-800 flex items-center justify-center"> 65 - <svg 66 - className="w-5 h-5 text-zinc-600" 67 - fill="currentColor" 68 - viewBox="0 0 20 20" 69 - > 70 - <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 71 - </svg> 72 - </div> 73 - )} 53 + <AlbumArt releaseMbId={releaseMbId} alt={`${trackName} album art`} /> 74 54 </div> 75 55 76 56 <div className="flex-1 min-w-0">
+16 -13
src/TopTracks.tsx
··· 1 1 import { graphql, useLazyLoadQuery } from "react-relay"; 2 2 import type { TopTracksQuery } from "./__generated__/TopTracksQuery.graphql"; 3 3 import TopTrackItem from "./TopTrackItem"; 4 + import Layout from "./Layout"; 4 5 5 6 export default function TopTracks() { 6 7 const data = useLazyLoadQuery<TopTracksQuery>( ··· 25 26 const maxCount = tracks.length > 0 ? tracks[0].count : 0; 26 27 27 28 return ( 28 - <div className="space-y-1"> 29 - {tracks.map((track, index) => ( 30 - <TopTrackItem 31 - key={`${track.trackName}-${index}`} 32 - trackName={track.trackName || "Unknown Track"} 33 - releaseMbId={track.releaseMbId} 34 - artists={track.artists || "Unknown Artist"} 35 - count={track.count} 36 - rank={index + 1} 37 - maxCount={maxCount} 38 - /> 39 - ))} 40 - </div> 29 + <Layout> 30 + <div className="space-y-1"> 31 + {tracks.map((track, index) => ( 32 + <TopTrackItem 33 + key={`${track.trackName}-${index}`} 34 + trackName={track.trackName || "Unknown Track"} 35 + releaseMbId={track.releaseMbId} 36 + artists={track.artists || "Unknown Artist"} 37 + count={track.count} 38 + rank={index + 1} 39 + maxCount={maxCount} 40 + /> 41 + ))} 42 + </div> 43 + </Layout> 41 44 ); 42 45 }
+2 -19
src/TrackItem.tsx
··· 1 1 import { graphql, useFragment } from "react-relay"; 2 2 import type { TrackItem_play$key } from "./__generated__/TrackItem_play.graphql"; 3 - import { useAlbumArt } from "./useAlbumArt"; 3 + import AlbumArt from "./AlbumArt"; 4 4 5 5 interface TrackItemProps { 6 6 play: TrackItem_play$key; ··· 24 24 play 25 25 ); 26 26 27 - const { albumArtUrl, isLoading } = useAlbumArt(data.releaseMbId); 28 - 29 27 return ( 30 28 <div className="group py-3 px-4 hover:bg-zinc-900/50 transition-colors"> 31 29 <div className="flex items-center gap-4"> 32 30 <div className="flex-shrink-0"> 33 - {isLoading ? ( 34 - <div className="w-10 h-10 bg-zinc-800 animate-pulse" /> 35 - ) : albumArtUrl ? ( 36 - <img 37 - src={albumArtUrl} 38 - alt={`${data.trackName} album art`} 39 - className="w-10 h-10 object-cover" 40 - loading="lazy" 41 - /> 42 - ) : ( 43 - <div className="w-10 h-10 bg-zinc-800 flex items-center justify-center"> 44 - <svg className="w-5 h-5 text-zinc-600" fill="currentColor" viewBox="0 0 20 20"> 45 - <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 46 - </svg> 47 - </div> 48 - )} 31 + <AlbumArt releaseMbId={data.releaseMbId} alt={`${data.trackName} album art`} /> 49 32 </div> 50 33 51 34 <div className="flex-1 min-w-0 grid grid-cols-2 gap-4">
-1
src/albumArtCache.ts
··· 1 - export const albumArtCache = new Map<string, string | null>();
+4
src/main.tsx
··· 4 4 import "./index.css"; 5 5 import App from "./App.tsx"; 6 6 import Profile from "./Profile.tsx"; 7 + import TopTracks from "./TopTracks.tsx"; 8 + import TopAlbums from "./TopAlbums.tsx"; 7 9 import LoadingFallback from "./LoadingFallback.tsx"; 8 10 import { RelayEnvironmentProvider } from "react-relay"; 9 11 import { Environment, Network, type FetchFunction } from "relay-runtime"; ··· 34 36 <Suspense fallback={<LoadingFallback />}> 35 37 <Routes> 36 38 <Route path="/" element={<App />} /> 39 + <Route path="/tracks" element={<TopTracks />} /> 40 + <Route path="/albums" element={<TopAlbums />} /> 37 41 <Route path="/profile/:handle" element={<Profile />} /> 38 42 </Routes> 39 43 </Suspense>
-48
src/useAlbumArt.ts
··· 1 - import { useState, useEffect } from "react"; 2 - import { albumArtCache } from "./albumArtCache"; 3 - 4 - export function useAlbumArt(releaseMbId: string | null | undefined) { 5 - const [albumArtUrl, setAlbumArtUrl] = useState<string | null>(null); 6 - const [isLoading, setIsLoading] = useState(false); 7 - 8 - useEffect(() => { 9 - if (!releaseMbId) return; 10 - 11 - // Check cache first 12 - if (albumArtCache.has(releaseMbId)) { 13 - setAlbumArtUrl(albumArtCache.get(releaseMbId) || null); 14 - setIsLoading(false); 15 - return; 16 - } 17 - 18 - setIsLoading(true); 19 - 20 - const fetchAlbumArt = async () => { 21 - try { 22 - // Fetch cover art from Cover Art Archive 23 - const coverArtUrl = `https://coverartarchive.org/release/${releaseMbId}/front-250`; 24 - 25 - // Check if cover art exists 26 - const coverArtResponse = await fetch(coverArtUrl, { method: "HEAD" }); 27 - 28 - if (coverArtResponse.ok) { 29 - setAlbumArtUrl(coverArtUrl); 30 - albumArtCache.set(releaseMbId, coverArtUrl); 31 - } else { 32 - setAlbumArtUrl(null); 33 - albumArtCache.set(releaseMbId, null); 34 - } 35 - } catch (error) { 36 - console.error("Error fetching album art:", error); 37 - setAlbumArtUrl(null); 38 - albumArtCache.set(releaseMbId, null); 39 - } finally { 40 - setIsLoading(false); 41 - } 42 - }; 43 - 44 - fetchAlbumArt(); 45 - }, [releaseMbId]); 46 - 47 - return { albumArtUrl, isLoading }; 48 - }