|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
export interface Trailer { |
|
|
id: number; |
|
|
name: string; |
|
|
url: string; |
|
|
language: string; |
|
|
runtime: number; |
|
|
} |
|
|
|
|
|
|
|
|
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 }[]; |
|
|
} |
|
|
|
|
|
|
|
|
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)}`; |
|
|
|
|
|
|
|
|
const description = cardData?.overview || initialDescription || ''; |
|
|
const genre = (cardData?.genres?.map((g: any) => g.name) || initialGenre || []); |
|
|
const year = cardData?.year || initialYear || ''; |
|
|
|
|
|
|
|
|
const selectRandomImage = (cardData: MovieCardData | TvShowCardData | null) => { |
|
|
if (!cardData) return null; |
|
|
|
|
|
|
|
|
if (cardData.banner && cardData.banner.length > 0) { |
|
|
const randomIndex = Math.floor(Math.random() * cardData.banner.length); |
|
|
return cardData.banner[randomIndex].image; |
|
|
} |
|
|
|
|
|
|
|
|
if (cardData.portrait && cardData.portrait.length > 0) { |
|
|
const randomIndex = Math.floor(Math.random() * cardData.portrait.length); |
|
|
return cardData.portrait[randomIndex].image; |
|
|
} |
|
|
|
|
|
|
|
|
return cardData.image || image || fallbackImage; |
|
|
}; |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const checkMyList = async () => { |
|
|
const isInList = await isInMyList(title, type); |
|
|
setInMyList(isInList); |
|
|
}; |
|
|
|
|
|
checkMyList(); |
|
|
}, [title, type]); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
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] h-full card-hover group" |
|
|
onMouseEnter={() => setIsHovered(true)} |
|
|
onMouseLeave={() => setIsHovered(false)} |
|
|
> |
|
|
<div className="relative rounded-md overflow-hidden shadow-xl bg-theme-card h-[170px] md:h-[170px]"> |
|
|
{/* 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 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/40 to-theme-background-dark |
|
|
transition-all duration-300 flex flex-col justify-between p-3 w-full h-full |
|
|
${isHovered ? 'opacity-100 backdrop-blur-md' : 'opacity-0 pointer-events-none backdrop-blur-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; |
|
|
|