feat(discover): Artists|Albums tabs on /discover

Suggested artists and Suggested albums were stacked sections; make them
tabs like /lastfm (.tabs bar + count badge). Discover-now/freshness and
Find-similar stay above the tabs.

The Albums tab filters to full-length albums (isFullAlbum) — a client-side
stopgap that hides any pre-D1 singles/EP suggestions still in the DB (new
sweeps already filter server-side via discover.albumsOnly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 20:42:33 +02:00
parent c9905937cc
commit 72e9c2d47d
+30 -7
View File
@@ -15,11 +15,22 @@ type Suggestion = {
artistName: string; artistName: string;
rgMbid: string | null; rgMbid: string | null;
album: string | null; album: string | null;
primaryType: string | null;
secondaryTypes: string[];
score: number; score: number;
seedCount: number; seedCount: number;
sources: string[]; sources: string[];
}; };
type Tab = "Artists" | "Albums";
/** Only full-length albums belong in Suggested albums — hide singles/EPs and
* secondary-type releases (live/comp). New sweeps already filter server-side
* (discover.albumsOnly); this also sweeps any pre-filter rows still in the DB. */
function isFullAlbum(s: Suggestion): boolean {
return (s.primaryType ?? "Album") === "Album" && (s.secondaryTypes?.length ?? 0) === 0;
}
/** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */ /** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */
function agoLabel(value: string): string { function agoLabel(value: string): string {
const a = timeAgo(value, Date.now()); const a = timeAgo(value, Date.now());
@@ -43,6 +54,9 @@ export function DiscoverClient() {
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set()); const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null); const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null); const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
const [tab, setTab] = useState<Tab>("Artists");
const albums = feed.albums.filter(isFullAlbum);
const loadFeed = useCallback(async () => { const loadFeed = useCallback(async () => {
setFeed(await (await fetch("/api/discover")).json()); setFeed(await (await fetch("/api/discover")).json());
@@ -172,8 +186,19 @@ export function DiscoverClient() {
</ul> </ul>
) : null} ) : null}
<SectionHeader title="Suggested artists" note={`${feed.artists.length}`} /> <div className="tabs">
{feed.artists.length === 0 ? ( <button className={`tab${tab === "Artists" ? " active" : ""}`} onClick={() => setTab("Artists")}>
Suggested artists
<span className="n">{feed.artists.length}</span>
</button>
<button className={`tab${tab === "Albums" ? " active" : ""}`} onClick={() => setTab("Albums")}>
Suggested albums
<span className="n">{albums.length}</span>
</button>
</div>
{tab === "Artists" ? (
feed.artists.length === 0 ? (
<p className="muted-note">No suggestions yet run Discover, or follow a few artists to seed it.</p> <p className="muted-note">No suggestions yet run Discover, or follow a few artists to seed it.</p>
) : ( ) : (
<ul className="list"> <ul className="list">
@@ -204,14 +229,12 @@ export function DiscoverClient() {
</li> </li>
))} ))}
</ul> </ul>
)} )
) : albums.length === 0 ? (
<SectionHeader title="Suggested albums" note={`${feed.albums.length}`} />
{feed.albums.length === 0 ? (
<p className="muted-note">No album suggestions yet.</p> <p className="muted-note">No album suggestions yet.</p>
) : ( ) : (
<ul className="list"> <ul className="list">
{feed.albums.map((s) => ( {albums.map((s) => (
<li key={s.id} className="list-row"> <li key={s.id} className="list-row">
<div className="main"> <div className="main">
<button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}> <button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}>