241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import {
|
|
Play,
|
|
Star,
|
|
Calendar,
|
|
Clock,
|
|
Tv,
|
|
Download,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} from 'lucide-react';
|
|
import { useSeriesDetails, useEpisodes } from '../hooks/useSeries';
|
|
import { SeasonList } from '../components/tv';
|
|
import Button from '../components/ui/Button';
|
|
import Badge from '../components/ui/Badge';
|
|
import { SeriesDetailsSkeleton } from '../components/ui/Skeleton';
|
|
import type { UnifiedEpisode } from '../types/unified';
|
|
|
|
export default function SeriesDetails() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const { series, isLoading: seriesLoading, error: seriesError } = useSeriesDetails(id);
|
|
const { episodes, isLoading: episodesLoading } = useEpisodes(id);
|
|
|
|
const [showFullOverview, setShowFullOverview] = useState(false);
|
|
|
|
if (seriesLoading) return <SeriesDetailsSkeleton />;
|
|
|
|
if (seriesError || !series) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-netflix-black">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold mb-2">Series not found</h1>
|
|
<p className="text-gray-400 mb-4">{seriesError}</p>
|
|
<Button onClick={() => navigate(-1)}>Go Back</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleEpisodeClick = (episode: UnifiedEpisode) => {
|
|
if (episode.hasFile) {
|
|
// Navigate to player with episode
|
|
navigate(`/player/episode/${episode.id}`);
|
|
}
|
|
};
|
|
|
|
const handleSeasonDownload = (seasonNumber: number) => {
|
|
// TODO: Trigger season search in Sonarr
|
|
console.log('Download season', seasonNumber);
|
|
};
|
|
|
|
const statusColor = {
|
|
continuing: 'success',
|
|
ended: 'default',
|
|
upcoming: 'info',
|
|
deleted: 'error',
|
|
} as const;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="min-h-screen bg-netflix-black"
|
|
>
|
|
{/* Hero Background */}
|
|
<div className="relative h-[50vh] md:h-[60vh]">
|
|
<img
|
|
src={series.fanart || series.poster}
|
|
alt={series.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
|
|
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black via-transparent to-transparent" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="max-w-7xl mx-auto px-4 md:px-8 -mt-48 md:-mt-64 relative z-10">
|
|
<div className="flex flex-col md:flex-row gap-8">
|
|
{/* Poster */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="flex-shrink-0 hidden md:block"
|
|
>
|
|
<img
|
|
src={series.poster}
|
|
alt={series.title}
|
|
className="w-[250px] rounded-lg shadow-2xl"
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Details */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
className="flex-1"
|
|
>
|
|
{/* Title */}
|
|
<h1 className="text-3xl md:text-5xl font-bold mb-4">{series.title}</h1>
|
|
|
|
{/* Meta */}
|
|
<div className="flex flex-wrap items-center gap-3 md:gap-4 mb-4">
|
|
{series.rating && (
|
|
<span className="flex items-center gap-1 text-green-400 font-semibold">
|
|
<Star size={18} fill="currentColor" />
|
|
{series.rating.toFixed(1)}/10
|
|
</span>
|
|
)}
|
|
{series.year && (
|
|
<span className="flex items-center gap-1 text-gray-300">
|
|
<Calendar size={16} />
|
|
{series.year}
|
|
</span>
|
|
)}
|
|
{series.runtime && (
|
|
<span className="flex items-center gap-1 text-gray-300">
|
|
<Clock size={16} />
|
|
{series.runtime} min
|
|
</span>
|
|
)}
|
|
<Badge variant={statusColor[series.status]}>
|
|
{series.status}
|
|
</Badge>
|
|
{series.network && (
|
|
<Badge variant="info">{series.network}</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex flex-wrap gap-3 mb-6">
|
|
<Badge size="lg" className="bg-white/10">
|
|
<Tv size={14} className="mr-1" />
|
|
{series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''}
|
|
</Badge>
|
|
<Badge size="lg" className="bg-white/10">
|
|
{series.episodeFileCount}/{series.episodeCount} Episodes
|
|
</Badge>
|
|
{series.nextAiring && (
|
|
<Badge size="lg" variant="info">
|
|
Next: {new Date(series.nextAiring).toLocaleDateString()}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Genres */}
|
|
{series.genres && series.genres.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
{series.genres.map((genre) => (
|
|
<span
|
|
key={genre}
|
|
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-full text-sm transition-colors"
|
|
>
|
|
{genre}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-wrap items-center gap-3 mb-8">
|
|
{series.episodeFileCount > 0 && (
|
|
<Button size="lg" leftIcon={<Play size={20} fill="white" />}>
|
|
Play
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="secondary"
|
|
size="lg"
|
|
leftIcon={<Download size={20} />}
|
|
>
|
|
Download All
|
|
</Button>
|
|
<Button
|
|
variant="icon"
|
|
size="lg"
|
|
leftIcon={<RefreshCw size={20} />}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Overview */}
|
|
{series.overview && (
|
|
<div className="mb-8">
|
|
<h3 className="text-xl font-semibold mb-2">Overview</h3>
|
|
<p className={`text-gray-300 leading-relaxed ${!showFullOverview && 'line-clamp-4'}`}>
|
|
{series.overview}
|
|
</p>
|
|
{series.overview.length > 300 && (
|
|
<button
|
|
onClick={() => setShowFullOverview(!showFullOverview)}
|
|
className="flex items-center gap-1 text-netflix-red mt-2 hover:underline"
|
|
>
|
|
{showFullOverview ? (
|
|
<>Show Less <ChevronUp size={16} /></>
|
|
) : (
|
|
<>Show More <ChevronDown size={16} /></>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Seasons & Episodes */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
className="mt-8"
|
|
>
|
|
<h2 className="text-2xl font-semibold mb-4">Seasons & Episodes</h2>
|
|
{episodesLoading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="glass rounded-lg h-16 animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<SeasonList
|
|
seasons={series.seasons || []}
|
|
episodes={episodes}
|
|
onEpisodeClick={handleEpisodeClick}
|
|
onSeasonDownload={handleSeasonDownload}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|