feat(web): discovery modals — artist + album, not preview navigation
Clicking a suggested (or find-similar) artist opens an ArtistModal: Follow + a cover-art grid of their releases with per-album Want (reuses the preview endpoint), plus a "Full page" link to the deep-linkable preview route. Clicking a suggested album opens the AlbumModal (tracklist + Want). Lighter than jumping to a full preview page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "./modal";
|
||||
import { CoverArt } from "./cover-art";
|
||||
|
||||
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[] };
|
||||
|
||||
/** 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"). */
|
||||
export function ArtistModal({
|
||||
open,
|
||||
onClose,
|
||||
mbid,
|
||||
initialName,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
mbid: string;
|
||||
initialName: string;
|
||||
}) {
|
||||
const [data, setData] = useState<Preview | "loading" | "error">("loading");
|
||||
const [followed, setFollowed] = useState(false);
|
||||
const [wanted, setWanted] = 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]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user