Files
mystuff_frontend/src/components/CastDetailView.tsx
T
Lars Behrends 073c8a6c5d Integrate shadcn UI & add UI primitives
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.
2026-04-26 02:18:01 +02:00

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