Files
mystuff_frontend/src/App.tsx
T
Lars Behrends 073c8a6c5d Integrate shadcn UI & add UI primitives
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.
2026-04-26 02:18:01 +02:00

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>
);
}