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.
This commit is contained in:
+165
-49
@@ -6,7 +6,8 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { LayoutGroup } from 'motion/react';
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
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';
|
||||
@@ -23,6 +24,9 @@ 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';
|
||||
|
||||
@@ -210,7 +214,8 @@ function AppContent() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const allMedia = useMemo(() => {
|
||||
// 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[] = [];
|
||||
|
||||
@@ -228,9 +233,14 @@ function AppContent() {
|
||||
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 list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
||||
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
||||
return allEnabledMedia.filter(m => m.category === activeCategory);
|
||||
}, [activeCategory, allEnabledMedia]);
|
||||
|
||||
const handleAddMedia = async () => {
|
||||
// Reload all media from API to get the newly added item
|
||||
@@ -257,37 +267,55 @@ function AppContent() {
|
||||
|
||||
const allStaff = useMemo(() => {
|
||||
const staff: Staff[] = [];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let baseList: Media[] = [];
|
||||
const staffIds = new Set<string>(); // Track unique staff to avoid duplicates
|
||||
|
||||
if (apiMedia.length > 0) {
|
||||
// API has data, use it
|
||||
baseList = [...apiMedia];
|
||||
} else {
|
||||
// API is empty, use mock data as fallback
|
||||
baseList = [...MOCK_MEDIA];
|
||||
}
|
||||
|
||||
// Add custom media and detail media
|
||||
baseList = [...baseList, ...customMedia];
|
||||
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
baseList.push(DETAIL_MEDIA);
|
||||
}
|
||||
|
||||
const enabledMedia = baseList.filter(m => enabledCategories.includes(m.category));
|
||||
|
||||
enabledMedia.forEach(media => {
|
||||
// Use allEnabledMedia which already has enabled categories filtered
|
||||
allEnabledMedia.forEach(media => {
|
||||
media.staff?.forEach(s => {
|
||||
staff.push({
|
||||
...s,
|
||||
mediaId: media.id,
|
||||
mediaTitle: media.title
|
||||
});
|
||||
// Avoid duplicate staff entries
|
||||
if (!staffIds.has(s.id)) {
|
||||
staffIds.add(s.id);
|
||||
staff.push({
|
||||
...s,
|
||||
mediaId: media.id,
|
||||
mediaTitle: media.title
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return staff;
|
||||
}, [enabledCategories, customMedia, apiMedia]);
|
||||
}, [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();
|
||||
@@ -358,15 +386,98 @@ function AppContent() {
|
||||
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-background font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9] flex">
|
||||
<Sidebar
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
pageTitle={settings?.pageTitle}
|
||||
/>
|
||||
|
||||
<main className="flex-1 lg:ml-72 flex flex-col">
|
||||
<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={
|
||||
@@ -378,13 +489,16 @@ function AppContent() {
|
||||
} />
|
||||
<Route path="/browse" element={
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
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={
|
||||
@@ -430,23 +544,25 @@ function AppContent() {
|
||||
</LayoutGroup>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 px-6 border-t border-border/50 bg-muted/30 backdrop-blur-sm mt-auto">
|
||||
<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-lg font-black text-muted-foreground">
|
||||
<div className="w-5 h-5 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-full" />
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">{settings?.pageTitle || 'omnyx'}</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-6 text-sm font-bold text-muted-foreground">
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Terms</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Privacy</a>
|
||||
<a href="#" className="hover:text-[#6d28d9] transition-colors duration-300">Contact</a>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
© 2026 Omnyx Media Discovery. All rights reserved.
|
||||
<p className="text-xs text-gray-600">
|
||||
© 2026 MediaVault v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user