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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user