2291308b90
Adds an optional lastfmName prop to ArtistModal. When set (only on /lastfm) it fetches /api/lastfm/artist-plays independently of the existing preview fetch and renders a most-played-songs list plus a most-played-albums list above the release grid. Album Want resolves via /api/mb/release-group then POSTs /api/discover/want, mirroring the existing want(r) flow. Own-state chips name-match Last.fm albums against the already-loaded release grid. Other ArtistModal callers (discover-client.tsx) pass no lastfmName, so their behavior is unchanged.
278 lines
9.6 KiB
TypeScript
278 lines
9.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Modal } from "./modal";
|
||
import { CoverArt } from "./cover-art";
|
||
import { toast } from "./toast";
|
||
import type { ArtistPlays } from "@/lib/lastfm";
|
||
|
||
type Rel = {
|
||
rgMbid: string;
|
||
album: string;
|
||
primaryType: string | null;
|
||
secondaryTypes: string[];
|
||
firstReleaseDate: string | null;
|
||
monitored: boolean;
|
||
have: boolean;
|
||
};
|
||
type Preview = { artistName: string; followed: boolean; releases: Rel[] };
|
||
|
||
type ReleaseGroupMatch = {
|
||
rgMbid: string;
|
||
title: string;
|
||
primaryType: string | null;
|
||
secondaryTypes: string[];
|
||
firstReleaseDate: string | null;
|
||
artistMbid: string;
|
||
artistName: string;
|
||
};
|
||
|
||
/** Lightweight artist preview in a modal: follow + a cover-art grid of their releases with
|
||
* per-album Want. Reuses the discovery preview endpoint. The full preview page stays for
|
||
* deep links (linked here as "Full page"). When `lastfmName` is set (the /lastfm page only)
|
||
* it also shows the caller's most-played songs/albums for this artist, fetched independently
|
||
* of the preview above so it never blocks the existing grid. */
|
||
export function ArtistModal({
|
||
open,
|
||
onClose,
|
||
mbid,
|
||
initialName,
|
||
lastfmName,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
mbid: string;
|
||
initialName: string;
|
||
lastfmName?: string;
|
||
}) {
|
||
const [data, setData] = useState<Preview | "loading" | "error">("loading");
|
||
const [followed, setFollowed] = useState(false);
|
||
const [wanted, setWanted] = useState<Set<string>>(new Set());
|
||
const [plays, setPlays] = useState<ArtistPlays | "loading" | "error" | null>(null);
|
||
const [wantedAlbums, setWantedAlbums] = useState<Set<string>>(new Set());
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setData("loading");
|
||
setWanted(new Set());
|
||
let cancelled = false;
|
||
const qs = new URLSearchParams();
|
||
if (initialName) qs.set("name", initialName);
|
||
fetch(`/api/discover/preview/${mbid}?${qs.toString()}`)
|
||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||
.then((d: Preview) => {
|
||
if (cancelled) return;
|
||
setData(d);
|
||
setFollowed(d.followed);
|
||
})
|
||
.catch(() => !cancelled && setData("error"));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open, mbid, initialName]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !lastfmName) {
|
||
setPlays(null);
|
||
setWantedAlbums(new Set());
|
||
return;
|
||
}
|
||
setPlays("loading");
|
||
setWantedAlbums(new Set());
|
||
let cancelled = false;
|
||
fetch(`/api/lastfm/artist-plays?artist=${encodeURIComponent(lastfmName)}`)
|
||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||
.then((d: ArtistPlays) => {
|
||
if (!cancelled) setPlays(d);
|
||
})
|
||
.catch(() => !cancelled && setPlays("error"));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open, lastfmName]);
|
||
|
||
const name = typeof data === "object" ? data.artistName || initialName : initialName;
|
||
|
||
async function follow() {
|
||
const res = await fetch("/api/artists", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ mbid, name }),
|
||
});
|
||
if (res.ok || res.status === 409) {
|
||
setFollowed(true);
|
||
toast(`Following ${name}`);
|
||
}
|
||
}
|
||
|
||
async function want(r: Rel) {
|
||
const res = await fetch("/api/discover/want", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
rgMbid: r.rgMbid,
|
||
artistMbid: mbid,
|
||
artistName: name,
|
||
album: r.album,
|
||
primaryType: r.primaryType,
|
||
secondaryTypes: r.secondaryTypes,
|
||
firstReleaseDate: r.firstReleaseDate,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setWanted((s) => new Set(s).add(r.rgMbid));
|
||
toast(`Added ${r.album}`);
|
||
}
|
||
}
|
||
|
||
async function wantAlbum(albumName: string) {
|
||
const res = await fetch(
|
||
`/api/mb/release-group?artist=${encodeURIComponent(lastfmName!)}&album=${encodeURIComponent(albumName)}`,
|
||
);
|
||
if (!res.ok) {
|
||
toast(`No MusicBrainz match for ${albumName}`);
|
||
return;
|
||
}
|
||
const m: ReleaseGroupMatch = await res.json();
|
||
const wantRes = await fetch("/api/discover/want", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
rgMbid: m.rgMbid,
|
||
artistMbid: mbid,
|
||
artistName: name,
|
||
album: m.title,
|
||
primaryType: m.primaryType,
|
||
secondaryTypes: m.secondaryTypes,
|
||
firstReleaseDate: m.firstReleaseDate,
|
||
}),
|
||
});
|
||
if (wantRes.ok) {
|
||
setWantedAlbums((s) => new Set(s).add(albumName));
|
||
toast(`Added ${m.title}`);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal open={open} onClose={onClose} title="Artist" wide>
|
||
<div className="artist-modal-head">
|
||
<h2 className="album-modal-title">{name || "Artist"}</h2>
|
||
<div className="actions artist-modal-actions">
|
||
{followed ? <span className="following">Following</span> : (
|
||
<button className="btn accent sm" onClick={follow}>
|
||
Follow
|
||
</button>
|
||
)}
|
||
<a href={`/discover/artist/${mbid}?name=${encodeURIComponent(name)}`}>Full page ↗</a>
|
||
</div>
|
||
</div>
|
||
|
||
{lastfmName ? (
|
||
<div className="am-lastfm">
|
||
{plays === "loading" ? <p className="muted-note">Loading your plays…</p> : null}
|
||
{plays === "error" ? <p className="muted-note">Couldn’t load your Last.fm plays.</p> : null}
|
||
{plays && typeof plays === "object" ? (
|
||
plays.topTracks.length === 0 && plays.topAlbums.length === 0 ? (
|
||
<p className="muted-note">No play data.</p>
|
||
) : (
|
||
<>
|
||
{plays.topTracks.length > 0 ? (
|
||
<>
|
||
<h3 className="subhead">Most played songs</h3>
|
||
<ol className="tracks">
|
||
{plays.topTracks.map((t, i) => (
|
||
<li key={t.name}>
|
||
<span className="tnum">{i + 1}</span>
|
||
<span className="tname">{t.name}</span>
|
||
<span>{t.plays} plays</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</>
|
||
) : null}
|
||
{plays.topAlbums.length > 0 ? (
|
||
<>
|
||
<h3 className="subhead">Most played albums</h3>
|
||
<ul className="list">
|
||
{plays.topAlbums.map((album) => {
|
||
const rel =
|
||
typeof data === "object"
|
||
? data.releases.find(
|
||
(r) => r.album.toLowerCase().trim() === album.name.toLowerCase().trim(),
|
||
)
|
||
: undefined;
|
||
const owned = rel?.have ?? false;
|
||
const working = (rel?.monitored ?? false) || wantedAlbums.has(album.name);
|
||
return (
|
||
<li key={album.name} className="list-row">
|
||
<div className="main">
|
||
<div className="rtitle">{album.name}</div>
|
||
<div className="rmeta">
|
||
<span>{album.plays} plays</span>
|
||
</div>
|
||
</div>
|
||
<div className="actions">
|
||
{owned ? (
|
||
<span className="chip done">In library</span>
|
||
) : working ? (
|
||
<span className="chip working">Wanted</span>
|
||
) : (
|
||
<button className="btn sm" onClick={() => wantAlbum(album.name)}>
|
||
Want
|
||
</button>
|
||
)}
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</>
|
||
) : null}
|
||
{plays.sampled ? (
|
||
<p className="muted-note">Top of your most recent ~1000 scrobbles.</p>
|
||
) : null}
|
||
</>
|
||
)
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{data === "loading" ? <p className="muted-note">Loading…</p> : null}
|
||
{data === "error" ? <p className="muted-note">Couldn’t load this artist.</p> : null}
|
||
{typeof data === "object" ? (
|
||
data.releases.length === 0 ? (
|
||
<p className="muted-note">No releases found.</p>
|
||
) : (
|
||
<div className="artist-modal-grid">
|
||
{data.releases.slice(0, 18).map((r) => {
|
||
const owned = r.have;
|
||
const monitored = r.monitored || wanted.has(r.rgMbid);
|
||
return (
|
||
<div key={r.rgMbid} className="am-rel">
|
||
<CoverArt rgMbid={r.rgMbid} alt={r.album} />
|
||
<div className="ac-title">{r.album}</div>
|
||
<div className="ac-meta">
|
||
{r.primaryType}
|
||
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""}
|
||
</div>
|
||
<div className="am-rel-action">
|
||
{owned ? (
|
||
<span className="chip done">In library</span>
|
||
) : monitored ? (
|
||
<span className="chip working">Wanted</span>
|
||
) : (
|
||
<button className="btn sm" onClick={() => want(r)}>
|
||
Want
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)
|
||
) : null}
|
||
</Modal>
|
||
);
|
||
}
|