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