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:
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user