| |
|
| | 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; |
| |
|