073c8a6c5d
Integrates the shadcn/ui design system across the app and adds a collection of reusable UI primitives and layout components. Adds new UI atoms/molecules (avatar, card, collapsible, progress, select, sheet, sidebar, skeleton, table, tabs, toggles, tooltip), app sidebar, media filters, MediaTable, and a mobile hook; updates many views/components to use the new UI. Updates AGENTS.md with styling, layout, accessibility and design standards (Tailwind/shadcn guidance) and adds a registry entry to components.json. Also updates dependencies/lockfile to align shadcn and related packages.
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
/**
|
|
* @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<string>(); // 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<string, number> = {};
|
|
// 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<string, string> = {
|
|
'/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 (
|
|
<div className="min-h-screen bg-[#0a0c10] font-sans selection:bg-[#e8466c]/20 selection:text-[#e8466c] flex">
|
|
<SidebarProvider defaultOpen={true}>
|
|
<AppSidebar
|
|
enabledCategories={enabledCategories}
|
|
onToggleCategory={toggleCategory}
|
|
pageTitle={settings?.pageTitle || 'MediaVault'}
|
|
mediaCounts={mediaCounts}
|
|
activeFilter={activeFilter}
|
|
/>
|
|
|
|
<main className="flex-1 flex flex-col relative">
|
|
{/* Header with Search and Add Media */}
|
|
<header className="sticky top-0 z-30 bg-[#0a0c10]/80 backdrop-blur-xl border-b border-white/5 px-6 py-4">
|
|
<div className="flex items-center justify-between gap-4 max-w-[1920px] mx-auto">
|
|
{/* Search Bar */}
|
|
<div className="flex-1 max-w-xl">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search library..."
|
|
value={searchQuery}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* View Toggle and Add Button */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center bg-[#1a1d26] rounded-lg p-1 border border-white/10">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 rounded bg-white/10 text-white"
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 rounded text-gray-400 hover:text-white hover:bg-white/5"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleAddMediaView}
|
|
className="bg-[#e8466c] hover:bg-[#d13d60] text-white font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Media
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<LayoutGroup>
|
|
<Routes>
|
|
<Route path="/" element={
|
|
<DashboardView
|
|
mediaList={apiMedia.length > 0 ? apiMedia : [...MOCK_MEDIA, ...customMedia, DETAIL_MEDIA].filter(m => enabledCategories.includes(m.category))}
|
|
onMediaClick={handleMediaClick}
|
|
loading={mediaLoading}
|
|
/>
|
|
} />
|
|
<Route path="/browse" element={
|
|
<BrowseView
|
|
mediaList={searchQuery.trim() ? searchResultsMedia : allMedia}
|
|
onMediaClick={handleMediaClick}
|
|
activeCategory={activeCategory}
|
|
itemsPerPage={settings?.itemsPerPage}
|
|
gridItemSize={settings?.gridItemSize}
|
|
onGridItemSizeChange={handleGridItemSizeChange}
|
|
loading={mediaLoading}
|
|
searchResultsCast={searchQuery.trim() ? searchResultsCast : []}
|
|
onCastClick={handlePersonClick}
|
|
searchQuery={searchQuery}
|
|
/>
|
|
} />
|
|
<Route path="/:category" element={
|
|
<CategoryBrowseRoute
|
|
mediaList={filteredMedia}
|
|
onMediaClick={handleMediaClick}
|
|
itemsPerPage={settings?.itemsPerPage}
|
|
gridItemSize={settings?.gridItemSize}
|
|
onGridItemSizeChange={handleGridItemSizeChange}
|
|
loading={mediaLoading}
|
|
/>
|
|
} />
|
|
<Route path="/media/:id" element={
|
|
<MediaDetailRoute
|
|
allMedia={allMedia}
|
|
onPersonClick={handlePersonClick}
|
|
/>
|
|
} />
|
|
<Route path="/cast" element={
|
|
<CastView
|
|
onPersonClick={handlePersonClick}
|
|
enabledCategories={enabledCategories}
|
|
itemsPerPage={settings?.itemsPerPage}
|
|
/>
|
|
} />
|
|
<Route path="/cast/:id" element={
|
|
<CastDetailRoute />
|
|
} />
|
|
<Route path="/add" element={
|
|
<AddMediaView
|
|
activeCategory={activeCategory}
|
|
enabledCategories={enabledCategories}
|
|
onAddComplete={handleAddMedia}
|
|
/>
|
|
} />
|
|
<Route path="/import" element={
|
|
<ImporterView />
|
|
} />
|
|
<Route path="/settings" element={
|
|
<SettingsView onSettingsSaved={reloadSettings} />
|
|
} />
|
|
</Routes>
|
|
</LayoutGroup>
|
|
|
|
{/* Footer */}
|
|
<footer className="py-6 px-6 border-t border-white/5 bg-[#0a0c10] mt-auto">
|
|
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-500">
|
|
<span>{mediaCounts.all} total</span>
|
|
<span className="text-gray-700">•</span>
|
|
<span className="text-blue-400">{mediaCounts.movies} Movies</span>
|
|
<span className="text-green-400">{mediaCounts.series} Series</span>
|
|
<span className="text-purple-400">{mediaCounts.games} Games</span>
|
|
<span className="text-red-400">{mediaCounts.adult} Adult</span>
|
|
<span className="text-gray-700">•</span>
|
|
<span className="text-[#e8466c]">{mediaCounts.favorites} Favorites</span>
|
|
</div>
|
|
<p className="text-xs text-gray-600">
|
|
© 2026 MediaVault v1.0.0
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
</main>
|
|
</SidebarProvider>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<ThemeProvider>
|
|
<AppContent />
|
|
</ThemeProvider>
|
|
</BrowserRouter>
|
|
);
|
|
}
|