17bed331a9
AlbumModal showed the artist name as static text — no way to follow the artist or jump to them. Adds a Follow affordance (todo #13): - AlbumInfo gains optional `artistMbid`. When present, the modal title links the artist name to /discover/artist/{mbid} and shows a Follow/Following button that reflects live follow state. - Follow state fetched via a new lightweight GET /api/artists?mbid= check (avoids pulling the full watched list + discography); Follow reuses POST /api/artists. Non-ok POST surfaces a toast (also chips at todo #14). - Wired artistMbid in the three discovery contexts where following is valuable: Discover, Discover artist preview, and Last.fm. Library/discography callers pass none (already-followed artists) so the button stays hidden — graceful. web 152 tests (the ?mbid= follow-state check asserted), tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { PageHead } from "../../../_ui/page-head";
|
||
import { CoverArt } from "../../../_ui/cover-art";
|
||
import { AlbumModal } from "../../../_ui/album-modal";
|
||
import { categoryOf, TAB_ORDER, type Category } from "../../../_ui/categories";
|
||
import { toast } from "../../../_ui/toast";
|
||
|
||
type Release = {
|
||
rgMbid: string;
|
||
album: string;
|
||
primaryType: string | null;
|
||
secondaryTypes: string[];
|
||
firstReleaseDate: string | null;
|
||
monitored: boolean;
|
||
have: boolean;
|
||
};
|
||
type Preview = { artistName: string; followed: boolean; releases: Release[] };
|
||
|
||
export function PreviewClient({ mbid, initialName }: { mbid: string; initialName: string }) {
|
||
const [data, setData] = useState<Preview | null>(null);
|
||
const [followed, setFollowed] = useState(false);
|
||
const [error, setError] = useState(false);
|
||
const [tab, setTab] = useState<"All" | Category>("Albums");
|
||
const [wanted, setWanted] = useState<Set<string>>(new Set());
|
||
const [open, setOpen] = useState<Release | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setError(false);
|
||
const qs = new URLSearchParams({ all: "true" });
|
||
if (initialName) qs.set("name", initialName);
|
||
const res = await fetch(`/api/discover/preview/${mbid}?${qs.toString()}`);
|
||
if (!res.ok) {
|
||
setError(true);
|
||
return;
|
||
}
|
||
const d: Preview = await res.json();
|
||
setData(d);
|
||
setFollowed(d.followed);
|
||
}, [mbid, initialName]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
const name = data?.artistName || initialName;
|
||
|
||
async function follow() {
|
||
const res = await fetch("/api/artists", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ mbid, name }),
|
||
});
|
||
if (res.ok || res.status === 409) {
|
||
setFollowed(true);
|
||
toast(`Following ${name}`);
|
||
}
|
||
}
|
||
|
||
async function want(r: Release) {
|
||
const res = await fetch("/api/discover/want", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
rgMbid: r.rgMbid,
|
||
artistMbid: mbid,
|
||
artistName: name,
|
||
album: r.album,
|
||
primaryType: r.primaryType,
|
||
secondaryTypes: r.secondaryTypes,
|
||
firstReleaseDate: r.firstReleaseDate,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setWanted((s) => new Set(s).add(r.rgMbid));
|
||
toast(`Added ${r.album}`);
|
||
}
|
||
}
|
||
|
||
const counts = useMemo(() => {
|
||
const c: Record<string, number> = {};
|
||
data?.releases.forEach((r) => {
|
||
c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1;
|
||
});
|
||
return c;
|
||
}, [data]);
|
||
|
||
function chipFor(r: Release) {
|
||
if (r.have) return <span className="chip done">In library</span>;
|
||
if (r.monitored || wanted.has(r.rgMbid)) return <span className="chip working">Wanted</span>;
|
||
return null;
|
||
}
|
||
|
||
if (error)
|
||
return (
|
||
<p className="empty">
|
||
Couldn’t load this artist.{" "}
|
||
<button className="btn sm" onClick={load}>
|
||
Retry
|
||
</button>
|
||
</p>
|
||
);
|
||
if (!data) return <p className="empty">Loading…</p>;
|
||
|
||
const tabs: ("All" | Category)[] = ["All", ...TAB_ORDER.filter((t) => counts[t])];
|
||
const visible = tab === "All" ? data.releases : data.releases.filter((r) => categoryOf(r) === tab);
|
||
|
||
return (
|
||
<div>
|
||
<PageHead title={name || "Artist"} eyebrow="Preview · discography" />
|
||
|
||
<div className="tool-row">
|
||
{followed ? (
|
||
<span className="following">Following</span>
|
||
) : (
|
||
<button className="btn accent" onClick={follow}>
|
||
Follow
|
||
</button>
|
||
)}
|
||
<a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer">
|
||
MusicBrainz ↗
|
||
</a>
|
||
</div>
|
||
|
||
<div className="tabs">
|
||
{tabs.map((t) => (
|
||
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
|
||
{t}
|
||
<span className="n">{t === "All" ? data.releases.length : counts[t]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<ul className="list">
|
||
{visible.map((r) => {
|
||
const owned = r.have || r.monitored || wanted.has(r.rgMbid);
|
||
return (
|
||
<li key={r.rgMbid} className="list-row">
|
||
<div className="main">
|
||
<button className="disco-open" onClick={() => setOpen(r)} aria-label={`Open ${r.album}`}>
|
||
<CoverArt rgMbid={r.rgMbid} alt={r.album} className="thumb" />
|
||
<span>
|
||
<span className="rtitle">{r.album}</span>
|
||
<span className="rmeta">
|
||
{r.primaryType ? <span>{r.primaryType}</span> : null}
|
||
{r.firstReleaseDate ? (
|
||
<>
|
||
<span className="dot">·</span>
|
||
<span>{r.firstReleaseDate.slice(0, 4)}</span>
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div className="actions">
|
||
{chipFor(r)}
|
||
<button className="btn sm" onClick={() => want(r)} disabled={owned}>
|
||
Want
|
||
</button>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
{open ? (
|
||
<AlbumModal
|
||
open
|
||
onClose={() => setOpen(null)}
|
||
album={{
|
||
rgMbid: open.rgMbid,
|
||
album: open.album,
|
||
artist: name,
|
||
year: open.firstReleaseDate?.slice(0, 4) ?? null,
|
||
type: open.primaryType,
|
||
artistMbid: mbid,
|
||
}}
|
||
status={chipFor(open)}
|
||
actions={
|
||
open.have || open.monitored || wanted.has(open.rgMbid) ? null : (
|
||
<button
|
||
className="btn sm"
|
||
onClick={() => {
|
||
want(open);
|
||
setOpen(null);
|
||
}}
|
||
>
|
||
Want
|
||
</button>
|
||
)
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|