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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user