Add filmography sorting and role-count UI
Introduce filmography sorting controls and role-count indicators across cast list and detail views. In CastDetailView: add sort state (sortBy/sortOrder), compute sortedFilmography, add UI for choosing sort key and toggling order, and show a filmography count badge; import new icons and useState. In CastView: add a new 'roleCount' sort option (default sort is now roleCount desc), persist/restore it from localStorage, adjust reset/default filter logic and hasActiveFilters check, render role-count badges for each person, and tweak layout (flex-1) for better truncation. These changes make it easier to surface prolific cast members and sort filmography entries by year, title, or role.
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import { Staff, Media } from '@/types';
|
import { Staff, Media } from '@/types';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye } from 'lucide-react';
|
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface CastDetailViewProps {
|
interface CastDetailViewProps {
|
||||||
person: Staff;
|
person: Staff;
|
||||||
@@ -12,10 +13,24 @@ interface CastDetailViewProps {
|
|||||||
|
|
||||||
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
|
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [sortBy, setSortBy] = useState<'year' | 'title' | 'role'>('role');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
const handleMediaClick = (mediaId: string) => {
|
const handleMediaClick = (mediaId: string) => {
|
||||||
navigate(`/media/${mediaId}`);
|
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;
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background pb-20">
|
<div className="min-h-screen bg-background pb-20">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -33,7 +48,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
className="h-48 md:h-64 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={person.photo}
|
src={person.photo}
|
||||||
@@ -58,6 +73,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
{occ}
|
{occ}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
|
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1">
|
||||||
|
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,12 +299,33 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
|
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Film className="text-[#6d28d9]" />
|
<h2 className="text-2xl font-black text-foreground flex items-center gap-3">
|
||||||
Filmography
|
<Film className="text-[#6d28d9]" />
|
||||||
</h2>
|
Filmography
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="rounded-full border-border"
|
||||||
|
>
|
||||||
|
<ListFilter size={16} />
|
||||||
|
</Button>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
|
||||||
|
className="bg-muted border border-border rounded-full px-3 py-1.5 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]"
|
||||||
|
>
|
||||||
|
<option value="year">Year</option>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="role">Role</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{person.filmography.map(item => (
|
{sortedFilmography.map(item => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => handleMediaClick(item.id.toString())}
|
onClick={() => handleMediaClick(item.id.toString())}
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
const [searchQuery, setSearchQuery] = useState(() => {
|
const [searchQuery, setSearchQuery] = useState(() => {
|
||||||
return localStorage.getItem('castSearchQuery') || '';
|
return localStorage.getItem('castSearchQuery') || '';
|
||||||
});
|
});
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height'>(() => {
|
const [sortBy, setSortBy] = useState<'name' | 'role' | 'birthDate' | 'height' | 'roleCount'>(() => {
|
||||||
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height') || 'name';
|
return (localStorage.getItem('castSortBy') as 'name' | 'role' | 'birthDate' | 'height' | 'roleCount') || 'roleCount';
|
||||||
});
|
});
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => {
|
||||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'asc';
|
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||||
});
|
});
|
||||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||||
return localStorage.getItem('castFilterOccupation') || '';
|
return localStorage.getItem('castFilterOccupation') || '';
|
||||||
@@ -68,13 +68,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSortBy('name');
|
setSortBy('roleCount');
|
||||||
setSortOrder('asc');
|
setSortOrder('desc');
|
||||||
setFilterOccupation('');
|
setFilterOccupation('');
|
||||||
setFilterMediaType('');
|
setFilterMediaType('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc';
|
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCast = async () => {
|
const loadCast = async () => {
|
||||||
@@ -137,6 +137,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
const heightA = a.height || 0;
|
const heightA = a.height || 0;
|
||||||
const heightB = b.height || 0;
|
const heightB = b.height || 0;
|
||||||
comparison = heightA - heightB;
|
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 sortOrder === 'desc' ? -comparison : comparison;
|
||||||
@@ -247,6 +251,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
<option value="role">Role</option>
|
<option value="role">Role</option>
|
||||||
<option value="birthDate">Birth Date</option>
|
<option value="birthDate">Birth Date</option>
|
||||||
<option value="height">Height</option>
|
<option value="height">Height</option>
|
||||||
|
<option value="roleCount">Role Count</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -345,7 +350,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors">
|
||||||
{person.name}
|
{person.name}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -353,6 +358,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
{person.role}
|
{person.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
{person.filmography && person.filmography.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user