feat(web): rework preview page onto tabs + covers + album modal
The standalone /discover/artist/[mbid] preview page was an inconsistent orphan (no art, no tabs, old inline TRACKS toggle). Rebuild it to mirror the artist-detail page: category tabs, cover thumbnails, and the album modal for tracks, with per-release Want. Extract shared categoryOf/TAB_ORDER util. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
export type Category = "Albums" | "Singles" | "EPs" | "Live" | "Comps" | "Other";
|
||||||
|
|
||||||
|
export const TAB_ORDER: Category[] = ["Albums", "Singles", "EPs", "Live", "Comps", "Other"];
|
||||||
|
|
||||||
|
/** Bucket a release-group into a discography category, for the artist-detail + preview tabs. */
|
||||||
|
export function categoryOf(r: { primaryType: string | null; secondaryTypes: string[] }): Category {
|
||||||
|
if (r.secondaryTypes.includes("Live")) return "Live";
|
||||||
|
if (r.secondaryTypes.includes("Compilation")) return "Comps";
|
||||||
|
if (r.secondaryTypes.length > 0) return "Other";
|
||||||
|
if (r.primaryType === "Album") return "Albums";
|
||||||
|
if (r.primaryType === "Single") return "Singles";
|
||||||
|
if (r.primaryType === "EP") return "EPs";
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { PageHead } from "../../_ui/page-head";
|
import { PageHead } from "../../_ui/page-head";
|
||||||
import { CoverArt } from "../../_ui/cover-art";
|
import { CoverArt } from "../../_ui/cover-art";
|
||||||
import { AlbumModal } from "../../_ui/album-modal";
|
import { AlbumModal } from "../../_ui/album-modal";
|
||||||
|
import { categoryOf, TAB_ORDER, type Category } from "../../_ui/categories";
|
||||||
|
|
||||||
type Release = {
|
type Release = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,19 +19,6 @@ type Release = {
|
|||||||
};
|
};
|
||||||
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
||||||
|
|
||||||
type Category = "Albums" | "Singles" | "EPs" | "Live" | "Comps" | "Other";
|
|
||||||
const TAB_ORDER: Category[] = ["Albums", "Singles", "EPs", "Live", "Comps", "Other"];
|
|
||||||
|
|
||||||
function categoryOf(r: Release): Category {
|
|
||||||
if (r.secondaryTypes.includes("Live")) return "Live";
|
|
||||||
if (r.secondaryTypes.includes("Compilation")) return "Comps";
|
|
||||||
if (r.secondaryTypes.length > 0) return "Other";
|
|
||||||
if (r.primaryType === "Album") return "Albums";
|
|
||||||
if (r.primaryType === "Single") return "Singles";
|
|
||||||
if (r.primaryType === "EP") return "EPs";
|
|
||||||
return "Other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function badge(r: Release): { label: string; cls: string } {
|
function badge(r: Release): { label: string; cls: string } {
|
||||||
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
|
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
|
||||||
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
|
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { PageHead } from "../../../_ui/page-head";
|
import { PageHead } from "../../../_ui/page-head";
|
||||||
|
import { CoverArt } from "../../../_ui/cover-art";
|
||||||
|
import { AlbumModal } from "../../../_ui/album-modal";
|
||||||
|
import { categoryOf, TAB_ORDER, type Category } from "../../../_ui/categories";
|
||||||
|
import { toast } from "../../../_ui/toast";
|
||||||
|
|
||||||
type Release = {
|
type Release = {
|
||||||
rgMbid: string;
|
rgMbid: string;
|
||||||
@@ -13,26 +17,19 @@ type Release = {
|
|||||||
have: boolean;
|
have: boolean;
|
||||||
};
|
};
|
||||||
type Preview = { artistName: string; followed: boolean; releases: Release[] };
|
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 }) {
|
export function PreviewClient({ mbid, initialName }: { mbid: string; initialName: string }) {
|
||||||
const [data, setData] = useState<Preview | null>(null);
|
const [data, setData] = useState<Preview | null>(null);
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const [followed, setFollowed] = useState(false);
|
const [followed, setFollowed] = useState(false);
|
||||||
const [tracks, setTracks] = useState<Record<string, Track[] | "loading" | "error">>({});
|
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [tab, setTab] = useState<"All" | Category>("Albums");
|
||||||
|
const [wanted, setWanted] = useState<Set<string>>(new Set());
|
||||||
|
const [open, setOpen] = useState<Release | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(false);
|
setError(false);
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams({ all: "true" });
|
||||||
if (initialName) qs.set("name", initialName);
|
if (initialName) qs.set("name", initialName);
|
||||||
if (showAll) qs.set("all", "true");
|
|
||||||
const res = await fetch(`/api/discover/preview/${mbid}?${qs.toString()}`);
|
const res = await fetch(`/api/discover/preview/${mbid}?${qs.toString()}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(true);
|
setError(true);
|
||||||
@@ -41,28 +38,24 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
|||||||
const d: Preview = await res.json();
|
const d: Preview = await res.json();
|
||||||
setData(d);
|
setData(d);
|
||||||
setFollowed(d.followed);
|
setFollowed(d.followed);
|
||||||
}, [mbid, initialName, showAll]);
|
}, [mbid, initialName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Scroll to the album an album-suggestion deep-linked to (#rg-<rgMbid>) once data is in.
|
const name = data?.artistName || initialName;
|
||||||
useEffect(() => {
|
|
||||||
if (!data) return;
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (!hash) return;
|
|
||||||
document.getElementById(hash.slice(1))?.scrollIntoView();
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
async function follow() {
|
async function follow() {
|
||||||
const name = data?.artistName || initialName;
|
|
||||||
const res = await fetch("/api/artists", {
|
const res = await fetch("/api/artists", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ mbid, name }),
|
body: JSON.stringify({ mbid, name }),
|
||||||
});
|
});
|
||||||
if (res.ok || res.status === 409) setFollowed(true);
|
if (res.ok || res.status === 409) {
|
||||||
|
setFollowed(true);
|
||||||
|
toast(`Following ${name}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function want(r: Release) {
|
async function want(r: Release) {
|
||||||
@@ -72,36 +65,31 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
rgMbid: r.rgMbid,
|
rgMbid: r.rgMbid,
|
||||||
artistMbid: mbid,
|
artistMbid: mbid,
|
||||||
artistName: data?.artistName || initialName,
|
artistName: name,
|
||||||
album: r.album,
|
album: r.album,
|
||||||
primaryType: r.primaryType,
|
primaryType: r.primaryType,
|
||||||
secondaryTypes: r.secondaryTypes,
|
secondaryTypes: r.secondaryTypes,
|
||||||
firstReleaseDate: r.firstReleaseDate,
|
firstReleaseDate: r.firstReleaseDate,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) return; // don't flip the badge if the want failed
|
if (res.ok) {
|
||||||
setData((d) =>
|
setWanted((s) => new Set(s).add(r.rgMbid));
|
||||||
d ? { ...d, releases: d.releases.map((x) => (x.rgMbid === r.rgMbid ? { ...x, monitored: true } : x)) } : d,
|
toast(`Added ${r.album}`);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleTracks(rgMbid: string) {
|
const counts = useMemo(() => {
|
||||||
if (tracks[rgMbid]) {
|
const c: Record<string, number> = {};
|
||||||
setTracks((t) => {
|
data?.releases.forEach((r) => {
|
||||||
const n = { ...t };
|
c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1;
|
||||||
delete n[rgMbid];
|
});
|
||||||
return n;
|
return c;
|
||||||
});
|
}, [data]);
|
||||||
return;
|
|
||||||
}
|
function chipFor(r: Release) {
|
||||||
setTracks((t) => ({ ...t, [rgMbid]: "loading" }));
|
if (r.have) return <span className="chip done">In library</span>;
|
||||||
const res = await fetch(`/api/mb/release-groups/${rgMbid}/tracks`);
|
if (r.monitored || wanted.has(r.rgMbid)) return <span className="chip working">Wanted</span>;
|
||||||
if (!res.ok) {
|
return null;
|
||||||
setTracks((t) => ({ ...t, [rgMbid]: "error" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { tracks: ts } = await res.json();
|
|
||||||
setTracks((t) => ({ ...t, [rgMbid]: ts }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error)
|
if (error)
|
||||||
@@ -115,9 +103,12 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
|||||||
);
|
);
|
||||||
if (!data) return <p className="empty">Loading…</p>;
|
if (!data) return <p className="empty">Loading…</p>;
|
||||||
|
|
||||||
|
const tabs: ("All" | Category)[] = ["All", ...TAB_ORDER.filter((t) => counts[t])];
|
||||||
|
const visible = tab === "All" ? data.releases : data.releases.filter((r) => categoryOf(r) === tab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead title={data.artistName || "Artist"} eyebrow="Preview · discography" />
|
<PageHead title={name || "Artist"} eyebrow="Preview · discography" />
|
||||||
|
|
||||||
<div className="tool-row">
|
<div className="tool-row">
|
||||||
{followed ? (
|
{followed ? (
|
||||||
@@ -130,55 +121,77 @@ export function PreviewClient({ mbid, initialName }: { mbid: string; initialName
|
|||||||
<a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer">
|
<a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer">
|
||||||
MusicBrainz ↗
|
MusicBrainz ↗
|
||||||
</a>
|
</a>
|
||||||
<label className="toggle">
|
</div>
|
||||||
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} />
|
|
||||||
Show all types
|
<div className="tabs">
|
||||||
</label>
|
{tabs.map((t) => (
|
||||||
|
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
|
||||||
|
{t}
|
||||||
|
<span className="n">{t === "All" ? data.releases.length : counts[t]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{data.releases.map((r) => {
|
{visible.map((r) => {
|
||||||
const t = tracks[r.rgMbid];
|
const owned = r.have || r.monitored || wanted.has(r.rgMbid);
|
||||||
return (
|
return (
|
||||||
<li key={r.rgMbid} id={`rg-${r.rgMbid}`} className="list-row">
|
<li key={r.rgMbid} className="list-row">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="rtitle">{r.album}</div>
|
<button className="disco-open" onClick={() => setOpen(r)} aria-label={`Open ${r.album}`}>
|
||||||
<div className="rmeta">
|
<CoverArt rgMbid={r.rgMbid} alt={r.album} className="thumb" />
|
||||||
{r.primaryType ? <span>{r.primaryType}</span> : null}
|
<span>
|
||||||
{r.firstReleaseDate ? (
|
<span className="rtitle">{r.album}</span>
|
||||||
<>
|
<span className="rmeta">
|
||||||
<span className="dot">·</span>
|
{r.primaryType ? <span>{r.primaryType}</span> : null}
|
||||||
<span>{r.firstReleaseDate.slice(0, 4)}</span>
|
{r.firstReleaseDate ? (
|
||||||
</>
|
<>
|
||||||
) : null}
|
<span className="dot">·</span>
|
||||||
</div>
|
<span>{r.firstReleaseDate.slice(0, 4)}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{r.have ? <span className="chip done">In library</span> : r.monitored ? <span className="chip working">Monitored</span> : null}
|
{chipFor(r)}
|
||||||
<button className="btn sm" onClick={() => want(r)} disabled={r.have || r.monitored}>
|
<button className="btn sm" onClick={() => want(r)} disabled={owned}>
|
||||||
Want
|
Want
|
||||||
</button>
|
</button>
|
||||||
<button className="btn sm ghost" onClick={() => toggleTracks(r.rgMbid)}>
|
|
||||||
{t ? "Hide tracks" : "Tracks"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{t === "loading" ? <p className="muted-note" style={{ flexBasis: "100%" }}>Loading tracks…</p> : null}
|
|
||||||
{t === "error" ? <p className="notice" style={{ flexBasis: "100%" }}>Couldn’t load tracks.</p> : null}
|
|
||||||
{Array.isArray(t) ? (
|
|
||||||
<ol className="tracks" style={{ flexBasis: "100%" }}>
|
|
||||||
{t.map((tr) => (
|
|
||||||
<li key={tr.position}>
|
|
||||||
<span className="tnum">{tr.position}</span>
|
|
||||||
<span className="tname">{tr.title}</span>
|
|
||||||
{tr.lengthMs != null ? <span>{fmt(tr.lengthMs)}</span> : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
) : null}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<AlbumModal
|
||||||
|
open
|
||||||
|
onClose={() => setOpen(null)}
|
||||||
|
album={{
|
||||||
|
rgMbid: open.rgMbid,
|
||||||
|
album: open.album,
|
||||||
|
artist: name,
|
||||||
|
year: open.firstReleaseDate?.slice(0, 4) ?? null,
|
||||||
|
type: open.primaryType,
|
||||||
|
}}
|
||||||
|
status={chipFor(open)}
|
||||||
|
actions={
|
||||||
|
open.have || open.monitored || wanted.has(open.rgMbid) ? null : (
|
||||||
|
<button
|
||||||
|
className="btn sm"
|
||||||
|
onClick={() => {
|
||||||
|
want(open);
|
||||||
|
setOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Want
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user