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 { 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>