Files
Lyra/web/src/app/_ui/artist-modal.tsx
T
Jonathan 2291308b90 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.
2026-07-13 21:27:18 +02:00

278 lines
9.6 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, 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">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" ? (
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>
);
}