feat(web): Library page — owned-albums grid with cover art
New /library: GET /api/library lists LibraryItems enriched with rgMbid + year from the matching MonitoredRelease. Client renders a cover-art grid with a live filter; clicking an album opens the AlbumModal (tracklist). Added Library to the nav. The album-only overview to see everything downloaded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -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; }
|
||||
|
||||
@@ -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<Album[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState<Album | null>(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 (
|
||||
<div>
|
||||
<PageHead title="Library" eyebrow="The pressings · everything on disk" />
|
||||
|
||||
<form className="request-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<label className="field">
|
||||
<span>Filter</span>
|
||||
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</label>
|
||||
<span className="rmeta">
|
||||
{shown.length} of {albums.length} albums
|
||||
</span>
|
||||
</form>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<p className="empty">Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.</p>
|
||||
) : (
|
||||
<div className="album-grid">
|
||||
{shown.map((a) => (
|
||||
<button key={a.id} className="album-card" onClick={() => setOpen(a)} aria-label={`Open ${a.album}`}>
|
||||
<CoverArt rgMbid={a.rgMbid} alt={a.album} size={250} />
|
||||
<div className="ac-title">{a.album}</div>
|
||||
<div className="ac-artist">{a.artist}</div>
|
||||
<div className="ac-meta">
|
||||
{a.format}
|
||||
{a.qualityClass >= 3 ? " · hi-res" : ""}
|
||||
{a.year ? ` · ${a.year}` : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open ? (
|
||||
<AlbumModal
|
||||
open
|
||||
onClose={() => setOpen(null)}
|
||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType }}
|
||||
status={<span className="chip done">In library · {open.format}</span>}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LibraryClient } from "./library-client";
|
||||
|
||||
export default function LibraryPage() {
|
||||
return <LibraryClient />;
|
||||
}
|
||||
Reference in New Issue
Block a user