web / frontend /src /components /ContentCard.tsx
Chandima Prabhath
Track bun.lockb with Git LFS
cc2caf9
raw
history blame
14 kB
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;