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

View File

@@ -5,6 +5,7 @@
import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react';
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
import Header from './components/Header';
import BrowseView from './components/BrowseView';
import DetailView from './components/DetailView';
@@ -14,19 +15,23 @@ import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types';
import { fetchAllMedia, fetchMediaById } from './api';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff } from './api';
export default function App() {
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail' | 'add' | 'import'>('browse');
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
function AppContent() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
(searchParams.get('category') as MediaCategory) || 'Anime'
);
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load media from API on component mount
// Load media from API on component mount (only when not on cast routes)
const [apiMedia, setApiMedia] = useState<Media[]>([]);
useEffect(() => {
@@ -38,8 +43,12 @@ export default function App() {
console.error('Failed to load media from API:', error);
}
};
loadMediaFromApi();
}, []);
// Only load media if not on cast routes
if (!location.pathname.startsWith('/cast')) {
loadMediaFromApi();
}
}, [location.pathname]);
const toggleCategory = (category: MediaCategory) => {
setEnabledCategories(prev => {
@@ -59,17 +68,18 @@ export default function App() {
const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category);
setCurrentView('browse');
setSearchParams({ category });
navigate('/');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleAddMediaView = () => {
setCurrentView('add');
navigate('/add');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleImporterView = () => {
setCurrentView('import');
navigate('/import');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -168,17 +178,17 @@ export default function App() {
// For non-adult media, use the original media
setSelectedMedia(media);
}
setCurrentView('detail');
navigate(`/media/${media.id}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => {
setCurrentView('browse');
navigate('/');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCastClick = () => {
setCurrentView('cast');
navigate('/cast');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -192,76 +202,73 @@ export default function App() {
occupations: ['Voice Actor', 'Singer', 'Narrator']
};
setSelectedPerson(enrichedPerson);
setCurrentView('castDetail');
navigate(`/cast/${person.id}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleSearch = (query: string) => {
setSearchQuery(query);
if (currentView !== 'browse' && currentView !== 'cast') {
setCurrentView('browse');
const params = new URLSearchParams(searchParams);
if (query) {
params.set('search', query);
} else {
params.delete('search');
}
setSearchParams(params);
navigate('/');
};
return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
<Header
onBrowse={handleBack}
onCast={handleCastClick}
onAddMedia={handleAddMediaView}
onImporter={handleImporterView}
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
transparent={currentView === 'detail' || currentView === 'castDetail'}
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
/>
<main>
<LayoutGroup>
{currentView === 'browse' ? (
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
/>
) : currentView === 'cast' ? (
<CastView
staffList={allStaff}
onPersonClick={handlePersonClick}
/>
) : currentView === 'castDetail' ? (
selectedPerson && (
<CastDetailView
person={selectedPerson}
onBack={handleCastClick}
onMediaClick={(id) => {
const media = allMedia.find(m => m.id === id);
if (media) handleMediaClick(media);
}}
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
<Routes>
<Route path="/" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
/>
)
) : currentView === 'add' ? (
<AddMediaView
activeCategory={activeCategory}
onBack={handleBack}
onAddComplete={handleAddMedia}
/>
) : currentView === 'import' ? (
<ImporterView
onBack={handleBack}
/>
) : (
selectedMedia && (
<DetailView
media={selectedMedia}
onBack={handleBack}
} />
<Route path="/media/:id" element={
<MediaDetailRoute
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
allMedia={allMedia}
onPersonClick={handlePersonClick}
/>
)
)}
} />
<Route path="/cast" element={
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
/>
} />
<Route path="/cast/:id" element={
<CastDetailRoute
selectedPerson={selectedPerson}
setSelectedPerson={setSelectedPerson}
/>
} />
<Route path="/add" element={
<AddMediaView
activeCategory={activeCategory}
onAddComplete={handleAddMedia}
/>
} />
<Route path="/import" element={
<ImporterView />
} />
</Routes>
</LayoutGroup>
</main>
@@ -285,3 +292,87 @@ export default function App() {
</div>
);
}
// Helper component for media detail route
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadMedia = async () => {
if (id) {
// First check if media is in allMedia
const media = allMedia.find(m => m.id === id);
if (media) {
setSelectedMedia(media);
} else {
// If not found, fetch from API
try {
const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) {
setSelectedMedia(fetchedMedia);
} else {
navigate('/');
}
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/');
}
}
}
};
loadMedia();
}, [id, allMedia]);
if (!selectedMedia) return null;
return (
<DetailView
media={selectedMedia}
onPersonClick={onPersonClick}
/>
);
}
// Helper component for cast detail route
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadCast = async () => {
if (id) {
try {
const castData = await fetchCastById(id);
if (castData) {
const person = convertApiCastToStaff(castData);
setSelectedPerson(person);
} else {
navigate('/cast');
}
} catch (error) {
console.error('Failed to load cast:', error);
navigate('/cast');
}
}
};
loadCast();
}, [id]);
if (!selectedPerson) return null;
return (
<CastDetailView
person={selectedPerson}
relatedMedia={[]}
/>
);
}
export default function App() {
return (
<BrowserRouter>
<AppContent />
</BrowserRouter>
);
}