feat(discover): unified artist rows with inline album thumbnails

/discover is now one list of artist rows (no Artists|Albums tabs). Each row
shows the artist (name links to the full page — ArtistModal dropped from
Discover), the 'Similar to' reason, and its newest/most-played album
thumbnails with badges. Album-click opens the AlbumModal (cover + tracklist +
Want). Find-similar results link to the full page too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 23:46:14 +02:00
parent a8c1600111
commit 947c86bc8f
2 changed files with 106 additions and 97 deletions
+11
View File
@@ -286,6 +286,17 @@ nav.contents .sep { flex: 1; }
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; }
/* Discover unified row: artist block | inline album thumbnails | actions */
.disco-row { align-items: flex-start; flex-wrap: wrap; gap: 14px; }
.disco-row .main { flex: 1 1 180px; }
.disco-albums { display: flex; gap: 12px; flex-wrap: wrap; }
.disco-album { display: flex; flex-direction: column; gap: 4px; width: 92px; }
.disco-thumb { display: flex; flex-direction: column; gap: 4px; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
.disco-thumb .thumb { width: 92px; height: 92px; }
.disco-album .da-title { font-size: 12px; line-height: 1.2; color: var(--ink); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.disco-album .badge { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--graphite); }
.disco-album .btn.sm { padding: 2px 8px; }
.sel-tools { display: inline-flex; align-items: center; gap: 10px; }
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
.album-card { position: relative; }
+93 -95
View File
@@ -4,23 +4,28 @@ import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { timeAgo } from "../_ui/status";
import { SectionHeader } from "../_ui/section-header";
import { ArtistModal } from "../_ui/artist-modal";
import { AlbumModal } from "../_ui/album-modal";
import { CoverArt } from "../_ui/cover-art";
import { toast } from "../_ui/toast";
type Suggestion = {
type Album = {
id: string;
artistMbid: string;
artistName: string;
rgMbid: string | null;
album: string | null;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
albumReason: string | null;
};
type Row = {
id: string | null;
artistMbid: string;
artistName: string;
score: number;
seedCount: number;
sources: string[];
seeds: string[];
albums: Album[];
};
/** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */
@@ -31,13 +36,12 @@ function similarTo(seeds: string[]): string | null {
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
}
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;
/** Human badge for why an album is surfaced. */
function badgeFor(reason: string | null): string | null {
if (reason === "newest") return "Newest";
if (reason === "popular") return "Most played";
if (reason === "newest,popular") return "Newest · Most played";
return null;
}
/** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */
@@ -46,14 +50,13 @@ function agoLabel(value: string): string {
return !a || a === "just now" ? a || "recently" : `${a} ago`;
}
type Feed = { artists: Suggestion[]; albums: Suggestion[] };
type SearchResult = {
seed: { mbid: string; name: string } | null;
similar: { mbid: string; name: string; score: number }[];
};
export function DiscoverClient() {
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] });
const [rows, setRows] = useState<Row[]>([]);
const [result, setResult] = useState<string | null>(null);
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [running, setRunning] = useState(false);
@@ -61,18 +64,14 @@ export function DiscoverClient() {
const [search, setSearch] = useState<SearchResult | null>(null);
const [busy, setBusy] = useState(false);
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
const [tab, setTab] = useState<Tab>("Artists");
const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
const albums = feed.albums.filter(isFullAlbum);
const loadFeed = useCallback(async () => {
setFeed(await (await fetch("/api/discover")).json());
const loadRows = useCallback(async () => {
setRows((await (await fetch("/api/discover")).json()).rows ?? []);
}, []);
useEffect(() => {
loadFeed();
loadRows();
fetch("/api/discover/run")
.then((r) => r.json())
.then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => {
@@ -80,7 +79,7 @@ export function DiscoverClient() {
setLastRunAt(s.lastRunAt);
setRunning(s.requested || s.inProgress);
});
}, [loadFeed]);
}, [loadRows]);
useEffect(() => {
if (!running) return;
@@ -89,13 +88,12 @@ export function DiscoverClient() {
setResult(s.result);
setLastRunAt(s.lastRunAt);
if (!s.requested && !s.inProgress) {
// the sweep finished (neither queued nor in progress) — refresh the feed
setRunning(false);
loadFeed();
loadRows();
}
}, 2000);
return () => clearInterval(id);
}, [running, loadFeed]);
}, [running, loadRows]);
async function discoverNow() {
await fetch("/api/discover/run", { method: "POST" });
@@ -113,18 +111,26 @@ export function DiscoverClient() {
}
}
async function followSimilar(a: { mbid: string; name: string }) {
async function followByMbid(mbid: string, name: string) {
const res = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: a.mbid, name: a.name }),
});
if (res.ok || res.status === 409) {
body: JSON.stringify({ mbid, name }),
}).catch(() => null);
return !!res && (res.ok || res.status === 409);
}
async function followSimilar(a: { mbid: string; name: string }) {
if (await followByMbid(a.mbid, a.name)) {
setFollowedSimilar((s) => new Set(s).add(a.mbid));
toast(`Following ${a.name}`);
} else {
toast(`Couldn't follow ${a.name}`);
}
}
// Suggestion actions key off a suggestion id. A row with no artist-suggestion id (album-only,
// e.g. the artist was dismissed earlier) falls back to a plain follow-by-MBID.
async function act(id: string, action: "follow" | "want" | "dismiss") {
const res = await fetch(`/api/discover/${id}`, {
method: "POST",
@@ -136,7 +142,17 @@ export function DiscoverClient() {
return;
}
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed");
loadFeed();
loadRows();
}
async function followRow(row: Row) {
if (row.id) return act(row.id, "follow");
if (await followByMbid(row.artistMbid, row.artistName)) {
toast(`Following ${row.artistName}`);
loadRows();
} else {
toast(`Couldn't follow ${row.artistName}`);
}
}
return (
@@ -173,9 +189,7 @@ export function DiscoverClient() {
<li key={s.mbid} className="list-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}>
{s.name}
</button>
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
</div>
<div className="rmeta">
<span className="score">similarity {s.score.toFixed(1)}</span>
@@ -195,96 +209,80 @@ export function DiscoverClient() {
</ul>
) : null}
<div className="tabs">
<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 ? (
<SectionHeader title="Suggested artists" note={`${rows.length}`} />
{rows.length === 0 ? (
<p className="muted-note">No suggestions yet run Discover, or follow a few artists to seed it.</p>
) : (
<ul className="list">
{feed.artists.map((s) => (
<li key={s.id} className="list-row">
{rows.map((row) => (
<li key={row.artistMbid} className="list-row disco-row">
<div className="main">
<div className="rtitle">
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}>
{s.artistName}
</button>
<a href={`/discover/artist/${row.artistMbid}?name=${encodeURIComponent(row.artistName)}`}>
{row.artistName}
</a>
</div>
<div className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span>
<span className="score">score {row.score.toFixed(2)}</span>
<span className="dot">·</span>
<span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span>
<span className="dot">·</span>
<span>{s.sources.join(", ")}</span>
<span>{row.sources.join(", ")}</span>
</div>
{similarTo(s.seeds) ? <div className="rmeta why">{similarTo(s.seeds)}</div> : null}
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
</div>
<div className="actions">
<button className="btn sm" onClick={() => act(s.id, "follow")}>
Follow
{row.albums.length > 0 ? (
<div className="disco-albums">
{row.albums.map((a) => (
<div key={a.id} className="disco-album">
<button
className="disco-thumb"
onClick={() => setAlbumModal({ a, artistName: row.artistName, artistMbid: row.artistMbid })}
aria-label={`Open ${a.album ?? ""}`}
>
<CoverArt rgMbid={a.rgMbid} alt={a.album ?? ""} className="thumb" />
<span className="da-title">{a.album}</span>
{badgeFor(a.albumReason) ? <span className="badge">{badgeFor(a.albumReason)}</span> : null}
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
Dismiss
</button>
</div>
</li>
))}
</ul>
)
) : albums.length === 0 ? (
<p className="muted-note">No album suggestions yet.</p>
) : (
<ul className="list">
{albums.map((s) => (
<li key={s.id} className="list-row">
<div className="main">
<button className="disco-open" onClick={() => setAlbumModal(s)} aria-label={`Open ${s.album ?? ""}`}>
<CoverArt rgMbid={s.rgMbid} alt={s.album ?? ""} className="thumb" />
<span>
<span className="rtitle">
{s.album} <span className="artist">· {s.artistName}</span>
</span>
<span className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span>
</span>
{similarTo(s.seeds) ? <span className="rmeta why">{similarTo(s.seeds)}</span> : null}
</span>
</button>
</div>
<div className="actions">
<button className="btn sm" onClick={() => act(s.id, "want")}>
<button className="btn sm" onClick={() => act(a.id, "want")}>
Want
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
</div>
))}
</div>
) : null}
<div className="actions">
<button className="btn sm" onClick={() => followRow(row)}>
Follow
</button>
{row.id ? (
<button className="btn sm ghost" onClick={() => act(row.id!, "dismiss")}>
Dismiss
</button>
) : null}
</div>
</li>
))}
</ul>
)}
{artistModal ? (
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
) : null}
{albumModal ? (
<AlbumModal
open
onClose={() => setAlbumModal(null)}
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null, artistMbid: albumModal.artistMbid }}
album={{
rgMbid: albumModal.a.rgMbid,
album: albumModal.a.album ?? "",
artist: albumModal.artistName,
year: albumModal.a.firstReleaseDate?.slice(0, 4) ?? null,
type: albumModal.a.primaryType,
artistMbid: albumModal.artistMbid,
}}
actions={
<button
className="btn sm"
onClick={() => {
act(albumModal.id, "want");
act(albumModal.a.id, "want");
setAlbumModal(null);
}}
>