Introduce a reusable Loading component (src/components/ui/loading.tsx) that shows a spinning Loader2 icon and an optional message. Replace ad-hoc loading UIs by importing and using Loading in BrowseView and CastView. In App.tsx, add mediaLoading state (set around fetchAllMedia) and pass it to BrowseView; also add local loading states to MediaDetailRoute and CastDetailRoute to show Loading while fetching details. These changes centralize loading UX and remove duplicated spinner markup.
441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
import { Staff, MediaCategory } from '@/types';
|
|
import { useState, useMemo, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import Loading from '@/components/ui/loading';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { cn } from '@/lib/utils';
|
|
import { fetchAllCast } from '@/api';
|
|
|
|
interface CastViewProps {
|
|
onPersonClick: (person: Staff) => void;
|
|
enabledCategories: MediaCategory[];
|
|
itemsPerPage?: number;
|
|
}
|
|
|
|
export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) {
|
|
const navigate = useNavigate();
|
|
const [staffList, setStaffList] = useState<Staff[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState(() => {
|
|
return localStorage.getItem('castSearchQuery') || '';
|
|
});
|
|
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
|
|
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
|
|
});
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
|
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
|
});
|
|
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
|
return localStorage.getItem('castFilterOccupation') || '';
|
|
});
|
|
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
|
return localStorage.getItem('castFilterMediaType') || '';
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
// Sync itemsPerPage with prop when API settings are loaded
|
|
useEffect(() => {
|
|
if (initialItemsPerPage) {
|
|
setItemsPerPage(initialItemsPerPage);
|
|
}
|
|
}, [initialItemsPerPage]);
|
|
|
|
// Persist filters and sorts
|
|
useEffect(() => {
|
|
localStorage.setItem('castSearchQuery', searchQuery);
|
|
}, [searchQuery]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('castSortBy', sortBy);
|
|
}, [sortBy]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('castSortOrder', sortOrder);
|
|
}, [sortOrder]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('castFilterOccupation', filterOccupation);
|
|
}, [filterOccupation]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('castFilterMediaType', filterMediaType);
|
|
}, [filterMediaType]);
|
|
|
|
const handleResetFilters = () => {
|
|
setSearchQuery('');
|
|
setSortBy('roleCount');
|
|
setSortOrder('desc');
|
|
setFilterOccupation('');
|
|
setFilterMediaType('');
|
|
};
|
|
|
|
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
|
|
|
useEffect(() => {
|
|
const loadCast = async () => {
|
|
try {
|
|
const cast = await fetchAllCast();
|
|
setStaffList(cast);
|
|
} catch (error) {
|
|
console.error('Failed to load cast:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
loadCast();
|
|
}, []);
|
|
|
|
const filteredStaff = useMemo(() => {
|
|
let list = staffList.filter(s => {
|
|
// Hide actors without linked media
|
|
if (!s.filmography || s.filmography.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Filter by enabled categories based on media_types
|
|
if (s.media_types && s.media_types.length > 0) {
|
|
const hasEnabledMediaType = s.media_types.some(type => {
|
|
const category = type.charAt(0).toUpperCase() + type.slice(1);
|
|
return enabledCategories.includes(category as MediaCategory);
|
|
});
|
|
if (!hasEnabledMediaType) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Filter by occupation
|
|
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
|
|
return false;
|
|
}
|
|
|
|
// Filter by media type
|
|
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
|
|
return false;
|
|
}
|
|
|
|
return s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.role.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
s.mediaTitle?.toLowerCase().includes(searchQuery.toLowerCase());
|
|
});
|
|
|
|
// Sort
|
|
list.sort((a, b) => {
|
|
let comparison = 0;
|
|
|
|
if (sortBy === 'name' || sortBy === 'role') {
|
|
comparison = a[sortBy].localeCompare(b[sortBy]);
|
|
} else if (sortBy === 'birthDate') {
|
|
const dateA = a.birthDate ? new Date(a.birthDate).getTime() : 0;
|
|
const dateB = b.birthDate ? new Date(b.birthDate).getTime() : 0;
|
|
comparison = dateA - dateB;
|
|
} else if (sortBy === 'height') {
|
|
const heightA = a.height || 0;
|
|
const heightB = b.height || 0;
|
|
comparison = heightA - heightB;
|
|
} else if (sortBy === 'roleCount') {
|
|
const roleCountA = a.filmography?.length || 0;
|
|
const roleCountB = b.filmography?.length || 0;
|
|
comparison = roleCountA - roleCountB;
|
|
}
|
|
|
|
return sortOrder === 'desc' ? -comparison : comparison;
|
|
});
|
|
|
|
return list;
|
|
}, [staffList, searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, enabledCategories]);
|
|
|
|
// Get unique occupations and media types for filters
|
|
const uniqueOccupations = useMemo(() => {
|
|
const occupations = new Set<string>();
|
|
staffList.forEach(s => s.occupations?.forEach(o => occupations.add(o)));
|
|
return Array.from(occupations).sort();
|
|
}, [staffList]);
|
|
|
|
const uniqueMediaTypes = useMemo(() => {
|
|
const mediaTypes = new Set<string>();
|
|
staffList.forEach(s => s.media_types?.forEach(m => mediaTypes.add(m)));
|
|
return Array.from(mediaTypes).sort();
|
|
}, [staffList]);
|
|
|
|
// Reset to first page when filters or sorting change
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchQuery, sortBy, sortOrder, filterOccupation, filterMediaType, itemsPerPage]);
|
|
|
|
const totalPages = Math.ceil(filteredStaff.length / itemsPerPage);
|
|
|
|
const paginatedStaff = useMemo(() => {
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
|
|
}, [filteredStaff, currentPage, itemsPerPage]);
|
|
|
|
const handlePrevPage = () => {
|
|
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
const handleNextPage = () => {
|
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
return (
|
|
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
|
|
<div>
|
|
<h1 className="text-4xl font-black text-foreground mb-2">Cast & Staff</h1>
|
|
<p className="text-muted-foreground font-medium">Discover the people behind your favorite media</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
|
<Input
|
|
placeholder="Search cast..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 w-full md:w-[300px] bg-muted border-none rounded-full h-11"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant={showFilters ? 'default' : 'outline'}
|
|
size="icon"
|
|
className={`rounded-full h-11 w-11 ${showFilters ? 'bg-[#6d28d9] text-white border-[#6d28d9]' : 'border-border'}`}
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
>
|
|
<Filter size={20} />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-full h-11 w-11 border-border"
|
|
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
|
|
>
|
|
<ArrowUpDown size={20} />
|
|
</Button>
|
|
{hasActiveFilters && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="rounded-full h-11 w-11 text-muted-foreground hover:text-foreground"
|
|
onClick={handleResetFilters}
|
|
title="Reset filters"
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showFilters && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="bg-muted/50 rounded-2xl p-6 mb-6 border border-border"
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div>
|
|
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as any)}
|
|
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
|
>
|
|
<option value="name">Name</option>
|
|
<option value="role">Role</option>
|
|
<option value="birthDate">Birth Date</option>
|
|
<option value="height">Height</option>
|
|
<option value="roleCount">Role Count</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
|
|
<select
|
|
value={filterOccupation}
|
|
onChange={(e) => setFilterOccupation(e.target.value)}
|
|
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
|
>
|
|
<option value="">All Occupations</option>
|
|
{uniqueOccupations.map(occ => (
|
|
<option key={occ} value={occ}>{occ}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
|
|
<select
|
|
value={filterMediaType}
|
|
onChange={(e) => setFilterMediaType(e.target.value)}
|
|
className="w-full bg-background border-border rounded-lg px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
|
>
|
|
<option value="">All Media Types</option>
|
|
{uniqueMediaTypes.map(type => (
|
|
<option key={type} value={type}>{type}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex items-center gap-2">
|
|
{searchQuery && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
Search: {searchQuery}
|
|
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
|
|
<X size={12} />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
{filterOccupation && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
Occupation: {filterOccupation}
|
|
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
|
|
<X size={12} />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
{filterMediaType && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
Media Type: {filterMediaType}
|
|
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
|
|
<X size={12} />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
{(sortBy !== 'name' || sortOrder !== 'asc') && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
Sort: {sortBy} ({sortOrder})
|
|
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
|
|
<X size={12} />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<Loading message="Loading cast..." />
|
|
) : filteredStaff.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
<User size={48} className="mb-4 opacity-20" />
|
|
<p className="text-lg font-bold">No cast members found</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
<AnimatePresence mode="popLayout">
|
|
{paginatedStaff.map((person) => (
|
|
<motion.div
|
|
key={person.id}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
className="group bg-card rounded-2xl p-4 shadow-sm border border-border hover:shadow-xl hover:border-[#6d28d9]/20 transition-all duration-300 cursor-pointer"
|
|
onClick={() => onPersonClick(person)}
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border group-hover:border-[#6d28d9] transition-colors">
|
|
<img
|
|
src={person.photo}
|
|
alt={person.name}
|
|
className="w-full h-full object-cover"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
|
{person.name}
|
|
</h3>
|
|
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
|
{person.role}
|
|
</p>
|
|
</div>
|
|
{person.filmography && person.filmography.length > 0 && (
|
|
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
|
|
{person.filmography.length}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{person.filmography && person.filmography.length > 0 && (
|
|
<div className="bg-muted/50 rounded-xl p-3 flex items-center gap-3">
|
|
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background">
|
|
<img
|
|
src={person.filmography[0].poster || person.photo}
|
|
alt={person.filmography[0].title}
|
|
className="w-full h-full object-cover"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
|
|
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
|
|
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination Controls */}
|
|
{filteredStaff.length > 0 && (
|
|
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
|
<select
|
|
value={itemsPerPage}
|
|
onChange={(e) => {
|
|
setItemsPerPage(Number(e.target.value));
|
|
}}
|
|
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
|
>
|
|
{[12, 20, 36, 48, 60].map(size => (
|
|
<option key={size} value={size}>{size}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handlePrevPage}
|
|
disabled={currentPage === 1}
|
|
className="gap-2 font-bold border-border"
|
|
>
|
|
<ChevronLeft size={16} />
|
|
Previous
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
|
<span className="text-sm text-muted-foreground font-medium">of</span>
|
|
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleNextPage}
|
|
disabled={currentPage === totalPages || totalPages === 0}
|
|
className="gap-2 font-bold border-border"
|
|
>
|
|
Next
|
|
<ChevronRight size={16} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|