e1033dab81
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|