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";
import { useEffect, useState } from "react";
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;
@@ -17,9 +19,12 @@ type Hit = { mbid: string; name: string; disambiguation: string };
export function ArtistsClient() {
const [artists, setArtists] = useState<Artist[]>([]);
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");
@@ -36,20 +41,30 @@ export function ArtistsClient() {
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 }),
});
setHits([]);
setQuery("");
refresh();
toast(`Following ${hit.name}`);
refresh(); // keep the modal open so several can be added; the hit flips to "Following"
}
async function toggleAuto(a: Artist) {
@@ -67,60 +82,41 @@ export function ArtistsClient() {
}
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 (
<div>
<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">
<span>Find an artist</span>
<span>Filter</span>
<input
aria-label="artist search"
placeholder="Search MusicBrainz…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="filter artists"
placeholder="Filter watched…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</label>
<button type="submit" className="btn accent">
Search
</button>
{searching ? <span className="rmeta">searching</span> : null}
</form>
</div>
{hits.length > 0 ? (
<>
<SectionHeader title="Search results" note={`${hits.length} found`} />
<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`} />
<SectionHeader
title="Watching"
note={filter ? `${shown.length} of ${artists.length}` : `${artists.length} artists`}
/>
{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">
{artists.map((a) => (
{shown.map((a) => (
<li key={a.id} className="list-row">
<div className="main">
<div className="rtitle">
@@ -128,7 +124,8 @@ export function ArtistsClient() {
</div>
<div className="rmeta">
<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>
</div>
</div>
@@ -150,6 +147,54 @@ export function ArtistsClient() {
))}
</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>
);
}