Files
musicmouse/web/src/components/AlbumModal.tsx
2026-09-11 13:56:02 +02:00

222 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** The album detail sheet: cover, metadata, and a numbered track list. */
import { useEffect, useRef } from "react";
import type { Album } from "../api/types";
import { isBook, unitLabel } from "../lib/covers";
import { clock } from "../lib/format";
import { ANIMATE_VIEW_TRANSITIONS } from "../lib/theme";
import { Cover } from "./Cover";
interface Props {
album: Album;
currentAlbumId: string | null;
currentTrackIndex: number;
/** The track Ctrl+j/k has highlighted, so the keyboard has something visible to move. */
selectedIndex: number;
onClose: () => void;
onPlay: (trackIndex: number) => void;
}
export function AlbumModal({
album,
currentAlbumId,
currentTrackIndex,
selectedIndex,
onClose,
onPlay,
}: Props) {
const book = isBook(album);
const sheet = useRef<HTMLDivElement | null>(null);
// Keep the Ctrl+j/k highlight on screen as it moves past the fold, the same way the
// browse grid keeps its own keyboard selection visible.
useEffect(() => {
const box = sheet.current;
const element = box?.querySelector<HTMLElement>(`[data-nav-index="${selectedIndex}"]`);
if (!box || !element) return;
const top = element.offsetTop - box.offsetTop;
const bottom = top + element.offsetHeight;
const pad = 12;
if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad);
else if (bottom + pad > box.scrollTop + box.clientHeight) {
box.scrollTop = bottom + pad - box.clientHeight;
}
}, [selectedIndex]);
return (
<div
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }}
onClick={onClose}
>
<div
ref={sheet}
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
onClick={(event) => event.stopPropagation()}
style={{
background: "oklch(96% 0.012 210)",
borderRadius: 26,
padding: 26,
width: 640,
maxWidth: "100%",
maxHeight: "100%",
overflow: "auto",
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
}}
>
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
<div style={{ width: 150, flex: "none" }}>
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 26,
fontWeight: 900,
color: "oklch(22% 0.03 210)",
lineHeight: 1.15,
}}
>
{album.title}
</div>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: "oklch(45% 0.03 210 / .85)",
marginTop: 2,
}}
>
{album.artist}
</div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(50% 0.03 210 / .7)",
marginTop: 2,
}}
>
{book ? "📖 Hörbuch" : "🎵 Musik"} · {unitLabel(album, album.tracks.length)} ·{" "}
{clock(album.duration)}
{album.figure && ` · 🧸 ${album.figure}`}
</div>
<button
onClick={() => onPlay(0)}
style={{
marginTop: 14,
border: "none",
cursor: "pointer",
background: "var(--accent)",
color: "#fff",
fontSize: 16,
fontWeight: 800,
padding: "12px 22px",
borderRadius: 999,
display: "flex",
alignItems: "center",
gap: 10,
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
}}
>
<span
style={{
width: 14,
height: 16,
background: "#fff",
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
}}
/>
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
</button>
</div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 210)",
color: "var(--ink)",
fontSize: 18,
fontWeight: 900,
width: 40,
height: 40,
borderRadius: 999,
flex: "none",
}}
>
×
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 22 }}>
{album.tracks.map((track, index) => {
const current = currentAlbumId === album.id && currentTrackIndex === index;
return (
<button
key={index}
data-nav-index={index}
onClick={() => onPlay(index)}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "10px 14px",
borderRadius: 14,
cursor: "pointer",
border: "none",
font: "inherit",
textAlign: "left",
background: current
? "oklch(70% 0.16 340 / .16)"
: "oklch(30% 0.03 210 / .05)",
outline: index === selectedIndex ? "3px solid var(--accent)" : "none",
outlineOffset: 2,
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: 999,
flex: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
font: "800 13px ui-monospace, Menlo, monospace",
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
color: current ? "#fff" : "oklch(35% 0.03 210)",
}}
>
{index + 1}
</div>
<div
style={{
flex: 1,
minWidth: 0,
fontSize: 16,
fontWeight: 800,
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
}}
>
{track.title}
</div>
<div
style={{
font: "700 13px ui-monospace, Menlo, monospace",
color: "oklch(45% 0.03 210 / .7)",
}}
>
{clock(track.duration)}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}