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:
@@ -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<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(() => {
|
||||
if (!open) return;
|
||||
@@ -69,7 +126,7 @@ export function AlbumModal({
|
||||
}, [open, album.rgMbid]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={album.artist} wide>
|
||||
<Modal open={open} onClose={onClose} title={titleNode} wide>
|
||||
<div className="album-modal-head">
|
||||
<CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" />
|
||||
<div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -241,7 +241,7 @@ export function DiscoverClient() {
|
||||
<AlbumModal
|
||||
open
|
||||
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={
|
||||
<button
|
||||
className="btn sm"
|
||||
|
||||
@@ -338,6 +338,7 @@ export function LastfmClient() {
|
||||
artist: albumTarget.match.artistName,
|
||||
year: albumTarget.match.firstReleaseDate?.slice(0, 4) ?? null,
|
||||
type: albumTarget.match.primaryType,
|
||||
artistMbid: albumTarget.match.artistMbid,
|
||||
}}
|
||||
status={
|
||||
owned ? (
|
||||
|
||||
Reference in New Issue
Block a user