Spaces:
Running
Running
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; | |