Files
mystuff_frontend/src/components/CastView.tsx
Lars Behrends 04156486e2 Add user settings UI and API integration
Introduce a full user settings feature: add a SettingsView component and UserSettings type, plus API helpers to fetch, create, and update settings (convertors between API and app shapes). App now loads settings on mount, persists category toggles to the API, exposes a /settings route, and passes itemsPerPage into BrowseView and CastView. Header gains a settings icon/link and BrowseView/CastView update pagination option defaults. This enables centralized library/display/content preferences and syncs them with the backend.
2026-04-10 14:14:27 +02:00

426 lines
17 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 { 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'>(() => {
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height') || 'name';
});
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'asc';
});
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);
// 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('name');
setSortOrder('asc');
setFilterOccupation('');
setFilterMediaType('');
};
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc';
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;
}
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-zinc-900 mb-2">Cast & Staff</h1>
<p className="text-zinc-500 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-zinc-400" size={18} />
<Input
placeholder="Search cast..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-full md:w-[300px] bg-zinc-100 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-zinc-200'}`}
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={20} />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full h-11 w-11 border-zinc-200"
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-zinc-400 hover:text-zinc-900"
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-zinc-50 rounded-2xl p-6 mb-6"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Sort By</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 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>
</select>
</div>
<div>
<label className="text-sm font-bold text-zinc-700 mb-2 block">Occupation</label>
<select
value={filterOccupation}
onChange={(e) => setFilterOccupation(e.target.value)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 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-zinc-700 mb-2 block">Media Type</label>
<select
value={filterMediaType}
onChange={(e) => setFilterMediaType(e.target.value)}
className="w-full bg-white border-zinc-200 rounded-lg px-3 py-2 text-sm font-bold text-zinc-700 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-zinc-900">
<X size={12} />
</button>
</Badge>
)}
{filterOccupation && (
<Badge variant="secondary" className="gap-1">
Occupation: {filterOccupation}
<button onClick={() => setFilterOccupation('')} className="hover:text-zinc-900">
<X size={12} />
</button>
</Badge>
)}
{filterMediaType && (
<Badge variant="secondary" className="gap-1">
Media Type: {filterMediaType}
<button onClick={() => setFilterMediaType('')} className="hover:text-zinc-900">
<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-zinc-900">
<X size={12} />
</button>
</Badge>
)}
</div>
</motion.div>
)}
{loading ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#6d28d9] mb-4" />
<p className="text-lg font-bold">Loading cast...</p>
</div>
) : filteredStaff.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<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-white rounded-2xl p-4 shadow-sm border border-zinc-100 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-zinc-100 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">
<h3 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{person.name}
</h3>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider">
{person.role}
</p>
</div>
</div>
{person.filmography && person.filmography.length > 0 && (
<div className="bg-zinc-50 rounded-xl p-3 flex items-center gap-3">
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-white">
<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-zinc-400 uppercase tracking-widest leading-none mb-1">Latest Role</p>
<p className="text-xs font-bold text-zinc-700 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-zinc-100 pt-8">
<div className="flex items-center gap-4">
<span className="text-sm text-zinc-500 font-medium">Items per page:</span>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
}}
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 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-zinc-200"
>
<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-zinc-400 font-medium">of</span>
<span className="text-sm font-bold text-zinc-700">{totalPages || 1}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-zinc-200"
>
Next
<ChevronRight size={16} />
</Button>
</div>
</div>
)}
</div>
);
}