Files
Lyra/web/src/app/_ui/artist-modal.tsx
T
Jonathan 7e5c083cef fix(web): surface a toast on failed mutations (#14)
Several client mutations either ignored a non-ok response (silent no-op) or
toasted success unconditionally even when the request failed. Sweep them so every
mutation gives feedback:

- artists: toggle auto-monitor, unfollow → error toast on failure.
- discography + wanted: toggle monitor / search-now → success or error toast.
- discover suggestion follow/want/dismiss → error toast (was toasting success
  even on failure).
- The Floor: queue request + retry → error toast on failure.
- artist-modal + preview follow/want, Last.fm want → error toast on failure
  (closes the deferred "want() shows no toast on non-ok" item).

web 153 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:25:31 +02:00

282 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { Modal } from "./modal";
import { CoverArt } from "./cover-art";
import { toast } from "./toast";
import type { ArtistPlays } from "@/lib/lastfm";
type Rel = {
rgMbid: string;
album: string;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
monitored: boolean;
have: boolean;
};
type Preview = { artistName: string; followed: boolean; releases: Rel[] };
type ReleaseGroupMatch = {
rgMbid: string;
title: string;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
artistMbid: string;
artistName: string;
};
/** Lightweight artist preview in a modal: follow + a cover-art grid of their releases with
* per-album Want. Reuses the discovery preview endpoint. The full preview page stays for
* deep links (linked here as "Full page"). When `lastfmName` is set (the /lastfm page only)
* it also shows the caller's most-played songs/albums for this artist, fetched independently
* of the preview above so it never blocks the existing grid. */
export function ArtistModal({
open,
onClose,
mbid,
initialName,
lastfmName,
}: {
open: boolean;
onClose: () => void;
mbid: string;
initialName: string;
lastfmName?: string;
}) {
const [data, setData] = useState<Preview | "loading" | "error">("loading");
const [followed, setFollowed] = useState(false);
const [wanted, setWanted] = useState<Set<string>>(new Set());
const [plays, setPlays] = useState<ArtistPlays | "loading" | "error" | null>(null);
const [wantedAlbums, setWantedAlbums] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open) return;
setData("loading");
setWanted(new Set());
let cancelled = false;
const qs = new URLSearchParams();
if (initialName) qs.set("name", initialName);
fetch(`/api/discover/preview/${mbid}?${qs.toString()}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: Preview) => {
if (cancelled) return;
setData(d);
setFollowed(d.followed);
})
.catch(() => !cancelled && setData("error"));
return () => {
cancelled = true;
};
}, [open, mbid, initialName]);
useEffect(() => {
if (!open || !lastfmName) {
setPlays(null);
setWantedAlbums(new Set());
return;
}
setPlays("loading");
setWantedAlbums(new Set());
let cancelled = false;
fetch(`/api/lastfm/artist-plays?artist=${encodeURIComponent(lastfmName)}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: ArtistPlays) => {
if (!cancelled) setPlays(d);
})
.catch(() => !cancelled && setPlays("error"));
return () => {
cancelled = true;
};
}, [open, lastfmName]);
const name = typeof data === "object" ? data.artistName || initialName : initialName;
async function follow() {
const res = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid, name }),
});
if (res.ok || res.status === 409) {
setFollowed(true);
toast(`Following ${name}`);
} else {
toast(`Couldn't follow ${name}`);
}
}
async function want(r: Rel) {
const res = await fetch("/api/discover/want", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
rgMbid: r.rgMbid,
artistMbid: mbid,
artistName: name,
album: r.album,
primaryType: r.primaryType,
secondaryTypes: r.secondaryTypes,
firstReleaseDate: r.firstReleaseDate,
}),
});
if (res.ok) {
setWanted((s) => new Set(s).add(r.rgMbid));
toast(`Added ${r.album}`);
} else {
toast(`Couldn't add ${r.album}`);
}
}
async function wantAlbum(albumName: string) {
const res = await fetch(
`/api/mb/release-group?artist=${encodeURIComponent(lastfmName!)}&album=${encodeURIComponent(albumName)}`,
);
if (!res.ok) {
toast(`No MusicBrainz match for ${albumName}`);
return;
}
const m: ReleaseGroupMatch = await res.json();
const wantRes = await fetch("/api/discover/want", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
rgMbid: m.rgMbid,
artistMbid: mbid,
artistName: name,
album: m.title,
primaryType: m.primaryType,
secondaryTypes: m.secondaryTypes,
firstReleaseDate: m.firstReleaseDate,
}),
});
if (wantRes.ok) {
setWantedAlbums((s) => new Set(s).add(albumName));
toast(`Added ${m.title}`);
}
}
return (
<Modal open={open} onClose={onClose} title="Artist" wide>
<div className="artist-modal-head">
<h2 className="album-modal-title">{name || "Artist"}</h2>
<div className="actions artist-modal-actions">
{followed ? <span className="following">Following</span> : (
<button className="btn accent sm" onClick={follow}>
Follow
</button>
)}
<a href={`/discover/artist/${mbid}?name=${encodeURIComponent(name)}`}>Full page </a>
</div>
</div>
{lastfmName ? (
<div className="am-lastfm">
{plays === "loading" ? <p className="muted-note">Loading your plays</p> : null}
{plays === "error" ? <p className="muted-note">Couldnt load your Last.fm plays.</p> : null}
{plays && typeof plays === "object" ? (
plays.topTracks.length === 0 && plays.topAlbums.length === 0 ? (
<p className="muted-note">No play data.</p>
) : (
<>
{plays.topTracks.length > 0 ? (
<>
<h3 className="subhead">Most played songs</h3>
<ol className="tracks">
{plays.topTracks.map((t, i) => (
<li key={t.name}>
<span className="tnum">{i + 1}</span>
<span className="tname">{t.name}</span>
<span>{t.plays} plays</span>
</li>
))}
</ol>
</>
) : null}
{plays.topAlbums.length > 0 ? (
<>
<h3 className="subhead">Most played albums</h3>
<ul className="list">
{plays.topAlbums.map((album) => {
const rel =
typeof data === "object"
? data.releases.find(
(r) => r.album.toLowerCase().trim() === album.name.toLowerCase().trim(),
)
: undefined;
const owned = rel?.have ?? false;
const working = (rel?.monitored ?? false) || wantedAlbums.has(album.name);
return (
<li key={album.name} className="list-row">
<div className="main">
<div className="rtitle">{album.name}</div>
<div className="rmeta">
<span>{album.plays} plays</span>
</div>
</div>
<div className="actions">
{owned ? (
<span className="chip done">In library</span>
) : working ? (
<span className="chip working">Wanted</span>
) : (
<button className="btn sm" onClick={() => wantAlbum(album.name)}>
Want
</button>
)}
</div>
</li>
);
})}
</ul>
</>
) : null}
{plays.sampled ? (
<p className="muted-note">Top of your most recent ~1000 scrobbles.</p>
) : null}
</>
)
) : null}
</div>
) : null}
{data === "loading" ? <p className="muted-note">Loading</p> : null}
{data === "error" ? <p className="muted-note">Couldnt load this artist.</p> : null}
{typeof data === "object" ? (
data.releases.length === 0 ? (
<p className="muted-note">No releases found.</p>
) : (
<div className="artist-modal-grid">
{data.releases.slice(0, 18).map((r) => {
const owned = r.have;
const monitored = r.monitored || wanted.has(r.rgMbid);
return (
<div key={r.rgMbid} className="am-rel">
<CoverArt rgMbid={r.rgMbid} alt={r.album} />
<div className="ac-title">{r.album}</div>
<div className="ac-meta">
{r.primaryType}
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""}
</div>
<div className="am-rel-action">
{owned ? (
<span className="chip done">In library</span>
) : monitored ? (
<span className="chip working">Wanted</span>
) : (
<button className="btn sm" onClick={() => want(r)}>
Want
</button>
)}
</div>
</div>
);
})}
</div>
)
) : null}
</Modal>
);
}