073c8a6c5d
Integrates the shadcn/ui design system across the app and adds a collection of reusable UI primitives and layout components. Adds new UI atoms/molecules (avatar, card, collapsible, progress, select, sheet, sidebar, skeleton, table, tabs, toggles, tooltip), app sidebar, media filters, MediaTable, and a mobile hook; updates many views/components to use the new UI. Updates AGENTS.md with styling, layout, accessibility and design standards (Tailwind/shadcn guidance) and adds a registry entry to components.json. Also updates dependencies/lockfile to align shadcn and related packages.
185 lines
8.3 KiB
TypeScript
185 lines
8.3 KiB
TypeScript
import { Episode } from '@/types';
|
|
import { useState, useMemo, useEffect } from 'react';
|
|
import { Search, Play, Clock, Calendar, ChevronDown, Tv } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
|
|
interface SeasonsTabProps {
|
|
episodes: Episode[];
|
|
}
|
|
|
|
export default function SeasonsTab({ episodes }: SeasonsTabProps) {
|
|
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
|
|
|
|
// Group episodes by season
|
|
const episodesBySeason = useMemo(() => {
|
|
if (!episodes) return {};
|
|
const grouped: Record<number, typeof episodes> = {};
|
|
episodes.forEach(episode => {
|
|
if (!grouped[episode.season]) {
|
|
grouped[episode.season] = [];
|
|
}
|
|
grouped[episode.season].push(episode);
|
|
});
|
|
// Sort episodes within each season by episode number
|
|
Object.keys(grouped).forEach(season => {
|
|
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
|
|
});
|
|
return grouped;
|
|
}, [episodes]);
|
|
|
|
// Expand first season by default on mount
|
|
useEffect(() => {
|
|
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
|
|
if (seasons.length > 0) {
|
|
setExpandedSeasons(new Set([seasons[0]]));
|
|
}
|
|
}, [episodesBySeason]);
|
|
|
|
const toggleSeason = (season: number) => {
|
|
setExpandedSeasons(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(season)) {
|
|
newSet.delete(season);
|
|
} else {
|
|
newSet.add(season);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
|
<Tv className="w-4 h-4 text-primary" />
|
|
</div>
|
|
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
|
Episodes
|
|
</h2>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{episodes.length}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
|
<Input
|
|
placeholder="Search episodes..."
|
|
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Seasons */}
|
|
<div className="space-y-3">
|
|
{Object.keys(episodesBySeason)
|
|
.map(Number)
|
|
.sort((a, b) => a - b)
|
|
.map(season => (
|
|
<Collapsible
|
|
key={season}
|
|
open={expandedSeasons.has(season)}
|
|
onOpenChange={() => toggleSeason(season)}
|
|
>
|
|
<Card className="border-border/60 overflow-hidden">
|
|
<CollapsibleTrigger asChild>
|
|
<CardHeader className="py-3 px-4 cursor-pointer hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="font-semibold text-foreground">Season {season}</h3>
|
|
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
|
|
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
</div>
|
|
<ChevronDown
|
|
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${
|
|
expandedSeasons.has(season) ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<CardContent className="p-0">
|
|
<div className="divide-y divide-border/50">
|
|
{episodesBySeason[season].map((episode, index) => (
|
|
<div
|
|
key={episode.id}
|
|
className="group p-4 hover:bg-muted/30 transition-colors cursor-pointer"
|
|
>
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
{/* Thumbnail */}
|
|
<div className="w-full sm:w-[160px] shrink-0 aspect-video rounded-lg overflow-hidden relative bg-muted border border-border/30">
|
|
{episode.thumbnail ? (
|
|
<img
|
|
src={episode.thumbnail}
|
|
alt={episode.title}
|
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Play className="w-8 h-8 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
|
<div className="w-10 h-10 rounded-full bg-primary/90 text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
|
<Play className="w-5 h-5 fill-current ml-0.5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs text-muted-foreground mb-1">
|
|
Episode {episode.episode_number}
|
|
</p>
|
|
<h4 className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
|
|
{episode.title}
|
|
</h4>
|
|
{episode.description && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
|
{episode.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
|
{episode.duration > 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
<span>{episode.duration}m</span>
|
|
</div>
|
|
)}
|
|
{episode.air_date && (
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
<span>{episode.air_date}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Card>
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|