Add routing, cast API conversion, and filters

Introduce client-side routing and cast API support. Key changes:

- Add react-router-dom dependency and wire BrowserRouter in App.
- Convert App to route-based structure (/, /media/:id, /cast, /cast/:id, /add, /import) with MediaDetailRoute and CastDetailRoute helpers.
- Extend API types for cast items and add convertApiCastToStaff; fetchAllCast now returns Staff[] (mapped via converter).
- Update components to use react-router hooks (useNavigate, useParams, useLocation, Link/NavLink): Header links, DetailView, CastDetailView, AddMediaView, ImporterView and others now navigate via routes.
- Enhance CastView: fetch cast list, loading state, persistent search/sort/filter controls, filtering by occupations/media types and enabled categories, improved pagination and UI controls.
- Update stashapp importer: add configurable blacklist check to skip scenes, increase per_page for queries.

These changes consolidate navigation, improve cast data handling from the API, and add richer filtering and importer controls.
This commit is contained in:
Lars Behrends
2026-04-10 13:46:52 +02:00
parent a610ce304e
commit f5c3e96823
12 changed files with 830 additions and 196 deletions
+187 -68
View File
@@ -1,17 +1,21 @@
import { Staff, Media } from '@/types';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User } from 'lucide-react';
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface CastDetailViewProps {
person: Staff;
onBack: () => void;
onMediaClick: (mediaId: string) => void;
relatedMedia: Media[];
}
export default function CastDetailView({ person, onBack, onMediaClick, relatedMedia }: CastDetailViewProps) {
export default function CastDetailView({ person, relatedMedia }: CastDetailViewProps) {
const navigate = useNavigate();
const handleMediaClick = (mediaId: string) => {
navigate(`/media/${mediaId}`);
};
return (
<div className="min-h-screen bg-white pb-20">
{/* Hero Section */}
@@ -63,7 +67,7 @@ export default function CastDetailView({ person, onBack, onMediaClick, relatedMe
<Button
variant="ghost"
size="icon"
onClick={onBack}
onClick={() => navigate(-1)}
className="absolute top-24 left-6 bg-white/20 hover:bg-white/40 text-white rounded-full backdrop-blur-md"
>
<ArrowLeft size={24} />
@@ -107,96 +111,211 @@ export default function CastDetailView({ person, onBack, onMediaClick, relatedMe
<p className="font-bold text-zinc-700">{person.role}</p>
</div>
</div>
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<User size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Ethnicity</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
</div>
</div>
)}
</div>
</div>
<div className="bg-zinc-50 rounded-3xl p-8 space-y-6">
<h3 className="text-xl font-black text-zinc-900">Measurements</h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Height</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.height || person.height} cm</p>
</div>
</div>
{(person.weight || person.adult_specifics?.weight) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Weight</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.weight || person.weight} kg</p>
</div>
</div>
)}
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Ruler size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Measurements</p>
<p className="font-bold text-zinc-700">
{person.adult_specifics?.measurements || (
<>
{person.bust_size && `${person.bust_size}`}
{person.cup_size && person.cup_size}
{person.bust_size || person.cup_size ? '-' : ''}
{person.waist_size && `${person.waist_size}`}
{person.waist_size ? '-' : ''}
{person.hip_size && `${person.hip_size}`}
</>
)}
</p>
</div>
</div>
)}
{(person.hair_color || person.adult_specifics?.hair_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Hair Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.hair_color || person.hair_color}</p>
</div>
</div>
)}
{(person.eye_color || person.adult_specifics?.eye_color) && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Eye size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Eye Color</p>
<p className="font-bold text-zinc-700">{person.adult_specifics?.eye_color || person.eye_color}</p>
</div>
</div>
)}
{person.adult_specifics?.tattoos && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Tattoos</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.tattoos}</p>
</div>
</div>
)}
{person.adult_specifics?.piercings && (
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-[#6d28d9] shadow-sm">
<Palette size={20} />
</div>
<div>
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Piercings</p>
<p className="font-bold text-zinc-700">{person.adult_specifics.piercings}</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Main Bio & Roles */}
<div className="lg:col-span-2 space-y-12">
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
Biography
</h2>
<p className="text-zinc-600 leading-relaxed text-lg">
{person.bio || `${person.name} is a talented ${person.role} known for their work in various media productions. They have brought numerous characters to life with their unique performances.`}
</p>
</section>
{person.bio && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
Biography
</h2>
<p className="text-zinc-600 leading-relaxed text-lg">
{person.bio}
</p>
</section>
)}
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<User className="text-[#6d28d9]" />
Characters
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{relatedMedia.map(media => {
const character = media.staff?.find(s => s.id === person.id);
if (!character) return null;
return (
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<User className="text-[#6d28d9]" />
Characters
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{person.filmography.map(item => (
<div
key={`${media.id}-char`}
key={`${item.id}-char`}
className="flex items-center gap-4 p-4 rounded-2xl bg-zinc-50 border border-zinc-100"
>
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-white">
<img
src={character.characterImage}
alt={character.characterName}
src={item.poster || person.photo}
alt={item.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-1">Character</p>
<h4 className="font-black text-zinc-900 truncate">{character.characterName}</h4>
<h4 className="font-black text-zinc-900 truncate">{item.characterName || item.role}</h4>
<button
onClick={() => onMediaClick(media.id)}
onClick={() => handleMediaClick(item.id.toString())}
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left"
>
in {media.title}
in {item.title}
</button>
</div>
</div>
);
})}
</div>
</section>
))}
</div>
</section>
)}
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{relatedMedia.map(media => (
<div
key={media.id}
onClick={() => onMediaClick(media.id)}
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
<img
src={media.poster}
alt={media.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{media.title}
</h4>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
{media.year}
</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
{person.role}
</Badge>
{person.filmography && person.filmography.length > 0 && (
<section>
<h2 className="text-2xl font-black text-zinc-900 mb-6 flex items-center gap-3">
<Film className="text-[#6d28d9]" />
Filmography
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{person.filmography.map(item => (
<div
key={item.id}
onClick={() => handleMediaClick(item.id.toString())}
className="group flex items-center gap-4 p-4 rounded-2xl bg-white border border-zinc-100 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 shadow-sm">
<img
src={item.poster || person.photo}
alt={item.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
<div className="min-w-0">
<h4 className="font-black text-zinc-900 truncate group-hover:text-[#6d28d9] transition-colors">
{item.title}
</h4>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-1">
{item.year || 'Unknown'}
</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-zinc-200">
{item.role}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
</section>
))}
</div>
</section>
)}
</div>
</div>
</div>