Files
Lyra/web/src/app/artists/artists-client.tsx
T
Jonathan 2c3d6e77d5 feat(artists): sort control on the roster
Add a Sort dropdown to /artists next to the filter: Recently followed
(default, createdAt desc — the prior order), Name A–Z, Most in library,
Most monitored, Most releases. Client-side sort of the already-fetched
roster; the filter box and "In library, not followed" section are
unchanged. Exposes WatchedArtist.createdAt on /api/artists for the sort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:46:24 +02:00

282 lines
9.9 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, 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;
mbid: string;
name: string;
createdAt: string;
autoMonitorFuture: boolean;
releaseCount: number;
monitoredCount: number;
haveCount: number;
};
type Hit = { mbid: string; name: string; disambiguation: string };
type SortKey = "followed" | "name" | "have" | "monitored" | "releases";
const SORTS: { key: SortKey; label: string; cmp: (a: Artist, b: Artist) => number }[] = [
{ key: "followed", label: "Recently followed", cmp: (a, b) => b.createdAt.localeCompare(a.createdAt) },
{ key: "name", label: "Name AZ", cmp: (a, b) => a.name.localeCompare(b.name) },
{ key: "have", label: "Most in library", cmp: (a, b) => b.haveCount - a.haveCount || a.name.localeCompare(b.name) },
{ key: "monitored", label: "Most monitored", cmp: (a, b) => b.monitoredCount - a.monitoredCount || a.name.localeCompare(b.name) },
{ key: "releases", label: "Most releases", cmp: (a, b) => b.releaseCount - a.releaseCount || a.name.localeCompare(b.name) },
];
export function ArtistsClient() {
const [artists, setArtists] = useState<Artist[]>([]);
const [ownedNotFollowed, setOwnedNotFollowed] = useState<string[]>([]);
const [justFollowed, setJustFollowed] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState("");
const [sort, setSort] = useState<SortKey>("followed");
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");
const data = await res.json();
setArtists(data.artists);
setOwnedNotFollowed(data.ownedNotFollowed ?? []);
}
useEffect(() => {
refresh();
}, []);
async function search(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setSearching(true);
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 }),
});
toast(`Following ${hit.name}`);
refresh(); // keep the modal open so several can be added; the hit flips to "Following"
}
// Follow an owned-but-not-followed artist: resolve their name → MBID (the library only
// stores names), then follow with the canonical MB name. Mirrors the Last.fm/discover flow.
async function followOwned(name: string) {
const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(name)}`);
const hit = res.ok ? (await res.json()).artists?.[0] : null;
if (!hit?.mbid) {
toast(`No MusicBrainz match for ${name}`);
return;
}
const r = await fetch("/api/artists", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
});
if (r.ok || r.status === 409) {
setJustFollowed((s) => new Set(s).add(name)); // hide it immediately, even if names differ
toast(`Following ${hit.name}`);
refresh();
} else {
toast(`Couldn't follow ${name}`);
}
}
async function toggleAuto(a: Artist) {
const res = await fetch(`/api/artists/${a.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ autoMonitorFuture: !a.autoMonitorFuture }),
}).catch(() => null);
if (!res?.ok) toast(`Couldn't update ${a.name}`);
refresh();
}
async function unfollow(a: Artist) {
const res = await fetch(`/api/artists/${a.id}`, { method: "DELETE" }).catch(() => null);
if (!res?.ok) toast(`Couldn't unfollow ${a.name}`);
refresh();
}
const followedMbids = new Set(artists.map((a) => a.mbid));
const shown = useMemo(() => {
const n = filter.trim().toLowerCase();
const filtered = n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp;
return [...filtered].sort(cmp);
}, [artists, filter, sort]);
const ownedShown = useMemo(
() => ownedNotFollowed.filter((name) => !justFollowed.has(name)),
[ownedNotFollowed, justFollowed],
);
return (
<div>
<PageHead title="Artists" eyebrow="The roster · follow & watch" />
<div className="request-form">
<button type="button" className="btn accent" onClick={openAdd}>
Add artist
</button>
<label className="field">
<span>Filter</span>
<input
aria-label="filter artists"
placeholder="Filter watched…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</label>
<label className="field">
<span>Sort</span>
<select aria-label="sort artists" value={sort} onChange={(e) => setSort(e.target.value as SortKey)}>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
</label>
</div>
<SectionHeader
title="Watching"
note={filter ? `${shown.length} of ${artists.length}` : `${artists.length} artists`}
/>
{artists.length === 0 ? (
<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">
{shown.map((a) => (
<li key={a.id} className="list-row">
<div className="main">
<div className="rtitle">
<a href={`/artists/${a.id}`}>{a.name}</a>
</div>
<div className="rmeta">
<span className="score">
{a.haveCount} have <span className="dot">·</span> {a.monitoredCount} monitored{" "}
<span className="dot">·</span> {a.releaseCount} releases
</span>
</div>
</div>
<div className="actions">
<label className="toggle">
<input
type="checkbox"
aria-label={`auto-monitor ${a.name}`}
checked={a.autoMonitorFuture}
onChange={() => toggleAuto(a)}
/>
auto-monitor
</label>
<button className="btn sm ghost" onClick={() => unfollow(a)}>
Unfollow
</button>
</div>
</li>
))}
</ul>
)}
{ownedShown.length > 0 ? (
<>
<SectionHeader
title="In library, not followed"
note={`${ownedShown.length}`}
/>
<p className="muted-note">
Artists you already own music by but arent watching. Follow to monitor them for new
releases and upgrades.
</p>
<ul className="list">
{ownedShown.map((name) => (
<li key={name} className="list-row">
<div className="main">
<div className="rtitle">{name}</div>
</div>
<div className="actions">
<button className="btn sm" onClick={() => followOwned(name)}>
Follow
</button>
</div>
</li>
))}
</ul>
</>
) : null}
{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>
);
}