From 34235a23553101ed08f96d53bdbb829a75b33554 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 15:14:34 +0200 Subject: [PATCH] feat(web): CoverArt + reusable AlbumModal CoverArt loads album art from the Cover Art Archive CDN by release-group MBID (browser-side, cached, graceful placeholder fallback). AlbumModal shows cover + tracklist (fetched from MB) with slots for a status chip and context actions. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/album-modal.tsx | 93 +++++++++++++++++++++++++++++++++ web/src/app/_ui/cover-art.tsx | 38 ++++++++++++++ web/src/app/design.css | 19 +++++++ 3 files changed, 150 insertions(+) create mode 100644 web/src/app/_ui/album-modal.tsx create mode 100644 web/src/app/_ui/cover-art.tsx diff --git a/web/src/app/_ui/album-modal.tsx b/web/src/app/_ui/album-modal.tsx new file mode 100644 index 0000000..e4a9fe8 --- /dev/null +++ b/web/src/app/_ui/album-modal.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { Modal } from "./modal"; +import { CoverArt } from "./cover-art"; + +type Track = { position: number; title: string; lengthMs: number | null }; + +function fmt(ms: number | null): string { + if (ms == null) return ""; + const s = Math.round(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} + +export type AlbumInfo = { + rgMbid: string | null; + album: string; + artist: string; + year?: string | null; + type?: string | null; +}; + +/** Reusable album modal: cover art + tracklist (fetched from MB by release-group MBID) with + * slots for a status chip and context-specific action buttons. */ +export function AlbumModal({ + open, + onClose, + album, + status, + actions, +}: { + open: boolean; + onClose: () => void; + album: AlbumInfo; + status?: ReactNode; + actions?: ReactNode; +}) { + const [tracks, setTracks] = useState("loading"); + + useEffect(() => { + if (!open) return; + if (!album.rgMbid) { + setTracks("error"); + return; + } + setTracks("loading"); + let cancelled = false; + fetch(`/api/mb/release-groups/${album.rgMbid}/tracks`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d) => !cancelled && setTracks(d.tracks)) + .catch(() => !cancelled && setTracks("error")); + return () => { + cancelled = true; + }; + }, [open, album.rgMbid]); + + return ( + +
+ +
+

{album.album}

+
+ {album.type ? {album.type} : null} + {album.year ? ( + <> + · + {album.year} + + ) : null} +
+ {status ?
{status}
: null} + {actions ?
{actions}
: null} +
+
+
+ {tracks === "loading" ?

Loading tracks…

: null} + {tracks === "error" ?

No tracklist available.

: null} + {Array.isArray(tracks) ? ( +
    + {tracks.map((t) => ( +
  1. + {t.position} + {t.title} + {t.lengthMs != null ? {fmt(t.lengthMs)} : null} +
  2. + ))} +
+ ) : null} +
+
+ ); +} diff --git a/web/src/app/_ui/cover-art.tsx b/web/src/app/_ui/cover-art.tsx new file mode 100644 index 0000000..ddd20c5 --- /dev/null +++ b/web/src/app/_ui/cover-art.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; + +/** Album art from the Cover Art Archive (keyed by release-group MBID), loaded straight from + * their CDN by the browser — no backend, no MB API rate limit. Falls back to a placeholder + * when there's no MBID or no art. */ +export function CoverArt({ + rgMbid, + alt, + size = 250, + className, +}: { + rgMbid: string | null | undefined; + alt: string; + size?: 250 | 500; + className?: string; +}) { + const [failed, setFailed] = useState(false); + const cls = `cover ${className ?? ""}`.trim(); + if (!rgMbid || failed) { + return ( +
+ +
+ ); + } + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} setFailed(true)} + /> + ); +} diff --git a/web/src/app/design.css b/web/src/app/design.css index 2287035..8123717 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -274,3 +274,22 @@ nav.contents .sep { flex: 1; } @keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } } @keyframes modal-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } @media (prefers-reduced-motion: reduce) { .modal-backdrop, .modal-panel { animation: none; } } + +/* ── Cover art + album modal ──────────────────────────── */ +.cover { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; background: var(--paper-2); border: 1px solid var(--rule); } +.cover-fallback { display: flex; align-items: center; justify-content: center; color: var(--rule-2); font-size: 2.2rem; } +.album-modal-head { display: grid; grid-template-columns: 170px 1fr; gap: 22px; align-items: start; } +.album-modal-cover { width: 170px; } +.album-modal-title { font-size: 1.7rem; line-height: 1.1; margin: 0; letter-spacing: -0.01em; text-wrap: balance; } +.album-modal-meta { + font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--graphite); margin-top: 8px; display: flex; gap: 8px; align-items: center; +} +.album-modal-status { margin-top: 12px; } +.album-modal-actions { margin-top: 16px; } +.album-modal-tracks { margin-top: 22px; border-top: 1px solid var(--rule); padding-top: 10px; max-height: 42vh; overflow-y: auto; } +.tracks li .tname { color: var(--ink); flex: 1; } +@media (max-width: 560px) { + .album-modal-head { grid-template-columns: 1fr; } + .album-modal-cover { width: 150px; } +}