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; }
|
.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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
<div className="actions">
|
|
||||||
<button className="btn sm" onClick={() => act(s.id, "follow")}>
|
{row.albums.length > 0 ? (
|
||||||
Follow
|
<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>
|
||||||
<button className="btn sm ghost" onClick={() => act(s.id, "dismiss")}>
|
<button className="btn sm" onClick={() => act(a.id, "want")}>
|
||||||
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")}>
|
|
||||||
Want
|
Want
|
||||||
</button>
|
</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
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user