From 17bed331a993603157e8e2b7a5b3aa9a1a5b862b Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 11:58:01 +0200 Subject: [PATCH] feat(web): Follow button + clickable artist in album modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/app/_ui/album-modal.tsx | 59 ++++++++++++++++++- web/src/app/api/artists/route.test.ts | 16 ++++- web/src/app/api/artists/route.ts | 10 +++- web/src/app/design.css | 3 + .../discover/artist/[mbid]/preview-client.tsx | 1 + web/src/app/discover/discover-client.tsx | 2 +- web/src/app/lastfm/lastfm-client.tsx | 1 + 7 files changed, 86 insertions(+), 6 deletions(-) diff --git a/web/src/app/_ui/album-modal.tsx b/web/src/app/_ui/album-modal.tsx index 235644a..266ac9a 100644 --- a/web/src/app/_ui/album-modal.tsx +++ b/web/src/app/_ui/album-modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { Modal } from "./modal"; import { CoverArt } from "./cover-art"; +import { toast } from "./toast"; type Track = { position: number; title: string; lengthMs: number | null }; @@ -22,6 +23,8 @@ export type AlbumInfo = { artist: string; year?: 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 @@ -40,6 +43,60 @@ export function AlbumModal({ actions?: ReactNode; }) { const [tracks, setTracks] = useState("loading"); + // null = unknown/loading; only meaningful when album.artistMbid is set. + const [followed, setFollowed] = useState(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 ? ( + + {album.artist} + + + ) : ( + album.artist + ); useEffect(() => { if (!open) return; @@ -69,7 +126,7 @@ export function AlbumModal({ }, [open, album.rgMbid]); return ( - +
diff --git a/web/src/app/api/artists/route.test.ts b/web/src/app/api/artists/route.test.ts index 5eb593f..0f80619 100644 --- a/web/src/app/api/artists/route.test.ts +++ b/web/src/app/api/artists/route.test.ts @@ -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); + }); }); diff --git a/web/src/app/api/artists/route.ts b/web/src/app/api/artists/route.ts index db206af..da7979a 100644 --- a/web/src/app/api/artists/route.ts +++ b/web/src/app/api/artists/route.ts @@ -2,7 +2,15 @@ import { prisma } from "@/lib/db"; import { browseReleaseGroups } from "@/lib/musicbrainz"; 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({ orderBy: { createdAt: "desc" }, include: { diff --git a/web/src/app/design.css b/web/src/app/design.css index d919eb3..d6410c9 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -194,6 +194,9 @@ nav.contents .sep { flex: 1; } color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0; } .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 ──────────── */ .page-head { margin: 6px 0 26px; } diff --git a/web/src/app/discover/artist/[mbid]/preview-client.tsx b/web/src/app/discover/artist/[mbid]/preview-client.tsx index 0f31644..8bc9073 100644 --- a/web/src/app/discover/artist/[mbid]/preview-client.tsx +++ b/web/src/app/discover/artist/[mbid]/preview-client.tsx @@ -175,6 +175,7 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName artist: name, year: open.firstReleaseDate?.slice(0, 4) ?? null, type: open.primaryType, + artistMbid: mbid, }} status={chipFor(open)} actions={ diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index 4e890f9..4698963 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -241,7 +241,7 @@ export function DiscoverClient() { 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={