+37
src/AlbumArt.tsx
+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
+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
+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
+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
+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
+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
+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
+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
src/albumArtCache.ts
···
1
-
export const albumArtCache = new Map<string, string | null>();
+4
src/main.tsx
+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
-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
-
}