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
+13 -3
View File
@@ -8,6 +8,10 @@ import { GET, POST } from "./route";
const mockBrowse = vi.mocked(browseReleaseGroups);
afterEach(() => mockBrowse.mockReset());
function listReq(qs = "") {
return new Request(`http://localhost/api/artists${qs}`);
}
function postReq(body: unknown) {
return new Request("http://localhost/api/artists", {
method: "POST",
@@ -52,7 +56,7 @@ describe("artists collection API", () => {
// mark the one release as monitored + have, to exercise the counts
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);
const a = (await res.json()).artists[0];
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 });
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 });
});
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);
});
});