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.
426 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|