/** * @license * SPDX-License-Identifier: Apache-2.0 */ import { useState, useMemo, useEffect } from 'react'; import { LayoutGroup } from 'motion/react'; import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom'; import AppSidebar from './components/sidebar/AppSidebar'; import { SidebarProvider } from '@/components/ui/sidebar'; import BrowseView from './components/BrowseView'; import DashboardView from './components/DashboardView'; import DetailView from './components/DetailView'; import CastView from './components/CastView'; import CastDetailView from './components/CastDetailView'; import AddMediaView from './components/AddMediaView'; import ImporterView from './components/ImporterView'; import SettingsView from './components/SettingsView'; import Loading from './components/ui/loading'; import MediaDetailRoute from './components/routes/MediaDetailRoute'; import CastDetailRoute from './components/routes/CastDetailRoute'; import CategoryBrowseRoute from './components/routes/CategoryBrowseRoute'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; import { Media, Staff, MediaCategory, UserSettings } from './types'; import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api'; import { ThemeProvider, useTheme } from './contexts/ThemeContext'; import { Search, Plus, LayoutGrid, List, Filter } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { CATEGORY_PATHS, PATH_TO_CATEGORY, DEFAULT_ENABLED_CATEGORIES, DEFAULT_SETTINGS } from './constants'; import { useAppStore } from './store/appStore'; function AppContent() { const navigate = useNavigate(); const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { setTheme } = useTheme(); // Zustand store const { apiMedia, customMedia, adultMedia, mediaLoading, selectedMedia, selectedPerson, activeCategory, enabledCategories, searchQuery, settings, setApiMedia, setCustomMedia, setAdultMedia, setMediaLoading, setSelectedMedia, setSelectedPerson, setActiveCategory, setEnabledCategories, setSearchQuery, setSettings, } = useAppStore(); // Set category from URL path on mount or location change useEffect(() => { const pathParts = location.pathname.split('/').filter(Boolean); if (pathParts.length === 1 && PATH_TO_CATEGORY[pathParts[0]]) { const category = PATH_TO_CATEGORY[pathParts[0]]; if (enabledCategories.includes(category)) { setActiveCategory(category); } } }, [location.pathname, enabledCategories, setActiveCategory]); useEffect(() => { const loadSettingsFromApi = async () => { try { const loadedSettings = await fetchSettings(); if (loadedSettings) { setSettings(loadedSettings); setEnabledCategories(loadedSettings.enabledCategories); // Sync theme with theme context setTheme(loadedSettings.theme); // Set custom page title if (loadedSettings.pageTitle) { document.title = loadedSettings.pageTitle; } // Set custom favicon if (loadedSettings.favicon) { let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement; if (!faviconLink) { faviconLink = document.createElement('link'); faviconLink.rel = 'icon'; document.head.appendChild(faviconLink); } faviconLink.href = loadedSettings.favicon; } } } catch (error) { console.error('Failed to load settings from API:', error); } }; loadSettingsFromApi(); }, [setTheme]); // Apply custom colors when settings change useEffect(() => { if (settings?.customColors) { const root = document.documentElement; const colors = settings.customColors; if (colors.primary) root.style.setProperty('--color-primary', colors.primary); if (colors.secondary) root.style.setProperty('--color-secondary', colors.secondary); if (colors.background) root.style.setProperty('--color-background', colors.background); if (colors.surface) root.style.setProperty('--color-surface', colors.surface); if (colors.text) root.style.setProperty('--color-text', colors.text); if (colors.muted) root.style.setProperty('--color-muted', colors.muted); if (colors.border) root.style.setProperty('--color-border', colors.border); } }, [settings?.customColors]); const reloadSettings = async () => { try { const loadedSettings = await fetchSettings(); if (loadedSettings) { setSettings(loadedSettings); setEnabledCategories(loadedSettings.enabledCategories); // Sync theme with theme context setTheme(loadedSettings.theme); // Set custom page title if (loadedSettings.pageTitle) { document.title = loadedSettings.pageTitle; } // Set custom favicon if (loadedSettings.favicon) { let faviconLink = document.querySelector("link[rel~='icon']") as HTMLLinkElement; if (!faviconLink) { faviconLink = document.createElement('link'); faviconLink.rel = 'icon'; document.head.appendChild(faviconLink); } faviconLink.href = loadedSettings.favicon; } } } catch (error) { console.error('Failed to reload settings from API:', error); } }; useEffect(() => { const loadMediaFromApi = async () => { setMediaLoading(true); try { const media = await fetchAllMedia(); setApiMedia(media); } catch (error) { console.error('Failed to load media from API:', error); } finally { setMediaLoading(false); } }; // Only load media if not on cast routes if (!location.pathname.startsWith('/cast')) { loadMediaFromApi(); } }, [location.pathname]); const toggleCategory = async (category: MediaCategory) => { const isEnabling = !enabledCategories.includes(category); const newList = isEnabling ? [...enabledCategories, category] : enabledCategories.filter(c => c !== category); // If we disable the current active category, switch to another enabled one if (!isEnabling && activeCategory === category) { const nextCategory = newList.find(c => c !== category) || 'Anime'; setActiveCategory(nextCategory as MediaCategory); } setEnabledCategories(newList); // Save to API const baseSettings = settings || DEFAULT_SETTINGS; const updatedSettings: UserSettings = { ...baseSettings, enabledCategories: newList, }; updateSettings(updatedSettings).then(saved => { if (saved) { setSettings(saved); } }); }; const handleCategoryChange = (category: MediaCategory) => { setActiveCategory(category); navigate(`/${CATEGORY_PATHS[category]}`); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleAddMediaView = () => { navigate('/add'); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleImporterView = () => { navigate('/import'); window.scrollTo({ top: 0, behavior: 'smooth' }); }; // All media from enabled categories (for cross-category search) const allEnabledMedia = useMemo(() => { // Use API data if available, otherwise fall back to mock data let list: Media[] = []; if (apiMedia.length > 0) { // API has data, use it list = [...apiMedia]; } else { // API is empty, use mock data as fallback list = [...MOCK_MEDIA]; } // Add custom media and detail media list = [...list, ...customMedia]; if (!list.find(m => m.id === DETAIL_MEDIA.id)) { list.push(DETAIL_MEDIA); } // Filter by enabled categories only (all enabled categories, not just active) return list.filter(m => enabledCategories.includes(m.category)); }, [enabledCategories, customMedia, apiMedia]); const allMedia = useMemo(() => { // Filter by active category AND ensure it's enabled return allEnabledMedia.filter(m => m.category === activeCategory); }, [activeCategory, allEnabledMedia]); const handleAddMedia = async () => { // Reload all media from API to get the newly added item try { const media = await fetchAllMedia(); setApiMedia(media); } catch (error) { console.error('Failed to reload media from API:', error); } }; const handleGridItemSizeChange = async (size: number) => { const baseSettings = settings || { ...DEFAULT_SETTINGS, enabledCategories }; const updatedSettings: UserSettings = { ...baseSettings, gridItemSize: size, }; updateSettings(updatedSettings).then(saved => { if (saved) { setSettings(saved); } }); }; const allStaff = useMemo(() => { const staff: Staff[] = []; const staffIds = new Set(); // Track unique staff to avoid duplicates // Use allEnabledMedia which already has enabled categories filtered allEnabledMedia.forEach(media => { media.staff?.forEach(s => { // Avoid duplicate staff entries if (!staffIds.has(s.id)) { staffIds.add(s.id); staff.push({ ...s, mediaId: media.id, mediaTitle: media.title }); } }); }); return staff; }, [allEnabledMedia]); // Search across all enabled media (all categories) const searchResultsMedia = useMemo(() => { if (!searchQuery.trim()) return []; const query = searchQuery.toLowerCase(); return allEnabledMedia.filter(media => media.title.toLowerCase().includes(query) || media.year.toLowerCase().includes(query) || media.genres?.some(g => g.toLowerCase().includes(query)) || media.studios?.some(s => s.toLowerCase().includes(query)) || media.description?.toLowerCase().includes(query) || media.tags?.some(t => t.toLowerCase().includes(query)) || media.developers?.some(d => d.toLowerCase().includes(query)) || media.platforms?.some(p => p.toLowerCase().includes(query)) ); }, [allEnabledMedia, searchQuery]); // Search cast members const searchResultsCast = useMemo(() => { if (!searchQuery.trim()) return []; const query = searchQuery.toLowerCase(); return allStaff.filter(staff => staff.name.toLowerCase().includes(query) || staff.role.toLowerCase().includes(query) || staff.bio?.toLowerCase().includes(query) || staff.occupations?.some(o => o.toLowerCase().includes(query)) || staff.characterName?.toLowerCase().includes(query) ); }, [allStaff, searchQuery]); // Legacy filteredMedia for backward compatibility (searches within current category) const filteredMedia = useMemo(() => { if (!searchQuery.trim()) return allMedia; const query = searchQuery.toLowerCase(); return allMedia.filter(media => media.title.toLowerCase().includes(query) || media.year.toLowerCase().includes(query) || media.genres?.some(g => g.toLowerCase().includes(query)) || media.studios?.some(s => s.toLowerCase().includes(query)) ); }, [allMedia, searchQuery]); const handleMediaClick = async (media: Media) => { // For adult media, try to fetch detailed data by ID if (media.category === 'Adult') { try { const detailedMedia = await fetchMediaById(parseInt(media.id)); if (detailedMedia) { setSelectedMedia(detailedMedia); } else { // Fallback to original media if detailed fetch fails setSelectedMedia(media); } } catch (error) { console.error('Failed to fetch detailed media:', error); setSelectedMedia(media); } } else { // For non-adult media, use the original media setSelectedMedia(media); } navigate(`/media/${media.id}`); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleBack = () => { navigate('/'); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleCastClick = () => { navigate('/cast'); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handlePersonClick = (person: Staff) => { // Enrich person with some mock data for the detail page const enrichedPerson: Staff = { ...person, bio: `${person.name} is a renowned ${person.role} with a career spanning over a decade. Known for their versatility and emotional depth, they have become a staple in the industry, particularly for their work in ${person.mediaTitle || 'major productions'}.`, birthDate: 'October 14, 1985', birthPlace: 'Tokyo, Japan', occupations: ['Voice Actor', 'Singer', 'Narrator'] }; setSelectedPerson(enrichedPerson); navigate(`/cast/${person.id}`); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleSearch = (query: string) => { setSearchQuery(query); const params = new URLSearchParams(searchParams); if (query) { params.set('search', query); } else { params.delete('search'); } setSearchParams(params); navigate('/browse'); }; // Calculate media counts for sidebar (all categories) const mediaCounts = useMemo(() => { const counts: Record = {}; // Count all enabled categories using allEnabledMedia enabledCategories.forEach(cat => { counts[cat] = allEnabledMedia.filter(m => m.category === cat).length; }); // Add favorites count counts['favorites'] = allEnabledMedia.filter(m => m.rating && m.rating >= 8).length; // Add total count counts['all'] = allEnabledMedia.length; return counts; }, [allEnabledMedia, enabledCategories]); // Calculate active filter based on current URL const activeFilter = useMemo(() => { const path = location.pathname; // Map routes to filter IDs const routeMap: Record = { '/anime': 'anime', '/movies': 'movies', '/tv-series': 'tv-series', '/music': 'music', '/books': 'books', '/adult': 'adult', '/consoles': 'consoles', '/games': 'games', }; if (routeMap[path]) return routeMap[path]; if (searchParams.get('favorites') === 'true') return 'favorites'; return undefined; }, [location.pathname, searchParams]); return (
{/* Header with Search and Add Media */}
{/* Search Bar */}
handleSearch(e.target.value)} className="w-full pl-10 pr-4 py-2 bg-[#1a1d26] border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:border-[#e8466c]/50 focus:ring-[#e8466c]/20" />
{/* View Toggle and Add Button */}
0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))} onMediaClick={handleMediaClick} loading={mediaLoading} /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Footer */}
{mediaCounts.all} total {mediaCounts.movies} Movies {mediaCounts.series} Series {mediaCounts.games} Games {mediaCounts.adult} Adult {mediaCounts.favorites} Favorites

© 2026 MediaVault v1.0.0

); } export default function App() { return ( ); }