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; } .save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; } .edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; } .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; } .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); } .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; } .album-card { position: relative; }
+95 -97
View File
@@ -4,23 +4,28 @@ import { useCallback, useEffect, useState } from "react";
import { PageHead } from "../_ui/page-head"; import { PageHead } from "../_ui/page-head";
import { timeAgo } from "../_ui/status"; import { timeAgo } from "../_ui/status";
import { SectionHeader } from "../_ui/section-header"; import { SectionHeader } from "../_ui/section-header";
import { ArtistModal } from "../_ui/artist-modal";
import { AlbumModal } from "../_ui/album-modal"; import { AlbumModal } from "../_ui/album-modal";
import { CoverArt } from "../_ui/cover-art"; import { CoverArt } from "../_ui/cover-art";
import { toast } from "../_ui/toast"; import { toast } from "../_ui/toast";
type Suggestion = { type Album = {
id: string; id: string;
artistMbid: string;
artistName: string;
rgMbid: string | null; rgMbid: string | null;
album: string | null; album: string | null;
primaryType: string | null; primaryType: string | null;
secondaryTypes: string[]; secondaryTypes: string[];
firstReleaseDate: string | null;
albumReason: string | null;
};
type Row = {
id: string | null;
artistMbid: string;
artistName: string;
score: number; score: number;
seedCount: number; seedCount: number;
sources: string[]; sources: string[];
seeds: string[]; seeds: string[];
albums: Album[];
}; };
/** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */ /** "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` : ""}`; return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
} }
type Tab = "Artists" | "Albums"; /** Human badge for why an album is surfaced. */
function badgeFor(reason: string | null): string | null {
/** Only full-length albums belong in Suggested albums — hide singles/EPs and if (reason === "newest") return "Newest";
* secondary-type releases (live/comp). New sweeps already filter server-side if (reason === "popular") return "Most played";
* (discover.albumsOnly); this also sweeps any pre-filter rows still in the DB. */ if (reason === "newest,popular") return "Newest · Most played";
function isFullAlbum(s: Suggestion): boolean { return null;
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"). */
@@ -46,14 +50,13 @@ function agoLabel(value: string): string {
return !a || a === "just now" ? a || "recently" : `${a} ago`; return !a || a === "just now" ? a || "recently" : `${a} ago`;
} }
type Feed = { artists: Suggestion[]; albums: Suggestion[] };
type SearchResult = { type SearchResult = {
seed: { mbid: string; name: string } | null; seed: { mbid: string; name: string } | null;
similar: { mbid: string; name: string; score: number }[]; similar: { mbid: string; name: string; score: number }[];
}; };
export function DiscoverClient() { export function DiscoverClient() {
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] }); const [rows, setRows] = useState<Row[]>([]);
const [result, setResult] = useState<string | null>(null); const [result, setResult] = useState<string | null>(null);
const [lastRunAt, setLastRunAt] = useState<string | null>(null); const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
@@ -61,18 +64,14 @@ export function DiscoverClient() {
const [search, setSearch] = useState<SearchResult | null>(null); const [search, setSearch] = useState<SearchResult | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
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 [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null);
const [albumModal, setAlbumModal] = useState<Suggestion | null>(null);
const [tab, setTab] = useState<Tab>("Artists");
const albums = feed.albums.filter(isFullAlbum); const loadRows = useCallback(async () => {
setRows((await (await fetch("/api/discover")).json()).rows ?? []);
const loadFeed = useCallback(async () => {
setFeed(await (await fetch("/api/discover")).json());
}, []); }, []);
useEffect(() => { useEffect(() => {
loadFeed(); loadRows();
fetch("/api/discover/run") fetch("/api/discover/run")
.then((r) => r.json()) .then((r) => r.json())
.then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => { .then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => {
@@ -80,7 +79,7 @@ export function DiscoverClient() {
setLastRunAt(s.lastRunAt); setLastRunAt(s.lastRunAt);
setRunning(s.requested || s.inProgress); setRunning(s.requested || s.inProgress);
}); });
}, [loadFeed]); }, [loadRows]);
useEffect(() => { useEffect(() => {
if (!running) return; if (!running) return;
@@ -89,13 +88,12 @@ export function DiscoverClient() {
setResult(s.result); setResult(s.result);
setLastRunAt(s.lastRunAt); setLastRunAt(s.lastRunAt);
if (!s.requested && !s.inProgress) { if (!s.requested && !s.inProgress) {
// the sweep finished (neither queued nor in progress) — refresh the feed
setRunning(false); setRunning(false);
loadFeed(); loadRows();
} }
}, 2000); }, 2000);
return () => clearInterval(id); return () => clearInterval(id);
}, [running, loadFeed]); }, [running, loadRows]);
async function discoverNow() { async function discoverNow() {
await fetch("/api/discover/run", { method: "POST" }); 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", { const res = await fetch("/api/artists", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: a.mbid, name: a.name }), body: JSON.stringify({ mbid, name }),
}); }).catch(() => null);
if (res.ok || res.status === 409) { 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)); setFollowedSimilar((s) => new Set(s).add(a.mbid));
toast(`Following ${a.name}`); 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") { async function act(id: string, action: "follow" | "want" | "dismiss") {
const res = await fetch(`/api/discover/${id}`, { const res = await fetch(`/api/discover/${id}`, {
method: "POST", method: "POST",
@@ -136,7 +142,17 @@ export function DiscoverClient() {
return; return;
} }
toast(action === "follow" ? "Following" : action === "want" ? "Added to wanted" : "Dismissed"); 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 ( return (
@@ -173,9 +189,7 @@ export function DiscoverClient() {
<li key={s.mbid} className="list-row"> <li key={s.mbid} className="list-row">
<div className="main"> <div className="main">
<div className="rtitle"> <div className="rtitle">
<button className="linkish" onClick={() => setArtistModal({ mbid: s.mbid, name: s.name })}> <a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>
{s.name}
</button>
</div> </div>
<div className="rmeta"> <div className="rmeta">
<span className="score">similarity {s.score.toFixed(1)}</span> <span className="score">similarity {s.score.toFixed(1)}</span>
@@ -195,96 +209,80 @@ export function DiscoverClient() {
</ul> </ul>
) : null} ) : null}
<div className="tabs"> <SectionHeader title="Suggested artists" note={`${rows.length}`} />
<button className={`tab${tab === "Artists" ? " active" : ""}`} onClick={() => setTab("Artists")}> {rows.length === 0 ? (
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">
{feed.artists.map((s) => ( {rows.map((row) => (
<li key={s.id} className="list-row"> <li key={row.artistMbid} className="list-row disco-row">
<div className="main"> <div className="main">
<div className="rtitle"> <div className="rtitle">
<button className="linkish" onClick={() => setArtistModal({ mbid: s.artistMbid, name: s.artistName })}> <a href={`/discover/artist/${row.artistMbid}?name=${encodeURIComponent(row.artistName)}`}>
{s.artistName} {row.artistName}
</button> </a>
</div> </div>
<div className="rmeta"> <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 className="dot">·</span>
<span>{s.seedCount} seed{s.seedCount === 1 ? "" : "s"}</span> <span>{row.sources.join(", ")}</span>
<span className="dot">·</span>
<span>{s.sources.join(", ")}</span>
</div> </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>
{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" onClick={() => act(a.id, "want")}>
Want
</button>
</div>
))}
</div>
) : null}
<div className="actions"> <div className="actions">
<button className="btn sm" onClick={() => act(s.id, "follow")}> <button className="btn sm" onClick={() => followRow(row)}>
Follow Follow
</button> </button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}> {row.id ? (
Dismiss <button className="btn sm ghost" onClick={() => act(row.id!, "dismiss")}>
</button> Dismiss
</div> </button>
</li> ) : null}
))}
</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")}>
Want
</button>
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
Dismiss
</button>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{artistModal ? (
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
) : null}
{albumModal ? ( {albumModal ? (
<AlbumModal <AlbumModal
open open
onClose={() => setAlbumModal(null)} 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={ actions={
<button <button
className="btn sm" className="btn sm"
onClick={() => { onClick={() => {
act(albumModal.id, "want"); act(albumModal.a.id, "want");
setAlbumModal(null); setAlbumModal(null);
}} }}
> >