feat(web): artists page — add-artist modal + filter watched list

The MusicBrainz search+follow moves into a modal opened by an "Add artist"
button; the main view gets a filter that narrows the watched roster (with a
"N of N" count). Follow keeps the modal open (toast per follow) so several can
be added in a row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 23:22:37 +02:00
parent 606ea82b70
commit 891793953f
+91 -46
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { PageHead } from "../_ui/page-head"; import { PageHead } from "../_ui/page-head";
import { SectionHeader } from "../_ui/section-header"; import { SectionHeader } from "../_ui/section-header";
import { Modal } from "../_ui/modal";
import { toast } from "../_ui/toast";
type Artist = { type Artist = {
id: string; id: string;
@@ -17,9 +19,12 @@ type Hit = { mbid: string; name: string; disambiguation: string };
export function ArtistsClient() { export function ArtistsClient() {
const [artists, setArtists] = useState<Artist[]>([]); const [artists, setArtists] = useState<Artist[]>([]);
const [filter, setFilter] = useState("");
const [showAdd, setShowAdd] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [hits, setHits] = useState<Hit[]>([]); const [hits, setHits] = useState<Hit[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
async function refresh() { async function refresh() {
const res = await fetch("/api/artists"); const res = await fetch("/api/artists");
@@ -36,20 +41,30 @@ export function ArtistsClient() {
try { try {
const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(query.trim())}`); const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(query.trim())}`);
setHits(res.ok ? (await res.json()).artists : []); setHits(res.ok ? (await res.json()).artists : []);
setSearched(true);
} finally { } finally {
setSearching(false); setSearching(false);
} }
} }
function openAdd() {
setShowAdd(true);
}
function closeAdd() {
setShowAdd(false);
setQuery("");
setHits([]);
setSearched(false);
}
async function follow(hit: Hit) { async function follow(hit: Hit) {
await fetch("/api/artists", { await fetch("/api/artists", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: hit.mbid, name: hit.name }), body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
}); });
setHits([]); toast(`Following ${hit.name}`);
setQuery(""); refresh(); // keep the modal open so several can be added; the hit flips to "Following"
refresh();
} }
async function toggleAuto(a: Artist) { async function toggleAuto(a: Artist) {
@@ -67,60 +82,41 @@ export function ArtistsClient() {
} }
const followedMbids = new Set(artists.map((a) => a.mbid)); 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]);
return ( return (
<div> <div>
<PageHead title="Artists" eyebrow="The roster · follow & watch" /> <PageHead title="Artists" eyebrow="The roster · follow & watch" />
<form className="request-form" onSubmit={search}> <div className="request-form">
<button type="button" className="btn accent" onClick={openAdd}>
Add artist
</button>
<label className="field"> <label className="field">
<span>Find an artist</span> <span>Filter</span>
<input <input
aria-label="artist search" aria-label="filter artists"
placeholder="Search MusicBrainz…" placeholder="Filter watched…"
value={query} value={filter}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setFilter(e.target.value)}
/> />
</label> </label>
<button type="submit" className="btn accent"> </div>
Search
</button>
{searching ? <span className="rmeta">searching</span> : null}
</form>
{hits.length > 0 ? ( <SectionHeader
<> title="Watching"
<SectionHeader title="Search results" note={`${hits.length} found`} /> note={filter ? `${shown.length} of ${artists.length}` : `${artists.length} artists`}
<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>
</>
) : null}
<SectionHeader title="Watching" note={`${artists.length} artists`} />
{artists.length === 0 ? ( {artists.length === 0 ? (
<p className="empty">Not following anyone yet. Search above and follow an artist to start watching their releases.</p> <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"> <ul className="list">
{artists.map((a) => ( {shown.map((a) => (
<li key={a.id} className="list-row"> <li key={a.id} className="list-row">
<div className="main"> <div className="main">
<div className="rtitle"> <div className="rtitle">
@@ -128,7 +124,8 @@ export function ArtistsClient() {
</div> </div>
<div className="rmeta"> <div className="rmeta">
<span className="score"> <span className="score">
{a.haveCount} have <span className="dot">·</span> {a.monitoredCount} monitored <span className="dot">·</span> {a.releaseCount} releases {a.haveCount} have <span className="dot">·</span> {a.monitoredCount} monitored{" "}
<span className="dot">·</span> {a.releaseCount} releases
</span> </span>
</div> </div>
</div> </div>
@@ -150,6 +147,54 @@ export function ArtistsClient() {
))} ))}
</ul> </ul>
)} )}
{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> </div>
); );
} }