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,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>
|
||||||
|
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