Files
beStream/src/pages/SeriesDetails.tsx
2025-12-14 12:57:37 +01:00

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