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.
497 lines
25 KiB
TypeScript
497 lines
25 KiB
TypeScript
import { Staff, Media } from '@/types';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye,
|
|
BookOpen, Theater, ArrowUpAZ, ArrowDownAZ, ArrowUpDown, Star
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface CastDetailViewProps {
|
|
person: Staff;
|
|
relatedMedia: Media[];
|
|
}
|
|
|
|
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
|
|
const navigate = useNavigate();
|
|
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
|
|
const handleMediaClick = (mediaId: string) => {
|
|
navigate(`/media/${mediaId}`);
|
|
};
|
|
|
|
const sortedFilmography = [...(person.filmography || [])].sort((a, b) => {
|
|
let comparison = 0;
|
|
if (sortBy === 'year') {
|
|
comparison = (a.year || 0) - (b.year || 0);
|
|
} else if (sortBy === 'title') {
|
|
comparison = (a.title || '').localeCompare(b.title || '');
|
|
} else if (sortBy === 'role') {
|
|
comparison = (a.role || '').localeCompare(b.role || '');
|
|
}
|
|
return sortOrder === 'asc' ? comparison : -comparison;
|
|
});
|
|
// Sort options
|
|
const sortOptions = [
|
|
{ value: 'year', label: 'Year', icon: Calendar },
|
|
{ value: 'title', label: 'Title', icon: ArrowUpAZ },
|
|
{ value: 'role', label: 'Role', icon: Briefcase },
|
|
] as const;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background pb-16">
|
|
{/* Compact Hero Section */}
|
|
<div className="relative h-[35vh] md:h-[40vh] overflow-hidden bg-zinc-900">
|
|
<img
|
|
src={person.photo}
|
|
alt={person.name}
|
|
className="w-full h-full object-cover opacity-30 blur-xl scale-110"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
|
|
|
<div className="absolute inset-0 flex items-end px-4 sm:px-6 pb-8">
|
|
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="shrink-0"
|
|
>
|
|
<Avatar className="h-32 md:h-40 w-auto aspect-[3/4] rounded-none border-3 border-background shadow-2xl">
|
|
<AvatarImage
|
|
src={person.photo}
|
|
alt={person.name}
|
|
className="object-cover"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
<AvatarFallback className="rounded-none text-3xl">
|
|
<User className="h-12 w-12" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</motion.div>
|
|
|
|
<div className="flex-1 text-center md:text-left pb-2">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.1 }}
|
|
>
|
|
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
|
{person.name}
|
|
</h1>
|
|
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
|
{person.occupations?.map(occ => (
|
|
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-medium px-3 py-1 text-xs">
|
|
{occ}
|
|
</Badge>
|
|
))}
|
|
{person.filmography && person.filmography.length > 0 && (
|
|
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-medium px-3 py-1 text-xs">
|
|
<Star className="w-3 h-3 mr-1" />
|
|
{person.filmography.length}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(-1)}
|
|
className="absolute top-20 left-4 sm:left-6 bg-white/20 hover:bg-white/40 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Content Section */}
|
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Sidebar Info - Modern shadcn Design */}
|
|
<div className="space-y-4 lg:col-span-1">
|
|
{/* Personal Info Card */}
|
|
<Card className="border-border/60 overflow-hidden">
|
|
<CardHeader className="py-3 px-4 border-b border-border/40">
|
|
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
|
<div className="w-5 h-5 rounded bg-[#6d28d9]/10 flex items-center justify-center">
|
|
<User size={12} className="text-[#6d28d9]" />
|
|
</div>
|
|
Personal Info
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{/* Birth Date */}
|
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
|
<Calendar size={14} />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Born</span>
|
|
</div>
|
|
<span className="text-sm font-medium">{person.birthDate || '—'}</span>
|
|
</div>
|
|
<Separator />
|
|
|
|
{/* Birth Place */}
|
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
|
<MapPin size={14} />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Origin</span>
|
|
</div>
|
|
<span className="text-sm font-medium truncate max-w-[140px]" title={person.birthPlace || undefined}>
|
|
{person.birthPlace || '—'}
|
|
</span>
|
|
</div>
|
|
<Separator />
|
|
|
|
{/* Known For */}
|
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
|
<Briefcase size={14} />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Role</span>
|
|
</div>
|
|
<Badge variant="secondary" className="text-xs font-normal bg-[#6d28d9]/10 text-[#6d28d9] border-none">
|
|
{person.role}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Ethnicity - only if present */}
|
|
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
|
<>
|
|
<Separator />
|
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
|
<User size={14} />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">Ethnicity</span>
|
|
</div>
|
|
<span className="text-sm font-medium truncate max-w-[140px]">
|
|
{person.adult_specifics?.ethnicity || person.ethnicity}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Measurements Card - Only if data exists */}
|
|
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight ||
|
|
person.adult_specifics?.measurements || person.bust_size || person.hair_color || person.adult_specifics?.hair_color) && (
|
|
<Card className="border-border/60 overflow-hidden">
|
|
<CardHeader className="py-3 px-4 border-b border-border/40">
|
|
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
|
<div className="w-5 h-5 rounded bg-[#6d28d9]/10 flex items-center justify-center">
|
|
<Ruler size={12} className="text-[#6d28d9]" />
|
|
</div>
|
|
Measurements
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{/* Height & Weight Grid */}
|
|
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight) && (
|
|
<>
|
|
<div className="grid grid-cols-2 divide-x divide-border">
|
|
{(person.adult_specifics?.height || person.height) && (
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Height</p>
|
|
<p className="text-lg font-semibold text-foreground">
|
|
{person.adult_specifics?.height || person.height}
|
|
<span className="text-xs font-normal text-muted-foreground ml-0.5">cm</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
{(person.adult_specifics?.weight || person.weight) && (
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Weight</p>
|
|
<p className="text-lg font-semibold text-foreground">
|
|
{person.adult_specifics?.weight || person.weight}
|
|
<span className="text-xs font-normal text-muted-foreground ml-0.5">kg</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Separator />
|
|
</>
|
|
)}
|
|
|
|
{/* Measurements (Bust-Waist-Hip) */}
|
|
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
|
<>
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1.5">Figure</p>
|
|
<p className="text-sm font-medium font-mono tracking-wide">
|
|
{person.adult_specifics?.measurements || (
|
|
<>
|
|
{person.bust_size && <span className="inline-flex items-center gap-0.5">{person.bust_size}{person.cup_size && <span className="text-xs text-muted-foreground">{person.cup_size}</span>}</span>}
|
|
{(person.bust_size || person.cup_size) && person.waist_size && <span className="text-muted-foreground mx-1">—</span>}
|
|
{person.waist_size && <span>{person.waist_size}</span>}
|
|
{person.hip_size && <span className="text-muted-foreground mx-1">—</span>}
|
|
{person.hip_size && <span>{person.hip_size}</span>}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Separator />
|
|
</>
|
|
)}
|
|
|
|
{/* Hair & Eyes Grid */}
|
|
<div className="grid grid-cols-2 divide-x divide-border">
|
|
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Palette size={12} className="text-[#6d28d9]" />
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
|
|
</div>
|
|
<p className="text-sm font-medium truncate">
|
|
{person.adult_specifics?.hair_color || person.hair_color}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Eye size={12} className="text-[#6d28d9]" />
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
|
|
</div>
|
|
<p className="text-sm font-medium truncate">
|
|
{person.adult_specifics?.eye_color || person.eye_color}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tattoos & Piercings */}
|
|
{(person.adult_specifics?.tattoos || person.adult_specifics?.piercings) && (
|
|
<>
|
|
<Separator />
|
|
<div className="grid grid-cols-2 divide-x divide-border">
|
|
{person.adult_specifics?.tattoos && (
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Tattoos</p>
|
|
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.tattoos}</p>
|
|
</div>
|
|
)}
|
|
{person.adult_specifics?.piercings && (
|
|
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Piercings</p>
|
|
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.piercings}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Bio & Roles - Wider */}
|
|
<div className="lg:col-span-3">
|
|
<Tabs defaultValue={person.bio ? 'bio' : 'filmography'} className="w-full">
|
|
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto">
|
|
{person.bio && (
|
|
<TabsTrigger value="bio" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
|
<BookOpen size={14} />
|
|
Biography
|
|
</TabsTrigger>
|
|
)}
|
|
{person.filmography && person.filmography.length > 0 && (
|
|
<>
|
|
<TabsTrigger value="characters" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
|
<Theater size={14} />
|
|
Characters
|
|
</TabsTrigger>
|
|
<TabsTrigger value="filmography" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
|
<Film size={14} />
|
|
Filmography
|
|
</TabsTrigger>
|
|
</>
|
|
)}
|
|
</TabsList>
|
|
|
|
{person.bio && (
|
|
<TabsContent value="bio" className="mt-0">
|
|
<Card className="border-border/60">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base font-semibold">Biography</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<p className="text-foreground leading-relaxed text-sm">
|
|
{person.bio}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
)}
|
|
|
|
{person.filmography && person.filmography.length > 0 && (
|
|
<>
|
|
<TabsContent value="characters" className="mt-0">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
<AnimatePresence mode="popLayout">
|
|
{person.filmography.map((item, index) => (
|
|
<motion.div
|
|
key={`${item.id}-char`}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.03 }}
|
|
>
|
|
<Card
|
|
className="hover:border-[#6d28d9]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
|
|
onClick={() => handleMediaClick(item.id.toString())}
|
|
>
|
|
<CardContent className="p-3 flex items-center gap-3">
|
|
<div className="w-14 h-14 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
|
<img
|
|
src={item.poster || person.photo}
|
|
alt={item.title}
|
|
className="w-full h-full object-cover"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
|
|
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#6d28d9] transition-colors">
|
|
{item.characterName || item.role}
|
|
</h4>
|
|
<p className="text-xs text-[#6d28d9] truncate">{item.title}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="filmography" className="mt-0">
|
|
{/* Sort Toolbar */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
{person.filmography.length} {person.filmography.length === 1 ? 'title' : 'titles'}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 px-2.5 rounded-lg text-xs border-border/60"
|
|
>
|
|
<ArrowUpDown size={14} className="mr-1.5" />
|
|
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
|
|
{sortOptions.find(o => o.value === sortBy)?.label}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
|
Sort by
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
{sortOptions.map(option => (
|
|
<DropdownMenuItem
|
|
key={option.value}
|
|
onClick={() => {
|
|
if (sortBy === option.value) {
|
|
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortBy(option.value);
|
|
setSortOrder('asc');
|
|
}
|
|
}}
|
|
className="flex items-center justify-between text-xs"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<option.icon size={14} />
|
|
{option.label}
|
|
</span>
|
|
{sortBy === option.value && (
|
|
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#6d28d9]" /> : <ArrowDownAZ size={14} className="text-[#6d28d9]" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filmography Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
<AnimatePresence mode="popLayout">
|
|
{sortedFilmography.map((item, index) => (
|
|
<motion.div
|
|
key={item.id}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.03 }}
|
|
>
|
|
<Card
|
|
onClick={() => handleMediaClick(item.id.toString())}
|
|
className="group cursor-pointer hover:border-[#6d28d9]/30 hover:shadow-md transition-all duration-200 border-border/60"
|
|
>
|
|
<CardContent className="p-3 flex items-center gap-3">
|
|
<div className="w-12 h-16 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
|
<img
|
|
src={item.poster || person.photo}
|
|
alt={item.title}
|
|
className="w-full h-full object-cover"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#6d28d9] transition-colors">
|
|
{item.title}
|
|
</h4>
|
|
<p className="text-xs text-muted-foreground mb-1">
|
|
{item.year || 'Unknown'}
|
|
</p>
|
|
<div className="flex items-center gap-1.5">
|
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 font-normal">
|
|
{item.role}
|
|
</Badge>
|
|
{item.category && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 bg-muted font-normal">
|
|
{item.category}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</TabsContent>
|
|
</>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|