Teal.fm frontend powered by slices.network tealfm-slices.wisp.place
tealfm slices

use routes for tabs, fix album art

+37
src/AlbumArt.tsx
···
+
import { useState } from "react";
+
+
interface AlbumArtProps {
+
releaseMbId: string | null | undefined;
+
alt: string;
+
}
+
+
export default function AlbumArt({ releaseMbId, alt }: AlbumArtProps) {
+
const [hasError, setHasError] = useState(false);
+
const [isLoading, setIsLoading] = useState(true);
+
+
if (!releaseMbId || hasError) {
+
return (
+
<div className="w-10 h-10 bg-zinc-800 flex items-center justify-center">
+
<svg className="w-5 h-5 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
+
<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" />
+
</svg>
+
</div>
+
);
+
}
+
+
return (
+
<>
+
{isLoading && <div className="w-10 h-10 bg-zinc-800 animate-pulse" />}
+
<img
+
src={`https://coverartarchive.org/release/${releaseMbId}/front-250`}
+
alt={alt}
+
className={`w-10 h-10 object-cover ${isLoading ? 'hidden' : ''}`}
+
onLoad={() => setIsLoading(false)}
+
onError={() => {
+
setIsLoading(false);
+
setHasError(true);
+
}}
+
/>
+
</>
+
);
+
}
+2 -22
src/AlbumItem.tsx
···
-
import { useAlbumArt } from "./useAlbumArt";
+
import AlbumArt from "./AlbumArt";
interface Artist {
artistName: string;
···
rank,
maxCount,
}: AlbumItemProps) {
-
const { albumArtUrl, isLoading } = useAlbumArt(releaseMbId);
const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0;
// Parse artists JSON
···
</div>
<div className="flex-shrink-0">
-
{isLoading ? (
-
<div className="w-10 h-10 bg-zinc-800 animate-pulse" />
-
) : albumArtUrl ? (
-
<img
-
src={albumArtUrl}
-
alt={`${releaseName} album art`}
-
className="w-10 h-10 object-cover"
-
loading="lazy"
-
/>
-
) : (
-
<div className="w-10 h-10 bg-zinc-800 flex items-center justify-center">
-
<svg
-
className="w-5 h-5 text-zinc-600"
-
fill="currentColor"
-
viewBox="0 0 20 20"
-
>
-
<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" />
-
</svg>
-
</div>
-
)}
+
<AlbumArt releaseMbId={releaseMbId} alt={`${releaseName} album art`} />
</div>
<div className="flex-1 min-w-0">
+32 -84
src/App.tsx
···
import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay";
-
import { useEffect, useRef, useState } from "react";
+
import { useEffect, useRef } from "react";
import type { AppQuery } from "./__generated__/AppQuery.graphql";
import type { App_plays$key } from "./__generated__/App_plays.graphql";
import TrackItem from "./TrackItem";
-
import TopAlbums from "./TopAlbums";
-
import TopTracks from "./TopTracks";
+
import Layout from "./Layout";
export default function App() {
-
const [activeTab, setActiveTab] = useState<"recent" | "tracks" | "albums">("recent");
const queryData = useLazyLoadQuery<AppQuery>(
graphql`
query AppQuery {
···
.filter((n) => n != null) || [];
useEffect(() => {
-
if (!loadMoreRef.current || !hasNext || activeTab !== "recent") return;
+
if (!loadMoreRef.current || !hasNext) return;
const observer = new IntersectionObserver(
(entries) => {
···
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
-
}, [hasNext, isLoadingNext, loadNext, activeTab]);
+
}, [hasNext, isLoadingNext, loadNext]);
// Group plays by date
const groupedPlays: { date: string; plays: typeof plays }[] = [];
···
});
return (
-
<div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono">
-
<div className="max-w-4xl mx-auto px-6 py-12">
-
<div className="mb-12 flex items-end justify-between border-b border-zinc-800 pb-6">
-
<div>
-
<h1 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Listening History</h1>
-
<p className="text-xs text-zinc-600 mt-1">fm.teal.alpha.feed.play</p>
-
</div>
-
-
<div className="flex gap-4 text-xs">
-
<button
-
onClick={() => setActiveTab("recent")}
-
className={`px-2 py-1 transition-colors ${
-
activeTab === "recent"
-
? "text-zinc-400"
-
: "text-zinc-500 hover:text-zinc-300"
-
}`}
-
>
-
Recent
-
</button>
-
<button
-
onClick={() => setActiveTab("tracks")}
-
className={`px-2 py-1 transition-colors ${
-
activeTab === "tracks"
-
? "text-zinc-400"
-
: "text-zinc-500 hover:text-zinc-300"
-
}`}
-
>
-
Top Tracks
-
</button>
-
<button
-
onClick={() => setActiveTab("albums")}
-
className={`px-2 py-1 transition-colors ${
-
activeTab === "albums"
-
? "text-zinc-400"
-
: "text-zinc-500 hover:text-zinc-300"
-
}`}
-
>
-
Top Albums
-
</button>
-
</div>
-
</div>
-
-
{activeTab === "recent" ? (
-
<>
-
<div className="mb-8">
-
<p className="text-xs text-zinc-500 uppercase tracking-wider">
-
{data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles
-
</p>
-
</div>
+
<Layout>
+
<div className="mb-8">
+
<p className="text-xs text-zinc-500 uppercase tracking-wider">
+
{data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles
+
</p>
+
</div>
-
<div>
-
{groupedPlays.map((group, groupIndex) => (
-
<div key={groupIndex} className="mb-12">
-
<h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider">
-
{group.date}
-
</h2>
-
<div className="space-y-1">
-
{group.plays.map((play, index) => (
-
<TrackItem key={index} play={play} />
-
))}
-
</div>
-
</div>
+
<div>
+
{groupedPlays.map((group, groupIndex) => (
+
<div key={groupIndex} className="mb-12">
+
<h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider">
+
{group.date}
+
</h2>
+
<div className="space-y-1">
+
{group.plays.map((play, index) => (
+
<TrackItem key={index} play={play} />
))}
</div>
-
-
{hasNext && (
-
<div ref={loadMoreRef} className="py-12 text-center">
-
{isLoadingNext ? (
-
<p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p>
-
) : (
-
<p className="text-xs text-zinc-700 uppercase tracking-wider">·</p>
-
)}
-
</div>
-
)}
-
</>
-
) : activeTab === "tracks" ? (
-
<TopTracks />
-
) : (
-
<TopAlbums />
-
)}
+
</div>
+
))}
</div>
-
</div>
+
+
{hasNext && (
+
<div ref={loadMoreRef} className="py-12 text-center">
+
{isLoadingNext ? (
+
<p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p>
+
) : (
+
<p className="text-xs text-zinc-700 uppercase tracking-wider">·</p>
+
)}
+
</div>
+
)}
+
</Layout>
);
}
+57
src/Layout.tsx
···
+
import { Link, useLocation } from "react-router-dom";
+
+
interface LayoutProps {
+
children: React.ReactNode;
+
}
+
+
export default function Layout({ children }: LayoutProps) {
+
const location = useLocation();
+
+
return (
+
<div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono">
+
<div className="max-w-4xl mx-auto px-6 py-12">
+
<div className="mb-12 flex items-end justify-between border-b border-zinc-800 pb-6">
+
<div>
+
<h1 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Listening History</h1>
+
<p className="text-xs text-zinc-600 mt-1">fm.teal.alpha.feed.play</p>
+
</div>
+
+
<div className="flex gap-4 text-xs">
+
<Link
+
to="/"
+
className={`px-2 py-1 transition-colors ${
+
location.pathname === "/"
+
? "text-zinc-400"
+
: "text-zinc-500 hover:text-zinc-300"
+
}`}
+
>
+
Recent
+
</Link>
+
<Link
+
to="/tracks"
+
className={`px-2 py-1 transition-colors ${
+
location.pathname === "/tracks"
+
? "text-zinc-400"
+
: "text-zinc-500 hover:text-zinc-300"
+
}`}
+
>
+
Top Tracks
+
</Link>
+
<Link
+
to="/albums"
+
className={`px-2 py-1 transition-colors ${
+
location.pathname === "/albums"
+
? "text-zinc-400"
+
: "text-zinc-500 hover:text-zinc-300"
+
}`}
+
>
+
Top Albums
+
</Link>
+
</div>
+
</div>
+
+
{children}
+
</div>
+
</div>
+
);
+
}
+16 -13
src/TopAlbums.tsx
···
import { graphql, useLazyLoadQuery } from "react-relay";
import type { TopAlbumsQuery } from "./__generated__/TopAlbumsQuery.graphql";
import AlbumItem from "./AlbumItem";
+
import Layout from "./Layout";
export default function TopAlbums() {
const data = useLazyLoadQuery<TopAlbumsQuery>(
···
const maxCount = dedupedAlbums.length > 0 ? dedupedAlbums[0].count : 0;
return (
-
<div className="space-y-1">
-
{dedupedAlbums.map((album, index) => (
-
<AlbumItem
-
key={album.releaseMbId || index}
-
releaseName={album.releaseName || "Unknown Album"}
-
releaseMbId={album.releaseMbId}
-
artists={album.artists}
-
count={album.count}
-
rank={index + 1}
-
maxCount={maxCount}
-
/>
-
))}
-
</div>
+
<Layout>
+
<div className="space-y-1">
+
{dedupedAlbums.map((album, index) => (
+
<AlbumItem
+
key={album.releaseMbId || index}
+
releaseName={album.releaseName || "Unknown Album"}
+
releaseMbId={album.releaseMbId}
+
artists={album.artists}
+
count={album.count}
+
rank={index + 1}
+
maxCount={maxCount}
+
/>
+
))}
+
</div>
+
</Layout>
);
}
+2 -22
src/TopTrackItem.tsx
···
-
import { useAlbumArt } from "./useAlbumArt";
+
import AlbumArt from "./AlbumArt";
interface Artist {
artistName: string;
···
rank,
maxCount,
}: TopTrackItemProps) {
-
const { albumArtUrl, isLoading } = useAlbumArt(releaseMbId);
const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0;
// Parse artists JSON
···
</div>
<div className="flex-shrink-0">
-
{isLoading ? (
-
<div className="w-10 h-10 bg-zinc-800 animate-pulse" />
-
) : albumArtUrl ? (
-
<img
-
src={albumArtUrl}
-
alt={`${trackName} album art`}
-
className="w-10 h-10 object-cover"
-
loading="lazy"
-
/>
-
) : (
-
<div className="w-10 h-10 bg-zinc-800 flex items-center justify-center">
-
<svg
-
className="w-5 h-5 text-zinc-600"
-
fill="currentColor"
-
viewBox="0 0 20 20"
-
>
-
<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" />
-
</svg>
-
</div>
-
)}
+
<AlbumArt releaseMbId={releaseMbId} alt={`${trackName} album art`} />
</div>
<div className="flex-1 min-w-0">
+16 -13
src/TopTracks.tsx
···
import { graphql, useLazyLoadQuery } from "react-relay";
import type { TopTracksQuery } from "./__generated__/TopTracksQuery.graphql";
import TopTrackItem from "./TopTrackItem";
+
import Layout from "./Layout";
export default function TopTracks() {
const data = useLazyLoadQuery<TopTracksQuery>(
···
const maxCount = tracks.length > 0 ? tracks[0].count : 0;
return (
-
<div className="space-y-1">
-
{tracks.map((track, index) => (
-
<TopTrackItem
-
key={`${track.trackName}-${index}`}
-
trackName={track.trackName || "Unknown Track"}
-
releaseMbId={track.releaseMbId}
-
artists={track.artists || "Unknown Artist"}
-
count={track.count}
-
rank={index + 1}
-
maxCount={maxCount}
-
/>
-
))}
-
</div>
+
<Layout>
+
<div className="space-y-1">
+
{tracks.map((track, index) => (
+
<TopTrackItem
+
key={`${track.trackName}-${index}`}
+
trackName={track.trackName || "Unknown Track"}
+
releaseMbId={track.releaseMbId}
+
artists={track.artists || "Unknown Artist"}
+
count={track.count}
+
rank={index + 1}
+
maxCount={maxCount}
+
/>
+
))}
+
</div>
+
</Layout>
);
}
+2 -19
src/TrackItem.tsx
···
import { graphql, useFragment } from "react-relay";
import type { TrackItem_play$key } from "./__generated__/TrackItem_play.graphql";
-
import { useAlbumArt } from "./useAlbumArt";
+
import AlbumArt from "./AlbumArt";
interface TrackItemProps {
play: TrackItem_play$key;
···
play
);
-
const { albumArtUrl, isLoading } = useAlbumArt(data.releaseMbId);
-
return (
<div className="group py-3 px-4 hover:bg-zinc-900/50 transition-colors">
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
-
{isLoading ? (
-
<div className="w-10 h-10 bg-zinc-800 animate-pulse" />
-
) : albumArtUrl ? (
-
<img
-
src={albumArtUrl}
-
alt={`${data.trackName} album art`}
-
className="w-10 h-10 object-cover"
-
loading="lazy"
-
/>
-
) : (
-
<div className="w-10 h-10 bg-zinc-800 flex items-center justify-center">
-
<svg className="w-5 h-5 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
-
<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" />
-
</svg>
-
</div>
-
)}
+
<AlbumArt releaseMbId={data.releaseMbId} alt={`${data.trackName} album art`} />
</div>
<div className="flex-1 min-w-0 grid grid-cols-2 gap-4">
-1
src/albumArtCache.ts
···
-
export const albumArtCache = new Map<string, string | null>();
+4
src/main.tsx
···
import "./index.css";
import App from "./App.tsx";
import Profile from "./Profile.tsx";
+
import TopTracks from "./TopTracks.tsx";
+
import TopAlbums from "./TopAlbums.tsx";
import LoadingFallback from "./LoadingFallback.tsx";
import { RelayEnvironmentProvider } from "react-relay";
import { Environment, Network, type FetchFunction } from "relay-runtime";
···
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<App />} />
+
<Route path="/tracks" element={<TopTracks />} />
+
<Route path="/albums" element={<TopAlbums />} />
<Route path="/profile/:handle" element={<Profile />} />
</Routes>
</Suspense>
-48
src/useAlbumArt.ts
···
-
import { useState, useEffect } from "react";
-
import { albumArtCache } from "./albumArtCache";
-
-
export function useAlbumArt(releaseMbId: string | null | undefined) {
-
const [albumArtUrl, setAlbumArtUrl] = useState<string | null>(null);
-
const [isLoading, setIsLoading] = useState(false);
-
-
useEffect(() => {
-
if (!releaseMbId) return;
-
-
// Check cache first
-
if (albumArtCache.has(releaseMbId)) {
-
setAlbumArtUrl(albumArtCache.get(releaseMbId) || null);
-
setIsLoading(false);
-
return;
-
}
-
-
setIsLoading(true);
-
-
const fetchAlbumArt = async () => {
-
try {
-
// Fetch cover art from Cover Art Archive
-
const coverArtUrl = `https://coverartarchive.org/release/${releaseMbId}/front-250`;
-
-
// Check if cover art exists
-
const coverArtResponse = await fetch(coverArtUrl, { method: "HEAD" });
-
-
if (coverArtResponse.ok) {
-
setAlbumArtUrl(coverArtUrl);
-
albumArtCache.set(releaseMbId, coverArtUrl);
-
} else {
-
setAlbumArtUrl(null);
-
albumArtCache.set(releaseMbId, null);
-
}
-
} catch (error) {
-
console.error("Error fetching album art:", error);
-
setAlbumArtUrl(null);
-
albumArtCache.set(releaseMbId, null);
-
} finally {
-
setIsLoading(false);
-
}
-
};
-
-
fetchAlbumArt();
-
}, [releaseMbId]);
-
-
return { albumArtUrl, isLoading };
-
}