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) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 15:14:34 +02:00
parent 3284059aba
commit 34235a2355
3 changed files with 150 additions and 0 deletions
+93
View File
@@ -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<Track[] | "loading" | "error">("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 (
<Modal open={open} onClose={onClose} title={album.artist} wide>
<div className="album-modal-head">
<CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" />
<div>
<h2 className="album-modal-title">{album.album}</h2>
<div className="album-modal-meta">
{album.type ? <span>{album.type}</span> : null}
{album.year ? (
<>
<span className="dot">·</span>
<span>{album.year}</span>
</>
) : null}
</div>
{status ? <div className="album-modal-status">{status}</div> : null}
{actions ? <div className="actions album-modal-actions">{actions}</div> : null}
</div>
</div>
<div className="album-modal-tracks">
{tracks === "loading" ? <p className="muted-note">Loading tracks</p> : null}
{tracks === "error" ? <p className="muted-note">No tracklist available.</p> : null}
{Array.isArray(tracks) ? (
<ol className="tracks">
{tracks.map((t) => (
<li key={t.position}>
<span className="tnum">{t.position}</span>
<span className="tname">{t.title}</span>
{t.lengthMs != null ? <span>{fmt(t.lengthMs)}</span> : null}
</li>
))}
</ol>
) : null}
</div>
</Modal>
);
}
+38
View File
@@ -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 (
<div className={`${cls} cover-fallback`} role="img" aria-label={alt}>
<span></span>
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
className={cls}
src={`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`}
alt={alt}
loading="lazy"
onError={() => setFailed(true)}
/>
);
}