feat(web): artist detail — category tabs, cover thumbs, album modal
Discography now has category tabs (All/Albums/Singles/EPs/Live/Comps/Other with counts, defaults to Albums), cover-art thumbnails, and clicking an album opens the AlbumModal with its tracklist. Detail GET gains ?all=true so the client filters by tab (studio-only default still governs the artists-list counts). Replaces the old flat date-sorted list + show-all toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,14 @@ describe("artist item API", () => {
|
|||||||
expect(body.releases.map((r: { rgMbid: string }) => r.rgMbid).sort()).toEqual(["rg1", "rg2"]);
|
expect(body.releases.map((r: { rgMbid: string }) => r.rgMbid).sort()).toEqual(["rg1", "rg2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns all release types with ?all=true regardless of showAllTypes", async () => {
|
||||||
|
const a = await seedArtist();
|
||||||
|
const res = await GET(new Request(`http://localhost/x?all=true`), ctx(a.id));
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.showAllTypes).toBe(false);
|
||||||
|
expect(body.releases).toHaveLength(2); // the Live secondary-type release is included
|
||||||
|
});
|
||||||
|
|
||||||
it("404s an unknown artist on GET", async () => {
|
it("404s an unknown artist on GET", async () => {
|
||||||
expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404);
|
expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { prisma } from "@/lib/db";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { isCoreRelease } from "@/lib/release-filter";
|
import { isCoreRelease } from "@/lib/release-filter";
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
// ?all=true returns every release type (the detail page filters client-side via tabs);
|
||||||
|
// otherwise honour the artist's showAllTypes flag (studio-only default) as before.
|
||||||
|
const all = new URL(request.url).searchParams.get("all") === "true";
|
||||||
const artist = await prisma.watchedArtist.findUnique({
|
const artist = await prisma.watchedArtist.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } },
|
include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } },
|
||||||
});
|
});
|
||||||
if (!artist) return Response.json({ error: "not found" }, { status: 404 });
|
if (!artist) return Response.json({ error: "not found" }, { status: 404 });
|
||||||
const visibleReleases = artist.showAllTypes ? artist.releases : artist.releases.filter(isCoreRelease);
|
const visibleReleases = all || artist.showAllTypes ? artist.releases : artist.releases.filter(isCoreRelease);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
id: artist.id,
|
id: artist.id,
|
||||||
mbid: artist.mbid,
|
mbid: artist.mbid,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { AlbumModal } from "../../_ui/album-modal";
|
||||||
|
|
||||||
type Release = {
|
type Release = {
|
||||||
id: string;
|
id: string;
|
||||||
|
rgMbid: string | null;
|
||||||
album: string;
|
album: string;
|
||||||
primaryType: string | null;
|
primaryType: string | null;
|
||||||
secondaryTypes: string[];
|
secondaryTypes: string[];
|
||||||
@@ -15,6 +18,19 @@ 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" };
|
||||||
@@ -23,9 +39,11 @@ function badge(r: Release): { label: string; cls: string } {
|
|||||||
|
|
||||||
export function DiscographyClient({ id }: { id: string }) {
|
export function DiscographyClient({ id }: { id: string }) {
|
||||||
const [detail, setDetail] = useState<Detail | null>(null);
|
const [detail, setDetail] = useState<Detail | null>(null);
|
||||||
|
const [tab, setTab] = useState<"All" | Category>("Albums");
|
||||||
|
const [openAlbum, setOpenAlbum] = useState<Release | null>(null);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const res = await fetch(`/api/artists/${id}`);
|
const res = await fetch(`/api/artists/${id}?all=true`);
|
||||||
setDetail(res.ok ? await res.json() : null);
|
setDetail(res.ok ? await res.json() : null);
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,44 +62,47 @@ export function DiscographyClient({ id }: { id: string }) {
|
|||||||
await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
|
await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
async function toggleShowAll() {
|
|
||||||
if (!detail) return;
|
const counts = useMemo(() => {
|
||||||
await fetch(`/api/artists/${id}`, {
|
const c: Record<string, number> = {};
|
||||||
method: "PATCH",
|
detail?.releases.forEach((r) => {
|
||||||
headers: { "content-type": "application/json" },
|
c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1;
|
||||||
body: JSON.stringify({ showAllTypes: !detail.showAllTypes }),
|
|
||||||
});
|
});
|
||||||
refresh();
|
return c;
|
||||||
}
|
}, [detail]);
|
||||||
|
|
||||||
if (!detail) return <p className="empty">Loading…</p>;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead title={detail.name} eyebrow="Discography" />
|
<PageHead title={detail.name} eyebrow="Discography" />
|
||||||
|
|
||||||
<div className="tool-row">
|
<div className="tabs">
|
||||||
<label className="toggle">
|
{tabs.map((t) => (
|
||||||
<input
|
<button key={t} className={`tab${tab === t ? " active" : ""}`} onClick={() => setTab(t)}>
|
||||||
type="checkbox"
|
{t}
|
||||||
aria-label="show all release types"
|
<span className="n">{t === "All" ? detail.releases.length : counts[t]}</span>
|
||||||
checked={detail.showAllTypes}
|
</button>
|
||||||
onChange={toggleShowAll}
|
))}
|
||||||
/>
|
|
||||||
Show all types (live, compilations, singles…)
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{detail.releases.map((r) => {
|
{visible.map((r) => {
|
||||||
const b = badge(r);
|
const b = badge(r);
|
||||||
return (
|
return (
|
||||||
<li key={r.id} className="list-row">
|
<li key={r.id} className="list-row">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="rtitle">
|
<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.album}
|
||||||
{r.firstReleaseDate ? <span className="dim"> ({r.firstReleaseDate.slice(0, 4)})</span> : null}
|
{r.firstReleaseDate ? <span className="dim"> ({r.firstReleaseDate.slice(0, 4)})</span> : null}
|
||||||
</div>
|
</span>
|
||||||
<div className="rmeta">
|
<span className="rmeta">
|
||||||
{r.primaryType ? <span>{r.primaryType}</span> : null}
|
{r.primaryType ? <span>{r.primaryType}</span> : null}
|
||||||
{r.secondaryTypes.length ? (
|
{r.secondaryTypes.length ? (
|
||||||
<>
|
<>
|
||||||
@@ -89,7 +110,9 @@ export function DiscographyClient({ id }: { id: string }) {
|
|||||||
<span>{r.secondaryTypes.join(", ")}</span>
|
<span>{r.secondaryTypes.join(", ")}</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<span className={b.cls}>{b.label}</span>
|
<span className={b.cls}>{b.label}</span>
|
||||||
@@ -105,6 +128,32 @@ export function DiscographyClient({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,3 +293,23 @@ nav.contents .sep { flex: 1; }
|
|||||||
.album-modal-head { grid-template-columns: 1fr; }
|
.album-modal-head { grid-template-columns: 1fr; }
|
||||||
.album-modal-cover { width: 150px; }
|
.album-modal-cover { width: 150px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ─────────────────────────────────────────────── */
|
||||||
|
.tabs { display: flex; gap: 2px; flex-wrap: wrap; border-bottom: 1px solid var(--rule); margin: 8px 0 18px; }
|
||||||
|
.tab {
|
||||||
|
font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--graphite); background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||||
|
padding: 9px 12px; cursor: pointer; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--ink); }
|
||||||
|
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||||
|
.tab .n { color: var(--rule-2); margin-left: 6px; }
|
||||||
|
|
||||||
|
/* ── Discography row (cover thumb + click-to-modal) ───── */
|
||||||
|
.disco-open {
|
||||||
|
display: flex; gap: 14px; align-items: center; background: transparent; border: 0; padding: 0;
|
||||||
|
cursor: pointer; text-align: left; color: inherit; min-width: 0;
|
||||||
|
}
|
||||||
|
.thumb { width: 46px; height: 46px; flex-shrink: 0; }
|
||||||
|
.disco-open .rtitle { display: block; }
|
||||||
|
.disco-open:hover .rtitle { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user