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:
Jonathan
2026-07-12 01:46:13 +02:00
parent 9f2b115721
commit e1033dab81
2 changed files with 176 additions and 0 deletions
@@ -0,0 +1,20 @@
import { PreviewClient } from "./preview-client";
export default async function PreviewPage({
params,
searchParams,
}: {
params: Promise<{ mbid: string }>;
searchParams: Promise<{ name?: string }>;
}) {
const { mbid } = await params;
const { name } = await searchParams;
return (
<main>
<p>
<a href="/discover"> Discover</a> · <a href="/artists">Artists</a>
</p>
<PreviewClient mbid={mbid} initialName={name ?? ""} />
</main>
);
}
@@ -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>
Couldnt 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>Couldnt 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>
);
}