Files
Lyra/web/src/app/artists/[id]/discography-client.tsx
T
Jonathan e624d12089 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>
2026-07-12 17:58:23 +02:00

148 lines
4.9 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
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";
type Release = {
id: string;
rgMbid: string | null;
album: string;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
monitored: boolean;
state: string;
currentQualityClass: number | null;
};
type Detail = { id: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
function badge(r: Release): { label: string; cls: string } {
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" };
return { label: "Dormant", cls: "chip" };
}
export function DiscographyClient({ id }: { id: string }) {
const [detail, setDetail] = useState<Detail | null>(null);
const [tab, setTab] = useState<"All" | Category>("Albums");
const [openAlbum, setOpenAlbum] = useState<Release | null>(null);
async function refresh() {
const res = await fetch(`/api/artists/${id}?all=true`);
setDetail(res.ok ? await res.json() : null);
}
useEffect(() => {
refresh();
}, [id]);
async function toggleMonitor(r: Release) {
await fetch(`/api/releases/${r.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ monitored: !r.monitored }),
});
refresh();
}
async function searchNow(r: Release) {
await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
refresh();
}
const counts = useMemo(() => {
const c: Record<string, number> = {};
detail?.releases.forEach((r) => {
c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1;
});
return c;
}, [detail]);
if (!detail) return <p className="empty">Loading</p>;
const tabs: ("All" | Category)[] = ["All", ...TAB_ORDER.filter((t) => counts[t])];
const visible = tab === "All" ? detail.releases : detail.releases.filter((r) => categoryOf(r) === tab);
return (
<div>
<PageHead title={detail.name} eyebrow="Discography" />
<div className="tabs">
{tabs.map((t) => (
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
{t}
<span className="n">{t === "All" ? detail.releases.length : counts[t]}</span>
</button>
))}
</div>
<ul className="list">
{visible.map((r) => {
const b = badge(r);
return (
<li key={r.id} className="list-row">
<div className="main">
<button className="disco-open" onClick={() => setOpenAlbum(r)} aria-label={`Open ${r.album}`}>
<CoverArt rgMbid={r.rgMbid} alt={r.album} className="thumb" />
<span>
<span className="rtitle">
{r.album}
{r.firstReleaseDate ? <span className="dim"> ({r.firstReleaseDate.slice(0, 4)})</span> : null}
</span>
<span className="rmeta">
{r.primaryType ? <span>{r.primaryType}</span> : null}
{r.secondaryTypes.length ? (
<>
<span className="dot">·</span>
<span>{r.secondaryTypes.join(", ")}</span>
</>
) : null}
</span>
</span>
</button>
</div>
<div className="actions">
<span className={b.cls}>{b.label}</span>
<label className="toggle">
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} />
monitor
</label>
<button className="btn sm ghost" onClick={() => searchNow(r)}>
Search now
</button>
</div>
</li>
);
})}
</ul>
{openAlbum ? (
<AlbumModal
open
onClose={() => setOpenAlbum(null)}
album={{
rgMbid: openAlbum.rgMbid,
album: openAlbum.album,
artist: detail.name,
year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null,
type: openAlbum.primaryType,
}}
status={<span className={badge(openAlbum).cls}>{badge(openAlbum).label}</span>}
actions={
<button
className="btn sm ghost"
onClick={() => {
searchNow(openAlbum);
setOpenAlbum(null);
}}
>
Search now
</button>
}
/>
) : null}
</div>
);
}