Files
Lyra/web/src/app/wanted/wanted-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

147 lines
4.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 { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header";
import { CoverArt } from "../_ui/cover-art";
import { AlbumModal } from "../_ui/album-modal";
import { toast } from "../_ui/toast";
type Wanted = {
id: string;
artistName: string;
artistMbid: string | null;
album: string;
state: string;
currentQualityClass: number | null;
lastSearchedAt: string | null;
rgMbid: string | null;
primaryType: string | null;
firstReleaseDate: string | null;
};
function stateLabel(w: Wanted): string {
if (w.currentQualityClass !== null) return `Have q${w.currentQualityClass} · upgrading`;
return w.state === "wanted" ? "Wanted" : w.state;
}
export function WantedClient() {
const [rows, setRows] = useState<Wanted[]>([]);
const [artist, setArtist] = useState("");
const [album, setAlbum] = useState("");
const [error, setError] = useState("");
const [open, setOpen] = useState<Wanted | null>(null);
async function refresh() {
const res = await fetch("/api/wanted");
setRows((await res.json()).wanted);
}
useEffect(() => {
refresh();
}, []);
async function add(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!artist.trim() || !album.trim()) return;
const res = await fetch("/api/wanted", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ artist, album }),
});
if (!res.ok) {
setError((await res.json()).error ?? "Couldnt add that — no MusicBrainz match?");
return;
}
toast("Added to wanted");
setArtist("");
setAlbum("");
refresh();
}
async function searchNow(w: Wanted) {
const res = await fetch(`/api/releases/${w.id}/search`, { method: "POST" }).catch(() => null);
toast(res?.ok ? `Searching for ${w.album}` : `Couldn't search for ${w.album}`);
refresh();
}
return (
<div>
<PageHead title="Wanted" eyebrow="Cutting list · acquire & upgrade" />
<form className="request-form" onSubmit={add}>
<label className="field">
<span>Artist</span>
<input aria-label="wanted artist" placeholder="Artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="wanted album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
</label>
<button type="submit" className="btn accent">
Add to wanted
</button>
{error ? <span className="notice">{error}</span> : null}
</form>
<SectionHeader title="Wanted" note={`${rows.length} on the list`} />
{rows.length === 0 ? (
<p className="empty">Nothing wanted yet. Add a release above, or send one over from an artists discography.</p>
) : (
<ul className="list">
{rows.map((w) => (
<li key={w.id} className="list-row">
<div className="main">
<button className="disco-open" onClick={() => setOpen(w)} aria-label={`Open ${w.album}`}>
<CoverArt rgMbid={w.rgMbid} alt={w.album} className="thumb" />
<span>
<span className="rtitle">
{w.album} <span className="artist">· {w.artistName}</span>
</span>
<span className="rmeta">
{w.lastSearchedAt ? `last tried ${new Date(w.lastSearchedAt).toLocaleDateString()}` : "never tried"}
</span>
</span>
</button>
</div>
<div className="actions">
<span className="chip">{stateLabel(w)}</span>
<button className="btn sm ghost" onClick={() => searchNow(w)}>
Search now
</button>
</div>
</li>
))}
</ul>
)}
{open ? (
<AlbumModal
open
onClose={() => setOpen(null)}
album={{
rgMbid: open.rgMbid,
album: open.album,
artist: open.artistName,
year: open.firstReleaseDate?.slice(0, 4) ?? null,
type: open.primaryType,
artistMbid: open.artistMbid,
}}
status={<span className="chip working">{stateLabel(open)}</span>}
actions={
<button
className="btn sm ghost"
onClick={() => {
searchNow(open);
setOpen(null);
}}
>
Search now
</button>
}
/>
) : null}
</div>
);
}