Spaces:
Sleeping
Sleeping
| "use client"; | |
| import React, { useState, useEffect, memo } from "react"; | |
| import Link from "next/link"; | |
| import Image from "next/image"; | |
| import { Card, CardContent } from "@/components/ui/Card"; | |
| import { formatRelativeDate, formatNiche, getImageUrlFallback, isUnoptimizedImageUrl } from "@/lib/utils/formatters"; | |
| import { CheckSquare, Square } from "lucide-react"; | |
| import type { AdCreativeDB } from "@/types/api"; | |
| interface AdCardProps { | |
| ad: AdCreativeDB; | |
| isSelected?: boolean; | |
| onSelect?: (adId: string) => void; | |
| hasAnySelection?: boolean; | |
| } | |
| // Reusable gradient badge component | |
| const GradientBadge: React.FC<{ children: React.ReactNode }> = ({ children }) => ( | |
| <span className="inline-flex items-center text-xs font-bold px-3 py-1.5 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-full shadow-sm whitespace-nowrap"> | |
| {children} | |
| </span> | |
| ); | |
| export const AdCard: React.FC<AdCardProps> = memo(({ | |
| ad, | |
| isSelected = false, | |
| onSelect, | |
| hasAnySelection = false, | |
| }) => { | |
| const { primary, fallback, initialUrl } = getImageUrlFallback(ad.image_url, ad.image_filename, ad.r2_url); | |
| const [imageSrc, setImageSrc] = useState<string | null>(initialUrl); | |
| const [imageError, setImageError] = useState(false); | |
| useEffect(() => { | |
| setImageSrc(initialUrl); | |
| setImageError(false); | |
| }, [ad.id, ad.image_url, ad.image_filename, ad.r2_url, initialUrl]); | |
| const handleImageError = () => { | |
| // Try fallback if primary failed | |
| if (!imageError && fallback && imageSrc === primary) { | |
| setImageSrc(fallback); | |
| setImageError(true); | |
| } else { | |
| // Both failed, show placeholder | |
| setImageSrc(null); | |
| } | |
| }; | |
| return ( | |
| <Card | |
| variant={isSelected ? "glass" : "elevated"} | |
| className={`cursor-pointer transition-all duration-300 group h-full flex flex-col ${ | |
| isSelected ? "ring-4 ring-blue-500 ring-opacity-50 scale-105" : "hover:scale-[1.02]" | |
| }`} | |
| onClick={() => onSelect?.(ad.id)} | |
| > | |
| <Link href={`/gallery/${ad.id}`} onClick={(e) => e.stopPropagation()} className="flex flex-col h-full"> | |
| <CardContent className="p-0 flex flex-col flex-1"> | |
| {(imageSrc || ad.image_filename || ad.image_url) && ( | |
| <div className="aspect-video bg-gradient-to-br from-gray-100 to-gray-200 rounded-t-2xl overflow-hidden relative flex items-center justify-center flex-shrink-0"> | |
| {imageSrc ? ( | |
| <Image | |
| src={imageSrc} | |
| alt={ad.headline} | |
| fill | |
| className="object-contain group-hover:scale-105 transition-transform duration-500" | |
| onError={handleImageError} | |
| loading="lazy" | |
| sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" | |
| unoptimized={isUnoptimizedImageUrl(imageSrc)} | |
| /> | |
| ) : ( | |
| <div className="w-full h-full flex items-center justify-center"> | |
| <div className="text-center text-gray-400"> | |
| <svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> | |
| </svg> | |
| <p className="text-xs">Image unavailable</p> | |
| </div> | |
| </div> | |
| )} | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div> | |
| {/* Selection checkbox - show on hover when nothing selected, always show when something is selected */} | |
| <div className="absolute top-3 left-3 z-10"> | |
| <button | |
| className={`flex items-center justify-center w-6 h-6 rounded-md border-2 transition-all duration-200 ${ | |
| isSelected | |
| ? "bg-blue-500 border-blue-500 text-white shadow-lg" | |
| : hasAnySelection | |
| ? "bg-white/90 backdrop-blur-sm border-gray-300 hover:border-blue-400 hover:bg-white shadow-md" | |
| : "bg-white/90 backdrop-blur-sm border-gray-300 hover:border-blue-400 hover:bg-white shadow-md opacity-0 group-hover:opacity-100" | |
| }`} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| onSelect?.(ad.id); | |
| }} | |
| > | |
| {isSelected ? ( | |
| <CheckSquare className="h-4 w-4" /> | |
| ) : ( | |
| <Square className="h-4 w-4 text-gray-400" /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| <div className="p-5 flex-1 flex flex-col"> | |
| {/* Header with badge and timestamp - properly aligned on same line */} | |
| <div className="flex items-center justify-between gap-2 mb-3"> | |
| <GradientBadge>{formatNiche(ad.niche)}</GradientBadge> | |
| <span className="text-xs text-gray-500 font-medium flex-shrink-0"> | |
| {formatRelativeDate(ad.created_at)} | |
| </span> | |
| </div> | |
| {/* Headline - improved typography */} | |
| <h3 className="font-bold text-lg text-gray-900 line-clamp-2 mb-3 group-hover:text-blue-600 transition-colors leading-tight"> | |
| {ad.headline} | |
| </h3> | |
| {/* Description section - improved spacing and hierarchy */} | |
| <div className="mt-auto space-y-1.5"> | |
| {ad.title && ( | |
| <p className="text-sm text-gray-700 line-clamp-1 font-medium"> | |
| {ad.title} | |
| </p> | |
| )} | |
| {ad.psychological_angle && ( | |
| <p className="text-xs text-gray-500 line-clamp-1 italic"> | |
| {ad.psychological_angle} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Link> | |
| </Card> | |
| ); | |
| }, (prevProps, nextProps) => { | |
| return ( | |
| prevProps.ad.id === nextProps.ad.id && | |
| prevProps.isSelected === nextProps.isSelected && | |
| prevProps.hasAnySelection === nextProps.hasAnySelection && | |
| prevProps.ad.image_url === nextProps.ad.image_url && | |
| prevProps.ad.image_filename === nextProps.ad.image_filename && | |
| prevProps.ad.r2_url === nextProps.ad.r2_url | |
| ); | |
| }); | |
| AdCard.displayName = "AdCard"; | |