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:
Lars Behrends
2026-04-11 00:47:04 +02:00
parent b36b72b8e0
commit 52d272c701
2 changed files with 65 additions and 14 deletions

View File

@@ -1,9 +1,10 @@
import { Staff, Media } from '@/types';
import { useNavigate } from 'react-router-dom';
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 { Badge } from '@/components/ui/badge';
import { useState } from 'react';
interface CastDetailViewProps {
person: Staff;
@@ -12,10 +13,24 @@ interface CastDetailViewProps {
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;
});
return (
<div className="min-h-screen bg-background pb-20">
{/* Hero Section */}
@@ -33,7 +48,7 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
<motion.div
initial={{ opacity: 0, y: 20 }}
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
src={person.photo}
@@ -58,6 +73,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{occ}
</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>
</motion.div>
</div>
@@ -279,12 +299,33 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-black text-foreground flex items-center gap-3">
<Film className="text-[#6d28d9]" />
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">
{person.filmography.map(item => (
{sortedFilmography.map(item => (
<div
key={item.id}
onClick={() => handleMediaClick(item.id.toString())}

View File

@@ -22,11 +22,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
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 [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') || 'asc';
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
});
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
return localStorage.getItem('castFilterOccupation') || '';
@@ -68,13 +68,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
const handleResetFilters = () => {
setSearchQuery('');
setSortBy('name');
setSortOrder('asc');
setSortBy('roleCount');
setSortOrder('desc');
setFilterOccupation('');
setFilterMediaType('');
};
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'name' || sortOrder !== 'asc';
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
useEffect(() => {
const loadCast = async () => {
@@ -137,6 +137,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
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;
@@ -247,6 +251,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
<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>
@@ -345,7 +350,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
referrerPolicy="no-referrer"
/>
</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">
{person.name}
</h3>
@@ -353,6 +358,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
{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 && (