7e5c083cef
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>
261 lines
8.7 KiB
TypeScript
261 lines
8.7 KiB
TypeScript
"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 aren’t 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>
|
||
);
|
||
}
|