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:
Jonathan
2026-07-12 15:24:16 +02:00
parent d05d092d53
commit 4f6c5995f8
6 changed files with 123 additions and 0 deletions
+79
View File
@@ -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>
);
}