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:
213
src/App.tsx
213
src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user