From e1033dab81ce8ae5f81b781fa77535481123a0e4 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 01:46:13 +0200 Subject: [PATCH] feat(preview): /discover/artist/[mbid] preview page (disco + tracks + own-state) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/discover/artist/[mbid]/page.tsx | 20 +++ .../discover/artist/[mbid]/preview-client.tsx | 156 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 web/src/app/discover/artist/[mbid]/page.tsx create mode 100644 web/src/app/discover/artist/[mbid]/preview-client.tsx diff --git a/web/src/app/discover/artist/[mbid]/page.tsx b/web/src/app/discover/artist/[mbid]/page.tsx new file mode 100644 index 0000000..057c0ba --- /dev/null +++ b/web/src/app/discover/artist/[mbid]/page.tsx @@ -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 ( +
+

+ ← Discover · Artists +

+ +
+ ); +} diff --git a/web/src/app/discover/artist/[mbid]/preview-client.tsx b/web/src/app/discover/artist/[mbid]/preview-client.tsx new file mode 100644 index 0000000..5816790 --- /dev/null +++ b/web/src/app/discover/artist/[mbid]/preview-client.tsx @@ -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(null); + const [showAll, setShowAll] = useState(false); + const [followed, setFollowed] = useState(false); + const [tracks, setTracks] = useState>({}); + 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-) 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 ( +

+ Couldn’t load this artist. +

+ ); + if (!data) return

Loading…

; + + return ( +
+

{data.artistName || "Artist"}

+

+ {followed ? Following : } + {" · "} + + MusicBrainz ↗ + +

+ +
    + {data.releases.map((r) => ( +
  • + {r.album}{" "} + + {r.primaryType} + {r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""} + {" "} + {r.have ? In library : r.monitored ? Monitored : null}{" "} + {" "} + + {tracks[r.rgMbid] === "loading" &&

    Loading tracks…

    } + {tracks[r.rgMbid] === "error" &&

    Couldn’t load tracks.

    } + {Array.isArray(tracks[r.rgMbid]) && ( +
      + {(tracks[r.rgMbid] as Track[]).map((t) => ( +
    1. + {t.title} {t.lengthMs != null && ({fmt(t.lengthMs)})} +
    2. + ))} +
    + )} +
  • + ))} +
+
+ ); +}