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>
This commit is contained in:
Jonathan
2026-07-14 11:58:01 +02:00
parent 938b8f32f9
commit 17bed331a9
7 changed files with 86 additions and 6 deletions
+58 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Modal } from "./modal"; import { Modal } from "./modal";
import { CoverArt } from "./cover-art"; import { CoverArt } from "./cover-art";
import { toast } from "./toast";
type Track = { position: number; title: string; lengthMs: number | null }; type Track = { position: number; title: string; lengthMs: number | null };
@@ -22,6 +23,8 @@ export type AlbumInfo = {
artist: string; artist: string;
year?: string | null; year?: string | null;
type?: string | null; type?: string | null;
/** When present, the modal shows a Follow button + links the artist name to their page. */
artistMbid?: string | null;
}; };
/** Reusable album modal: cover art + tracklist (fetched from MB by release-group MBID) with /** Reusable album modal: cover art + tracklist (fetched from MB by release-group MBID) with
@@ -40,6 +43,60 @@ export function AlbumModal({
actions?: ReactNode; actions?: ReactNode;
}) { }) {
const [tracks, setTracks] = useState<Track[] | "loading" | "error">("loading"); const [tracks, setTracks] = useState<Track[] | "loading" | "error">("loading");
// null = unknown/loading; only meaningful when album.artistMbid is set.
const [followed, setFollowed] = useState<boolean | null>(null);
useEffect(() => {
if (!open || !album.artistMbid) {
setFollowed(false);
return;
}
let cancelled = false;
setFollowed(null);
fetch(`/api/artists?mbid=${encodeURIComponent(album.artistMbid)}`)
.then((r) => (r.ok ? r.json() : { followed: false }))
.then((d) => !cancelled && setFollowed(!!d.followed))
.catch(() => !cancelled && setFollowed(false));
return () => {
cancelled = true;
};
}, [open, album.artistMbid]);
async function follow() {
if (!album.artistMbid) return;
try {
const res = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: album.artistMbid, name: album.artist }),
});
if (res.ok || res.status === 409) {
setFollowed(true);
toast(`Following ${album.artist}`);
} else {
toast(`Couldn't follow ${album.artist}`);
}
} catch {
toast(`Couldn't follow ${album.artist}`);
}
}
const titleNode: ReactNode = album.artistMbid ? (
<span className="modal-artist">
<a href={`/discover/artist/${album.artistMbid}`}>{album.artist}</a>
<button
type="button"
className="btn sm ghost"
onClick={follow}
disabled={followed !== false}
aria-label={followed ? "following" : "follow artist"}
>
{followed === null ? "…" : followed ? "Following" : "Follow"}
</button>
</span>
) : (
album.artist
);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -69,7 +126,7 @@ export function AlbumModal({
}, [open, album.rgMbid]); }, [open, album.rgMbid]);
return ( return (
<Modal open={open} onClose={onClose} title={album.artist} wide> <Modal open={open} onClose={onClose} title={titleNode} wide>
<div className="album-modal-head"> <div className="album-modal-head">
<CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" /> <CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" />
<div> <div>
+13 -3
View File
@@ -8,6 +8,10 @@ import { GET, POST } from "./route";
const mockBrowse = vi.mocked(browseReleaseGroups); const mockBrowse = vi.mocked(browseReleaseGroups);
afterEach(() => mockBrowse.mockReset()); afterEach(() => mockBrowse.mockReset());
function listReq(qs = "") {
return new Request(`http://localhost/api/artists${qs}`);
}
function postReq(body: unknown) { function postReq(body: unknown) {
return new Request("http://localhost/api/artists", { return new Request("http://localhost/api/artists", {
method: "POST", method: "POST",
@@ -52,7 +56,7 @@ describe("artists collection API", () => {
// mark the one release as monitored + have, to exercise the counts // mark the one release as monitored + have, to exercise the counts
await prisma.monitoredRelease.updateMany({ where: { artistMbid: "a1" }, data: { monitored: true, currentQualityClass: 2 } }); await prisma.monitoredRelease.updateMany({ where: { artistMbid: "a1" }, data: { monitored: true, currentQualityClass: 2 } });
const res = await GET(); const res = await GET(listReq());
expect(res.status).toBe(200); expect(res.status).toBe(200);
const a = (await res.json()).artists[0]; const a = (await res.json()).artists[0];
expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 }); expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 });
@@ -72,11 +76,17 @@ describe("artists collection API", () => {
}, },
}); });
const before = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2"); const before = (await (await GET(listReq())).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
expect(before).toMatchObject({ showAllTypes: false, releaseCount: 1, monitoredCount: 1, haveCount: 1 }); expect(before).toMatchObject({ showAllTypes: false, releaseCount: 1, monitoredCount: 1, haveCount: 1 });
await prisma.watchedArtist.update({ where: { mbid: "a2" }, data: { showAllTypes: true } }); await prisma.watchedArtist.update({ where: { mbid: "a2" }, data: { showAllTypes: true } });
const after = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2"); const after = (await (await GET(listReq())).json()).artists.find((a: { mbid: string }) => a.mbid === "a2");
expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 }); expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 });
}); });
it("GET ?mbid= returns the follow state for a single artist", async () => {
await prisma.watchedArtist.create({ data: { mbid: "followed-1", name: "Followed" } });
expect((await (await GET(listReq("?mbid=followed-1"))).json()).followed).toBe(true);
expect((await (await GET(listReq("?mbid=nope"))).json()).followed).toBe(false);
});
}); });
+9 -1
View File
@@ -2,7 +2,15 @@ import { prisma } from "@/lib/db";
import { browseReleaseGroups } from "@/lib/musicbrainz"; import { browseReleaseGroups } from "@/lib/musicbrainz";
import { isCoreRelease } from "@/lib/release-filter"; import { isCoreRelease } from "@/lib/release-filter";
export async function GET() { export async function GET(request: Request) {
// Lightweight follow-state check for a single artist (used by the album modal's Follow
// button) — avoids fetching the full watched list + discography.
const mbid = new URL(request.url).searchParams.get("mbid");
if (mbid) {
const followed = !!(await prisma.watchedArtist.findUnique({ where: { mbid } }));
return Response.json({ followed });
}
const artists = await prisma.watchedArtist.findMany({ const artists = await prisma.watchedArtist.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
+3
View File
@@ -194,6 +194,9 @@ nav.contents .sep { flex: 1; }
color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0; color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0;
} }
.sign-out:hover { color: var(--accent); } .sign-out:hover { color: var(--accent); }
.modal-artist { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.modal-artist a { color: inherit; text-decoration: none; border-bottom: 1.5px solid var(--rule-2); }
.modal-artist a:hover { border-bottom-color: var(--accent); color: var(--accent); }
/* ── Phase 2: page headers, list rows, tools ──────────── */ /* ── Phase 2: page headers, list rows, tools ──────────── */
.page-head { margin: 6px 0 26px; } .page-head { margin: 6px 0 26px; }
@@ -175,6 +175,7 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
artist: name, artist: name,
year: open.firstReleaseDate?.slice(0, 4) ?? null, year: open.firstReleaseDate?.slice(0, 4) ?? null,
type: open.primaryType, type: open.primaryType,
artistMbid: mbid,
}} }}
status={chipFor(open)} status={chipFor(open)}
actions={ actions={
+1 -1
View File
@@ -241,7 +241,7 @@ export function DiscoverClient() {
<AlbumModal <AlbumModal
open open
onClose={() => setAlbumModal(null)} onClose={() => setAlbumModal(null)}
album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null }} album={{ rgMbid: albumModal.rgMbid, album: albumModal.album ?? "", artist: albumModal.artistName, year: null, type: null, artistMbid: albumModal.artistMbid }}
actions={ actions={
<button <button
className="btn sm" className="btn sm"
+1
View File
@@ -338,6 +338,7 @@ export function LastfmClient() {
artist: albumTarget.match.artistName, artist: albumTarget.match.artistName,
year: albumTarget.match.firstReleaseDate?.slice(0, 4) ?? null, year: albumTarget.match.firstReleaseDate?.slice(0, 4) ?? null,
type: albumTarget.match.primaryType, type: albumTarget.match.primaryType,
artistMbid: albumTarget.match.artistMbid,
}} }}
status={ status={
owned ? ( owned ? (