Files
lidify/frontend/components/ui/PlayableCard.tsx
2025-12-25 18:58:06 -06:00

212 lines
7.6 KiB
TypeScript

"use client";
import { useState, ReactNode, memo } from "react";
import Link from "next/link";
import Image from "next/image";
import { Play, Pause, Check, Download } from "lucide-react";
import { Card, CardProps } from "./Card";
import { cn } from "@/utils/cn";
import type { ColorPalette } from "@/hooks/useImageColor";
// Lidify brand yellow for all on-page play buttons
const LIDIFY_YELLOW = "#ecb200";
export interface PlayableCardProps extends Omit<CardProps, "onPlay"> {
href?: string;
coverArt?: string | null;
title: string;
subtitle?: string;
placeholderIcon?: ReactNode;
isPlaying?: boolean;
onPlay?: (e: React.MouseEvent) => void;
onDownload?: (e: React.MouseEvent) => void;
showPlayButton?: boolean;
circular?: boolean;
badge?: "owned" | "download" | null;
isDownloading?: boolean;
colors?: ColorPalette | null;
tvCardIndex?: number;
}
const PlayableCard = memo(function PlayableCard({
href,
coverArt,
title,
subtitle,
placeholderIcon,
isPlaying = false,
onPlay,
onDownload,
showPlayButton = true,
circular = false,
badge = null,
isDownloading = false,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
colors = null,
className,
variant = "default",
tvCardIndex,
...props
}: PlayableCardProps) {
const [isHovered, setIsHovered] = useState(false);
// Handle Link click to prevent navigation when clicking on interactive elements
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
const target = e.target as HTMLElement;
if (target.closest("button")) {
e.preventDefault();
}
};
const cardContent = (
<>
{/* Image Container */}
<div
className="relative aspect-square mb-3"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={cn(
"relative w-full h-full bg-[#282828] flex items-center justify-center overflow-hidden shadow-lg",
circular ? "rounded-full" : "rounded-md"
)}>
{coverArt ? (
<Image
src={coverArt}
alt={title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 16vw"
className={cn(
"object-cover transition-transform duration-300",
isHovered && "scale-105"
)}
unoptimized
/>
) : (
placeholderIcon || (
<div className="w-12 h-12 bg-[#3e3e3e] rounded-full" />
)
)}
</div>
{/* Play Button */}
{showPlayButton && onPlay && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onPlay(e);
}}
style={{ backgroundColor: LIDIFY_YELLOW }}
className={cn(
"absolute bottom-2 right-2 w-10 h-10 rounded-full flex items-center justify-center",
"shadow-xl shadow-black/50 transition-all duration-200",
"hover:scale-105 hover:brightness-110",
isHovered || isPlaying
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-2"
)}
>
{isPlaying ? (
<Pause className="w-4 h-4 fill-current text-black" />
) : (
<Play className="w-4 h-4 fill-current ml-0.5 text-black" />
)}
</button>
)}
</div>
{/* Badge */}
{badge && (
<div className="mb-1.5">
{badge === "owned" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs font-medium text-green-400">
<Check className="w-3 h-3" />
Owned
</span>
)}
{badge === "download" && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (!isDownloading && onDownload) {
onDownload(e);
}
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
disabled={isDownloading}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-all",
isDownloading
? "bg-gray-500/20 border border-gray-500/30 text-gray-500 cursor-not-allowed"
: "bg-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/30 hover:border-yellow-500/50 text-yellow-400 hover:text-yellow-300"
)}
title={
isDownloading
? "Downloading..."
: "Download Album"
}
>
<Download
className={cn(
"w-3 h-3",
isDownloading && "animate-pulse"
)}
/>
{isDownloading ? "Downloading..." : "Download"}
</button>
)}
</div>
)}
{/* Title and Subtitle */}
<h3 className="text-sm font-semibold text-white truncate">
{title}
</h3>
{subtitle && (
<p className="text-xs text-gray-400 truncate mt-0.5">{subtitle}</p>
)}
</>
);
const cardClassName = cn("group cursor-pointer", className);
// TV navigation attributes
const tvNavProps = tvCardIndex !== undefined ? {
"data-tv-card": true,
"data-tv-card-index": tvCardIndex,
tabIndex: 0
} : {};
if (href) {
return (
<Link
href={href}
onClick={handleLinkClick}
{...tvNavProps}
>
<Card variant={variant} className={cardClassName} {...props}>
{cardContent}
</Card>
</Link>
);
}
return (
<Card
variant={variant}
className={cardClassName}
{...tvNavProps}
{...props}
>
{cardContent}
</Card>
);
});
export { PlayableCard };