feat(preview): /discover/artist/[mbid] preview page (disco + tracks + own-state)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type Release = {
|
||||
rgMbid: string;
|
||||
album: string;
|
||||
primaryType: string | null;
|
||||
secondaryTypes: string[];
|
||||
firstReleaseDate: string | null;
|
||||
monitored: boolean;
|
||||
have: boolean;
|
||||
};
|
||||
type Preview = { artistName: string; followed: boolean; releases: Release[] };
|
||||
type Track = { position: number; title: string; lengthMs: number | null };
|
||||
|
||||
function fmt(ms: number | null): string {
|
||||
if (ms == null) return "";
|
||||
const s = Math.round(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PreviewClient({ mbid, initialName }: { mbid: string; initialName: string }) {
|
||||
const [data, setData] = useState<Preview | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [followed, setFollowed] = useState(false);
|
||||
const [tracks, setTracks] = useState<Record<string, Track[] | "loading" | "error">>({});
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false);
|
||||
const qs = new URLSearchParams();
|
||||
if (initialName) qs.set("name", initialName);
|
||||
if (showAll) qs.set("all", "true");
|
||||
const res = await fetch(`/api/discover/preview/${mbid}?${qs.toString()}`);
|
||||
if (!res.ok) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
const d: Preview = await res.json();
|
||||
setData(d);
|
||||
setFollowed(d.followed);
|
||||
}, [mbid, initialName, showAll]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Scroll to the album an album-suggestion deep-linked to (#rg-<rgMbid>) once data is in.
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return;
|
||||
document.getElementById(hash.slice(1))?.scrollIntoView();
|
||||
}, [data]);
|
||||
|
||||
async function follow() {
|
||||
const name = data?.artistName || initialName;
|
||||
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: Release) {
|
||||
await fetch("/api/discover/want", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rgMbid: r.rgMbid,
|
||||
artistMbid: mbid,
|
||||
artistName: data?.artistName || initialName,
|
||||
album: r.album,
|
||||
primaryType: r.primaryType,
|
||||
secondaryTypes: r.secondaryTypes,
|
||||
firstReleaseDate: r.firstReleaseDate,
|
||||
}),
|
||||
});
|
||||
setData((d) =>
|
||||
d ? { ...d, releases: d.releases.map((x) => (x.rgMbid === r.rgMbid ? { ...x, monitored: true } : x)) } : d,
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleTracks(rgMbid: string) {
|
||||
if (tracks[rgMbid]) {
|
||||
setTracks((t) => {
|
||||
const n = { ...t };
|
||||
delete n[rgMbid];
|
||||
return n;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTracks((t) => ({ ...t, [rgMbid]: "loading" }));
|
||||
const res = await fetch(`/api/mb/release-groups/${rgMbid}/tracks`);
|
||||
if (!res.ok) {
|
||||
setTracks((t) => ({ ...t, [rgMbid]: "error" }));
|
||||
return;
|
||||
}
|
||||
const { tracks: ts } = await res.json();
|
||||
setTracks((t) => ({ ...t, [rgMbid]: ts }));
|
||||
}
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<p>
|
||||
Couldn’t load this artist. <button onClick={load}>Retry</button>
|
||||
</p>
|
||||
);
|
||||
if (!data) return <p>Loading…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.artistName || "Artist"}</h1>
|
||||
<p>
|
||||
{followed ? <span>Following</span> : <button onClick={follow}>Follow</button>}
|
||||
{" · "}
|
||||
<a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer">
|
||||
MusicBrainz ↗
|
||||
</a>
|
||||
</p>
|
||||
<label>
|
||||
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} /> Show all release
|
||||
types
|
||||
</label>
|
||||
<ul>
|
||||
{data.releases.map((r) => (
|
||||
<li key={r.rgMbid} id={`rg-${r.rgMbid}`}>
|
||||
<strong>{r.album}</strong>{" "}
|
||||
<small>
|
||||
{r.primaryType}
|
||||
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""}
|
||||
</small>{" "}
|
||||
{r.have ? <em>In library</em> : r.monitored ? <em>Monitored</em> : null}{" "}
|
||||
<button onClick={() => want(r)} disabled={r.have || r.monitored}>
|
||||
Want
|
||||
</button>{" "}
|
||||
<button onClick={() => toggleTracks(r.rgMbid)}>{tracks[r.rgMbid] ? "Hide tracks" : "Tracks"}</button>
|
||||
{tracks[r.rgMbid] === "loading" && <p>Loading tracks…</p>}
|
||||
{tracks[r.rgMbid] === "error" && <p>Couldn’t load tracks.</p>}
|
||||
{Array.isArray(tracks[r.rgMbid]) && (
|
||||
<ol>
|
||||
{(tracks[r.rgMbid] as Track[]).map((t) => (
|
||||
<li key={t.position}>
|
||||
{t.title} {t.lengthMs != null && <small>({fmt(t.lengthMs)})</small>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user