Files
Lyra/web/src/app/artists/artists-client.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

261 lines
8.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, useMemo, useState } from "react";
import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
import { Modal } from "../_ui/modal";
import { toast } from "../_ui/toast";
type Artist = {
id: string;
mbid: string;
name: string;
autoMonitorFuture: boolean;
releaseCount: number;
monitoredCount: number;
haveCount: number;
};
type Hit = { mbid: string; name: string; disambiguation: string };
export function ArtistsClient() {
const [artists, setArtists] = useState<Artist[]>([]);
const [ownedNotFollowed, setOwnedNotFollowed] = useState<string[]>([]);
const [justFollowed, setJustFollowed] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState("");
const [showAdd, setShowAdd] = useState(false);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
async function refresh() {
const res = await fetch("/api/artists");
const data = await res.json();
setArtists(data.artists);
setOwnedNotFollowed(data.ownedNotFollowed ?? []);
}
useEffect(() => {
refresh();
}, []);
async function search(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setSearching(true);
try {
const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(query.trim())}`);
setHits(res.ok ? (await res.json()).artists : []);
setSearched(true);
} finally {
setSearching(false);
}
}
function openAdd() {
setShowAdd(true);
}
function closeAdd() {
setShowAdd(false);
setQuery("");
setHits([]);
setSearched(false);
}
async function follow(hit: Hit) {
await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
});
toast(`Following ${hit.name}`);
refresh(); // keep the modal open so several can be added; the hit flips to "Following"
}
// Follow an owned-but-not-followed artist: resolve their name → MBID (the library only
// stores names), then follow with the canonical MB name. Mirrors the Last.fm/discover flow.
async function followOwned(name: string) {
const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(name)}`);
const hit = res.ok ? (await res.json()).artists?.[0] : null;
if (!hit?.mbid) {
toast(`No MusicBrainz match for ${name}`);
return;
}
const r = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
});
if (r.ok || r.status === 409) {
setJustFollowed((s) => new Set(s).add(name)); // hide it immediately, even if names differ
toast(`Following ${hit.name}`);
refresh();
} else {
toast(`Couldn't follow ${name}`);
}
}
async function toggleAuto(a: Artist) {
const res = await fetch(`/api/artists/${a.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ autoMonitorFuture: !a.autoMonitorFuture }),
}).catch(() => null);
if (!res?.ok) toast(`Couldn't update ${a.name}`);
refresh();
}
async function unfollow(a: Artist) {
const res = await fetch(`/api/artists/${a.id}`, { method: "DELETE" }).catch(() => null);
if (!res?.ok) toast(`Couldn't unfollow ${a.name}`);
refresh();
}
const followedMbids = new Set(artists.map((a) => a.mbid));
const shown = useMemo(() => {
const n = filter.trim().toLowerCase();
return n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
}, [artists, filter]);
const ownedShown = useMemo(
() => ownedNotFollowed.filter((name) => !justFollowed.has(name)),
[ownedNotFollowed, justFollowed],
);
return (
<div>
<PageHead title="Artists" eyebrow="The roster · follow & watch" />
<div className="request-form">
<button type="button" className="btn accent" onClick={openAdd}>
Add artist
</button>
<label className="field">
<span>Filter</span>
<input
aria-label="filter artists"
placeholder="Filter watched…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</label>
</div>
<SectionHeader
title="Watching"
note={filter ? `${shown.length} of ${artists.length}` : `${artists.length} artists`}
/>
{artists.length === 0 ? (
<p className="empty">Not following anyone yet. Use Add artist to search MusicBrainz and follow someone.</p>
) : shown.length === 0 ? (
<p className="empty">No watched artists match {filter}.</p>
) : (
<ul className="list">
{shown.map((a) => (
<li key={a.id} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/artists/${a.id}`}>{a.name}</a>
</div>
<div className="rmeta">
<span className="score">
{a.haveCount} have <span className="dot">·</span> {a.monitoredCount} monitored{" "}
<span className="dot">·</span> {a.releaseCount} releases
</span>
</div>
</div>
<div className="actions">
<label className="toggle">
<input
type="checkbox"
aria-label={`auto-monitor ${a.name}`}
checked={a.autoMonitorFuture}
onChange={() => toggleAuto(a)}
/>
auto-monitor
</label>
<button className="btn sm ghost" onClick={() => unfollow(a)}>
Unfollow
</button>
</div>
</li>
))}
</ul>
)}
{ownedShown.length > 0 ? (
<>
<SectionHeader
title="In library, not followed"
note={`${ownedShown.length}`}
/>
<p className="muted-note">
Artists you already own music by but arent watching. Follow to monitor them for new
releases and upgrades.
</p>
<ul className="list">
{ownedShown.map((name) => (
<li key={name} className="list-row">
<div className="main">
<div className="rtitle">{name}</div>
</div>
<div className="actions">
<button className="btn sm" onClick={() => followOwned(name)}>
Follow
</button>
</div>
</li>
))}
</ul>
</>
) : null}
{showAdd ? (
<Modal open onClose={closeAdd} title="Add artist">
<form className="request-form" style={{ margin: "0 0 6px" }} onSubmit={search}>
<label className="field">
<span>Find an artist</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
aria-label="artist search"
placeholder="Search MusicBrainz…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</label>
<button type="submit" className="btn accent">
Search
</button>
{searching ? <span className="rmeta">searching</span> : null}
</form>
{hits.length > 0 ? (
<ul className="list">
{hits.map((h) => (
<li key={h.mbid} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/discover/artist/${h.mbid}?name=${encodeURIComponent(h.name)}`}>{h.name}</a>
{h.disambiguation ? <span className="dim"> {h.disambiguation}</span> : null}
</div>
</div>
<div className="actions">
{followedMbids.has(h.mbid) ? (
<span className="following">Following</span>
) : (
<button className="btn sm" onClick={() => follow(h)}>
Follow
</button>
)}
</div>
</li>
))}
</ul>
) : searched && !searching ? (
<p className="muted-note">No matches on MusicBrainz.</p>
) : null}
</Modal>
) : null}
</div>
);
}