"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 A–Z", 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([]); const [ownedNotFollowed, setOwnedNotFollowed] = useState([]); const [justFollowed, setJustFollowed] = useState>(new Set()); const [filter, setFilter] = useState(""); const [sort, setSort] = useState("followed"); const [showAdd, setShowAdd] = useState(false); const [query, setQuery] = useState(""); const [hits, setHits] = useState([]); 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 (
{artists.length === 0 ? (

Not following anyone yet. Use “Add artist” to search MusicBrainz and follow someone.

) : shown.length === 0 ? (

No watched artists match “{filter}”.

) : (
    {shown.map((a) => (
  • {a.haveCount} have · {a.monitoredCount} monitored{" "} · {a.releaseCount} releases
  • ))}
)} {ownedShown.length > 0 ? ( <>

Artists you already own music by but aren’t watching. Follow to monitor them for new releases and upgrades.

    {ownedShown.map((name) => (
  • {name}
  • ))}
) : null} {showAdd ? (
{searching ? searching… : null}
{hits.length > 0 ? (
    {hits.map((h) => (
  • {h.name} {h.disambiguation ? — {h.disambiguation} : null}
    {followedMbids.has(h.mbid) ? ( Following ) : ( )}
  • ))}
) : searched && !searching ? (

No matches on MusicBrainz.

) : null}
) : null}
); }