Files
Lyra/web/src/app/discover/artist/[mbid]/preview-client.tsx
T
Jonathan 17bed331a9 feat(web): Follow button + clickable artist in album modals
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>
2026-07-14 11:58:01 +02:00

199 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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">
Couldnt 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>
);
}