diff --git a/web/src/app/api/artists/[id]/route.test.ts b/web/src/app/api/artists/[id]/route.test.ts index 1d001a4..c38faec 100644 --- a/web/src/app/api/artists/[id]/route.test.ts +++ b/web/src/app/api/artists/[id]/route.test.ts @@ -45,6 +45,14 @@ describe("artist item API", () => { 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 () => { expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404); }); diff --git a/web/src/app/api/artists/[id]/route.ts b/web/src/app/api/artists/[id]/route.ts index 0c22104..8c02205 100644 --- a/web/src/app/api/artists/[id]/route.ts +++ b/web/src/app/api/artists/[id]/route.ts @@ -2,14 +2,17 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@prisma/client"; 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; + // ?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({ where: { id }, include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } }, }); 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({ id: artist.id, mbid: artist.mbid, diff --git a/web/src/app/artists/[id]/discography-client.tsx b/web/src/app/artists/[id]/discography-client.tsx index 6b7c4b4..0399d55 100644 --- a/web/src/app/artists/[id]/discography-client.tsx +++ b/web/src/app/artists/[id]/discography-client.tsx @@ -1,10 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { PageHead } from "../../_ui/page-head"; +import { CoverArt } from "../../_ui/cover-art"; +import { AlbumModal } from "../../_ui/album-modal"; type Release = { id: string; + rgMbid: string | null; album: string; primaryType: string | null; secondaryTypes: string[]; @@ -15,6 +18,19 @@ type 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 } { 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" }; @@ -23,9 +39,11 @@ function badge(r: Release): { label: string; cls: string } { export function DiscographyClient({ id }: { id: string }) { const [detail, setDetail] = useState(null); + const [tab, setTab] = useState<"All" | Category>("Albums"); + const [openAlbum, setOpenAlbum] = useState(null); 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); } useEffect(() => { @@ -44,52 +62,57 @@ export function DiscographyClient({ id }: { id: string }) { await fetch(`/api/releases/${r.id}/search`, { method: "POST" }); refresh(); } - async function toggleShowAll() { - if (!detail) return; - await fetch(`/api/artists/${id}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ showAllTypes: !detail.showAllTypes }), + + const counts = useMemo(() => { + const c: Record = {}; + detail?.releases.forEach((r) => { + c[categoryOf(r)] = (c[categoryOf(r)] ?? 0) + 1; }); - refresh(); - } + return c; + }, [detail]); if (!detail) return

Loading…

; + + 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 (
-
- +
+ {tabs.map((t) => ( + + ))}
    - {detail.releases.map((r) => { + {visible.map((r) => { const b = badge(r); return (
  • -
    - {r.album} - {r.firstReleaseDate ? ({r.firstReleaseDate.slice(0, 4)}) : null} -
    -
    - {r.primaryType ? {r.primaryType} : null} - {r.secondaryTypes.length ? ( - <> - · - {r.secondaryTypes.join(", ")} - - ) : null} -
    +
    {b.label} @@ -105,6 +128,32 @@ export function DiscographyClient({ id }: { id: string }) { ); })}
+ + {openAlbum ? ( + setOpenAlbum(null)} + album={{ + rgMbid: openAlbum.rgMbid, + album: openAlbum.album, + artist: detail.name, + year: openAlbum.firstReleaseDate?.slice(0, 4) ?? null, + type: openAlbum.primaryType, + }} + status={{badge(openAlbum).label}} + actions={ + + } + /> + ) : null}
); } diff --git a/web/src/app/design.css b/web/src/app/design.css index 8123717..5b207c4 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -293,3 +293,23 @@ nav.contents .sep { flex: 1; } .album-modal-head { grid-template-columns: 1fr; } .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; }