diff --git a/web/src/app/_ui/contents-nav.tsx b/web/src/app/_ui/contents-nav.tsx index ebc6ab5..1a4b706 100644 --- a/web/src/app/_ui/contents-nav.tsx +++ b/web/src/app/_ui/contents-nav.tsx @@ -4,6 +4,7 @@ import { usePathname } from "next/navigation"; const LINKS: { href: string; label: string }[] = [ { href: "/", label: "The Floor" }, + { href: "/library", label: "Library" }, { href: "/artists", label: "Artists" }, { href: "/discover", label: "Discover" }, { href: "/wanted", label: "Wanted" }, diff --git a/web/src/app/api/library/route.test.ts b/web/src/app/api/library/route.test.ts new file mode 100644 index 0000000..f70f2a8 --- /dev/null +++ b/web/src/app/api/library/route.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/db"; +import { GET } from "./route"; + +describe("library API", () => { + it("lists owned albums enriched with rgMbid + year from the matching release", async () => { + await prisma.monitoredRelease.create({ + data: { + artistMbid: "a1", artistName: "Radiohead", rgMbid: "rg-inrainbows", album: "In Rainbows", + primaryType: "Album", firstReleaseDate: "2007-10-10", monitored: true, state: "fulfilled", + }, + }); + await prisma.libraryItem.create({ + data: { artist: "Radiohead", album: "In Rainbows", path: "/m", source: "qobuz", format: "FLAC", qualityClass: 3 }, + }); + await prisma.libraryItem.create({ + data: { artist: "Unknown", album: "Bootleg", path: "/m", source: "scan", format: "MP3", qualityClass: 1 }, + }); + + const res = await GET(); + expect(res.status).toBe(200); + const { albums } = await res.json(); + expect(albums).toHaveLength(2); + const inr = albums.find((a: { album: string }) => a.album === "In Rainbows"); + expect(inr).toMatchObject({ artist: "Radiohead", rgMbid: "rg-inrainbows", year: "2007", format: "FLAC", qualityClass: 3 }); + const boot = albums.find((a: { album: string }) => a.album === "Bootleg"); + expect(boot).toMatchObject({ rgMbid: null, year: null }); // no matching release → no art/tracks + }); +}); diff --git a/web/src/app/api/library/route.ts b/web/src/app/api/library/route.ts new file mode 100644 index 0000000..7162466 Binary files /dev/null and b/web/src/app/api/library/route.ts differ diff --git a/web/src/app/design.css b/web/src/app/design.css index 5b207c4..097ebe0 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -313,3 +313,12 @@ nav.contents .sep { flex: 1; } .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; } + +/* ── Album grid (Library) ─────────────────────────────── */ +.album-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 24px 18px; margin: 8px 0 48px; } +.album-card { background: transparent; border: 0; padding: 0; cursor: pointer; text-align: left; color: inherit; } +.album-card .cover { margin-bottom: 9px; } +.album-card:hover .ac-title { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; } +.ac-title { font-size: 0.98rem; line-height: 1.25; } +.ac-artist { color: var(--graphite); font-size: 0.86rem; margin-top: 1px; } +.ac-meta { font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--graphite); margin-top: 5px; } diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx new file mode 100644 index 0000000..4d782d2 --- /dev/null +++ b/web/src/app/library/library-client.tsx @@ -0,0 +1,79 @@ +"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"; + +type Album = { + id: string; + artist: string; + album: string; + format: string; + qualityClass: number; + rgMbid: string | null; + year: string | null; + primaryType: string | null; +}; + +export function LibraryClient() { + const [albums, setAlbums] = useState([]); + const [q, setQ] = useState(""); + const [open, setOpen] = useState(null); + + useEffect(() => { + fetch("/api/library") + .then((r) => r.json()) + .then((d) => setAlbums(d.albums ?? [])); + }, []); + + const shown = useMemo(() => { + const needle = q.trim().toLowerCase(); + if (!needle) return albums; + return albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle)); + }, [albums, q]); + + return ( +
+ + +
e.preventDefault()}> + + + {shown.length} of {albums.length} albums + +
+ + {albums.length === 0 ? ( +

Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.

+ ) : ( +
+ {shown.map((a) => ( + + ))} +
+ )} + + {open ? ( + setOpen(null)} + album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType }} + status={In library · {open.format}} + /> + ) : null} +
+ ); +} diff --git a/web/src/app/library/page.tsx b/web/src/app/library/page.tsx new file mode 100644 index 0000000..0777fb0 --- /dev/null +++ b/web/src/app/library/page.tsx @@ -0,0 +1,5 @@ +import { LibraryClient } from "./library-client"; + +export default function LibraryPage() { + return ; +}