feat(web): Last.fm most-played songs + albums in the artist modal

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.
This commit is contained in:
Jonathan
2026-07-13 21:27:18 +02:00
parent 5816a17952
commit 2291308b90
2 changed files with 143 additions and 2 deletions
+136 -1
View File
@@ -4,6 +4,7 @@ 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;
@@ -16,23 +17,39 @@ type Rel = {
};
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"). */
* 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;
@@ -54,6 +71,26 @@ export function ArtistModal({
};
}, [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() {
@@ -88,6 +125,34 @@ export function ArtistModal({
}
}
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">
@@ -102,6 +167,76 @@ export function ArtistModal({
</div>
</div>
{lastfmName ? (
<div className="am-lastfm">
{plays === "loading" ? <p className="muted-note">Loading your plays</p> : null}
{plays === "error" ? <p className="muted-note">Couldnt 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">Couldnt load this artist.</p> : null}
{typeof data === "object" ? (