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 { 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user