Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { Link } from 'react-router-dom'; | |
| import { Play, Info, Plus, Check, Clock, Loader2 } from 'lucide-react'; | |
| import { getMovieCard, getTvShowCard } from '../lib/api'; | |
| import { isInMyList, addToMyList, removeFromMyList } from '../lib/storage'; | |
| import { useToast } from '@/hooks/use-toast'; | |
| // -- Common Trailer type -- | |
| export interface Trailer { | |
| id: number; | |
| name: string; | |
| url: string; | |
| language: string; | |
| runtime: number; | |
| } | |
| // -- TV Show types -- | |
| export interface TvShowPortrait { | |
| id: number; | |
| image: string; | |
| thumbnail: string; | |
| language: string; | |
| type: number; | |
| score: number; | |
| width: number; | |
| height: number; | |
| includesText: boolean; | |
| thumbnailWidth: number; | |
| thumbnailHeight: number; | |
| updatedAt: number; | |
| status: { | |
| id: number; | |
| name: string | null; | |
| }; | |
| tagOptions: any; | |
| } | |
| export interface TvShowBanner { | |
| id: number; | |
| image: string; | |
| thumbnail: string; | |
| language: string; | |
| type: number; | |
| score: number; | |
| width: number; | |
| height: number; | |
| includesText: boolean; | |
| thumbnailWidth: number; | |
| thumbnailHeight: number; | |
| updatedAt: number; | |
| status: { | |
| id: number; | |
| name: string | null; | |
| }; | |
| tagOptions: any; | |
| } | |
| export interface TvShowCardData { | |
| title: string; | |
| year: string; | |
| image: string; | |
| portrait: TvShowPortrait[]; | |
| banner: TvShowBanner[]; | |
| overview: string; | |
| trailers: Trailer[]; | |
| genres?: { name: string }[]; | |
| } | |
| // -- Movie types -- | |
| export interface MoviePortrait { | |
| id: number; | |
| image: string; | |
| thumbnail: string; | |
| language: string; | |
| type: number; | |
| score: number; | |
| width: number; | |
| height: number; | |
| includesText: boolean; | |
| } | |
| export interface MovieBanner { | |
| id: number; | |
| image: string; | |
| thumbnail: string; | |
| language: string | null; | |
| type: number; | |
| score: number; | |
| width: number; | |
| height: number; | |
| includesText: boolean; | |
| } | |
| export interface MovieCardData { | |
| title: string; | |
| year: string; | |
| image: string; | |
| portrait: MoviePortrait[]; | |
| banner: MovieBanner[]; | |
| overview: string; | |
| trailers: Trailer[]; | |
| genres?: { name: string }[]; | |
| } | |
| interface ContentCardProps { | |
| type: 'movie' | 'tvshow'; | |
| title: string; | |
| image?: string; | |
| description?: string; | |
| genre?: string[]; | |
| year?: number | string; | |
| prefetchData?: boolean; | |
| } | |
| interface PlaybackProgress { | |
| currentTime: number; | |
| duration: number; | |
| lastPlayed: string; | |
| completed: boolean; | |
| } | |
| const ContentCard: React.FC<ContentCardProps> = ({ | |
| type, | |
| title, | |
| image, | |
| description: initialDescription, | |
| genre: initialGenre, | |
| year: initialYear, | |
| prefetchData = true | |
| }) => { | |
| const [isHovered, setIsHovered] = useState(false); | |
| const [progress, setProgress] = useState<{ percent: number, completed: boolean } | null>(null); | |
| const [loading, setLoading] = useState(prefetchData); | |
| const [cardData, setCardData] = useState<MovieCardData | TvShowCardData | null>(null); | |
| const [inMyList, setInMyList] = useState(false); | |
| const [addingToList, setAddingToList] = useState(false); | |
| const [selectedImage, setSelectedImage] = useState<string | null>(null); | |
| const { toast } = useToast(); | |
| const fallbackImage = '/placeholder.svg'; | |
| const path = type === 'movie' ? `/movie/${encodeURIComponent(title)}` : `/tv-show/${encodeURIComponent(title)}`; | |
| // Derived data with fallbacks | |
| const description = cardData?.overview || initialDescription || ''; | |
| const genre = (cardData?.genres?.map((g: any) => g.name) || initialGenre || []); | |
| const year = cardData?.year || initialYear || ''; | |
| // Function to randomly select an image from available banners or portraits | |
| const selectRandomImage = (cardData: MovieCardData | TvShowCardData | null) => { | |
| if (!cardData) return null; | |
| // First try to get banner images (landscape) | |
| if (cardData.banner && cardData.banner.length > 0) { | |
| const randomIndex = Math.floor(Math.random() * cardData.banner.length); | |
| return cardData.banner[randomIndex].image; | |
| } | |
| // Fall back to portrait images if no banners | |
| if (cardData.portrait && cardData.portrait.length > 0) { | |
| const randomIndex = Math.floor(Math.random() * cardData.portrait.length); | |
| return cardData.portrait[randomIndex].image; | |
| } | |
| // Finally fall back to the default image | |
| return cardData.image || image || fallbackImage; | |
| }; | |
| // Check if item is in user's list | |
| useEffect(() => { | |
| const checkMyList = async () => { | |
| const isInList = await isInMyList(title, type); | |
| setInMyList(isInList); | |
| }; | |
| checkMyList(); | |
| }, [title, type]); | |
| // Toggle my list status | |
| const toggleMyList = async (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setAddingToList(true); | |
| try { | |
| if (inMyList) { | |
| await removeFromMyList(title, type); | |
| setInMyList(false); | |
| toast({ | |
| title: "Removed from My List", | |
| description: `${title} has been removed from your list` | |
| }); | |
| } else { | |
| await addToMyList({ | |
| type, | |
| title, | |
| addedAt: new Date().toISOString() | |
| }); | |
| setInMyList(true); | |
| toast({ | |
| title: "Added to My List", | |
| description: `${title} has been added to your list` | |
| }); | |
| } | |
| } catch (error) { | |
| console.error('Error updating My List:', error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to update your list", | |
| variant: "destructive" | |
| }); | |
| } finally { | |
| setAddingToList(false); | |
| } | |
| }; | |
| // Load content data | |
| useEffect(() => { | |
| if (!prefetchData) { | |
| setLoading(false); | |
| return; | |
| } | |
| const fetchData = async () => { | |
| try { | |
| setLoading(true); | |
| let data; | |
| if (type === 'movie') { | |
| data = await getMovieCard(title); | |
| } else { | |
| data = await getTvShowCard(title); | |
| // TV show data is nested in a data property | |
| data = data?.data || data; | |
| } | |
| if (data) { | |
| setCardData(data); | |
| const randomImage = selectRandomImage(data); | |
| setSelectedImage(randomImage); | |
| } | |
| } catch (error) { | |
| console.error(`Error fetching ${type} data:`, error); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| fetchData(); | |
| }, [type, title, prefetchData, image]); | |
| // Load playback progress on mount | |
| useEffect(() => { | |
| try { | |
| const progressKey = type === 'movie' ? `movie-progress-${title}` : `playback-${title}`; | |
| const storedProgress = localStorage.getItem(progressKey); | |
| if (storedProgress) { | |
| let maxProgress = 0; | |
| let isCompleted = false; | |
| if (type === 'movie') { | |
| const progressData = JSON.parse(storedProgress); | |
| maxProgress = Math.min(100, Math.floor((progressData.currentTime / progressData.duration) * 100)); | |
| isCompleted = progressData.completed; | |
| } | |
| // For TV shows, find the latest episode with progress | |
| else { | |
| const progressData = JSON.parse(storedProgress); | |
| let latestPlaybackTime = 0; | |
| Object.values(progressData).forEach((item: PlaybackProgress) => { | |
| if (new Date(item.lastPlayed).getTime() > latestPlaybackTime) { | |
| latestPlaybackTime = new Date(item.lastPlayed).getTime(); | |
| maxProgress = Math.min(100, Math.floor((item.currentTime / item.duration) * 100)); | |
| isCompleted = item.completed; | |
| } | |
| }); | |
| } | |
| if (maxProgress > 0 || isCompleted) { | |
| setProgress({ percent: maxProgress, completed: isCompleted }); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Failed to load playback progress:", error); | |
| } | |
| }, [title, type]); | |
| const displayImage = selectedImage || image || fallbackImage; | |
| return ( | |
| <div | |
| className="relative flex-shrink-0 w-[240px] md:w-[280px] card-hover group" | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| > | |
| <div className="relative rounded-md overflow-hidden shadow-xl bg-theme-card h-[140px] md:h-[160px]"> | |
| {/* Base card image */} | |
| <Link to={path} className="block h-full"> | |
| {loading ? ( | |
| <div className="w-full h-full bg-theme-card flex justify-center items-center animate-pulse"> | |
| <Loader2 className="w-8 h-8 animate-spin text-theme-primary/40" /> | |
| </div> | |
| ) : ( | |
| <img | |
| src={displayImage} | |
| alt={title} | |
| className={`w-full h-full object-cover transition-all duration-300 ${ | |
| isHovered ? 'scale-105 brightness-30' : 'scale-100 brightness-90' | |
| }`} | |
| onError={(e) => { | |
| const target = e.target as HTMLImageElement; | |
| target.src = fallbackImage; | |
| }} | |
| /> | |
| )} | |
| </Link> | |
| {/* Progress indicator */} | |
| {progress && progress.percent > 0 && !progress.completed && ( | |
| <div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-800/50 z-10"> | |
| <div | |
| className="h-full bg-theme-primary" | |
| style={{ width: `${progress.percent}%` }} | |
| ></div> | |
| </div> | |
| )} | |
| {/* Title overlay (simple version when not hovered) */} | |
| <div className={`absolute inset-x-0 bottom-0 p-3 ${isHovered ? 'opacity-0' : 'opacity-100'} | |
| transition-opacity duration-300 bg-gradient-to-t from-black/90 to-transparent`}> | |
| <div className="flex items-center"> | |
| <h3 className="font-bold text-sm line-clamp-1 flex-1">{title}</h3> | |
| {progress?.completed && ( | |
| <div className="ml-1 bg-green-600 text-white p-0.5 rounded-full"> | |
| <Check className="w-3 h-3" /> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex justify-between items-center text-xs text-gray-300 mt-1"> | |
| <div className="flex gap-1 items-center"> | |
| {year && <span>{year}</span>} | |
| {genre && genre.length > 0 && <span className="hidden sm:inline">• {genre[0]}</span>} | |
| </div> | |
| {progress && !progress.completed && progress.percent > 0 && ( | |
| <div className="flex items-center ml-1 text-xs text-gray-400"> | |
| <Clock className="w-3 h-3 mr-0.5" /> | |
| <span>{progress.percent}%</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Expanded hover overlay with detailed info and buttons */} | |
| <div | |
| className={`fixed group-hover:absolute inset-0 z-20 bg-gradient-to-b from-black/90 to-theme-background-dark | |
| transition-opacity duration-300 flex flex-col justify-between p-3 w-[240px] md:w-[280px] h-[140px] md:h-[160px] | |
| ${isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} | |
| > | |
| {/* Top section - title and info */} | |
| <div> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="text-base font-bold line-clamp-1 flex-1">{title}</h3> | |
| {progress?.completed && ( | |
| <div className="ml-1 bg-green-600 text-white p-0.5 rounded-full"> | |
| <Check className="w-3 h-3" /> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex gap-1 items-center text-xs text-gray-300 mt-0.5"> | |
| {year && <span>{year}</span>} | |
| {genre && genre.length > 0 && <span>• {genre[0]}</span>} | |
| </div> | |
| {description && ( | |
| <p className="text-xs mt-2 line-clamp-2 text-gray-300">{description}</p> | |
| )} | |
| {progress && !progress.completed && progress.percent > 0 && ( | |
| <div className="mt-2"> | |
| <div className="relative w-full h-1 bg-gray-800 rounded overflow-hidden"> | |
| <div | |
| className="absolute left-0 top-0 h-full bg-theme-primary" | |
| style={{ width: `${progress.percent}%` }} | |
| ></div> | |
| </div> | |
| <p className="text-xs text-gray-400 mt-1">{progress.percent}% watched</p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Bottom section - action buttons */} | |
| <div className="mt-2"> | |
| <div className="flex justify-between space-x-2"> | |
| <button | |
| onClick={toggleMyList} | |
| disabled={addingToList} | |
| className={`flex-shrink-0 bg-theme-card/80 hover:bg-theme-card-hover border border-theme-border p-2 | |
| rounded-full transition-colors ${addingToList ? 'opacity-50' : ''}`} | |
| > | |
| {addingToList ? ( | |
| <Loader2 className="w-4 h-4 animate-spin" /> | |
| ) : inMyList ? ( | |
| <Check className="w-4 h-4" /> | |
| ) : ( | |
| <Plus className="w-4 h-4" /> | |
| )} | |
| </button> | |
| <Link | |
| to={`${path}/watch`} | |
| className="flex-grow bg-theme-primary hover:bg-theme-primary-hover text-white py-1.5 rounded flex items-center justify-center gap-1 font-medium text-sm transition-colors" | |
| > | |
| <Play className="w-4 h-4" /> | |
| <span>{progress && progress.percent > 0 && !progress.completed ? "Resume" : "Play"}</span> | |
| </Link> | |
| <Link | |
| to={path} | |
| className="flex-shrink-0 bg-theme-card/80 hover:bg-theme-card-hover border border-theme-border p-2 rounded-full transition-colors" | |
| > | |
| <Info className="w-4 h-4" /> | |
| </Link> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ContentCard; | |