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:
Lars Behrends
2026-04-26 02:18:01 +02:00
parent 9a72ba3064
commit 073c8a6c5d
37 changed files with 6306 additions and 1593 deletions
+336 -135
View File
@@ -1,86 +1,58 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
BookOpen,
Film,
Tv,
Gamepad2,
Library,
Users,
Tag,
Music as MusicIcon,
Monitor,
Eye,
Dumbbell,
Calendar,
FolderKanban,
Database,
Settings,
Sun,
LogOut,
ChevronDown,
ChevronRight,
Menu,
X,
Plus
Plus,
Film,
Tv,
Gamepad2,
Heart,
Eye,
Flame,
Clock,
ChevronRight
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext';
import { MediaCategory } from '@/types';
import { CATEGORY_PATHS } from '@/constants';
interface SidebarProps {
enabledCategories: MediaCategory[];
onToggleCategory: (category: MediaCategory) => void;
pageTitle?: string;
mediaCounts?: {
all: number;
movies: number;
series: number;
games: number;
adult: number;
favorites: number;
};
activeFilter?: string;
onFilterChange?: (filter: string) => void;
}
export default function Sidebar({ enabledCategories, onToggleCategory, pageTitle }: SidebarProps) {
const [isMediaExpanded, setIsMediaExpanded] = useState(true);
export default function Sidebar({
enabledCategories,
onToggleCategory,
pageTitle,
mediaCounts = { all: 24, movies: 8, series: 6, games: 6, adult: 4, favorites: 11 },
activeFilter = 'all',
onFilterChange
}: SidebarProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false);
const { theme, setTheme } = useTheme();
const location = useLocation();
const categoryIcons: Record<string, any> = {
'Audio Book': <BookOpen size={18} />,
'Book': <BookOpen size={18} />,
'Movie': <Film size={18} />,
'Music': <MusicIcon size={18} />,
'Show': <Tv size={18} />,
'Video Game': <Gamepad2 size={18} />,
'Consoles': <Monitor size={18} />,
'Adult': <Eye size={18} />,
'Groups': <Users size={18} />,
'People': <Users size={18} />,
'Genres': <Tag size={18} />
};
const navItems = [
{ icon: <LayoutDashboard size={18} />, label: 'Dashboard', path: '/' },
{
icon: <Film size={18} />,
label: 'Media',
hasSubmenu: true,
submenu: [
...(enabledCategories.includes('Anime') ? [{ label: 'Anime', path: '/anime' }] : []),
...(enabledCategories.includes('Books') ? [{ label: 'Book', path: '/books' }] : []),
...(enabledCategories.includes('Movies') ? [{ label: 'Movie', path: '/movies' }] : []),
...(enabledCategories.includes('Music') ? [{ label: 'Music', path: '/music' }] : []),
...(enabledCategories.includes('TV Series') ? [{ label: 'Show', path: '/tv-series' }] : []),
...(enabledCategories.includes('Games') ? [{ label: 'Video Game', path: '/games' }] : []),
...(enabledCategories.includes('Consoles') ? [{ label: 'Consoles', path: '/consoles' }] : []),
...(enabledCategories.includes('Adult') ? [{ label: 'Adult', path: '/adult' }] : []),
{ label: 'People', path: '/cast' },
{ label: 'Genres', path: '/browse' }
].filter(Boolean)
},
//{ icon: <Dumbbell size={18} />, label: 'Fitness', path: '/fitness' },
//{ icon: <Calendar size={18} />, label: 'Calendar', path: '/calendar' },
//{ icon: <FolderKanban size={18} />, label: 'Collections', path: '/collections' },
{ icon: <Plus size={18} />, label: 'Add Media', path: '/add' },
{ icon: <Settings size={18} />, label: 'Settings', path: '/settings' },
{ icon: <FolderKanban size={18} />, label: 'Import', path: '/import' }
];
const navigate = useNavigate();
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
@@ -90,6 +62,36 @@ export default function Sidebar({ enabledCategories, onToggleCategory, pageTitle
console.log('Logout clicked');
};
const handleFilterClick = (filter: string) => {
onFilterChange?.(filter);
if (filter === 'all') {
navigate('/browse');
} else if (filter === 'movies') {
navigate('/movies');
} else if (filter === 'series') {
navigate('/tv-series');
} else if (filter === 'games') {
navigate('/games');
} else if (filter === 'adult') {
navigate('/adult');
} else if (filter === 'favorites') {
navigate('/browse?favorites=true');
}
};
const handleQuickFilter = (filter: string) => {
if (filter === 'most-played') {
navigate('/browse?sort=plays');
} else if (filter === 'recently-added') {
navigate('/browse?sort=recent');
}
};
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
return (
<>
{/* Mobile menu button */}
@@ -111,100 +113,299 @@ export default function Sidebar({ enabledCategories, onToggleCategory, pageTitle
{/* Sidebar */}
<aside
className={cn(
'fixed left-0 top-0 bottom-0 w-72 bg-card border-r border-border/50 z-50 flex flex-col transition-transform duration-300',
'fixed left-0 top-0 bottom-0 w-64 bg-[#0d0f14] border-r border-white/5 z-50 flex flex-col transition-transform duration-300',
isMobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
)}
>
{/* Logo */}
<div className="p-6 border-b border-border/50">
<div className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] rounded-xl flex items-center justify-center shadow-lg shadow-[#6d28d9]/30">
<div className="w-5 h-5 rounded-full bg-white" />
<div className="w-8 h-8 bg-gradient-to-br from-[#e8466c] to-[#f47298] rounded-lg flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-5 h-5 text-white" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<span className="text-xl font-black text-foreground">{pageTitle || 'omnyx'}</span>
<span className="text-lg font-bold text-white">{pageTitle || 'MediaVault'}</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navItems.map((item) => (
<div key={item.label}>
{item.hasSubmenu ? (
<div>
<button
onClick={() => setIsMediaExpanded(!isMediaExpanded)}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-muted/50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="text-muted-foreground group-hover:text-foreground transition-colors">
{item.icon}
</div>
<span className="font-bold text-foreground">{item.label}</span>
</div>
{isMediaExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{isMediaExpanded && item.submenu && (
<div className="ml-4 mt-1 space-y-1">
{item.submenu.map((subItem) => (
<NavLink
key={subItem.label}
to={subItem.path}
onClick={() => setIsMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors',
isActive
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)
}
>
{categoryIcons[subItem.label]}
{subItem.label}
</NavLink>
))}
</div>
)}
</div>
) : (
<NavLink
to={item.path}
onClick={() => setIsMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-xl transition-colors group',
isActive
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)
}
>
<div className={cn('transition-colors', location.pathname === item.path ? 'text-[#6d28d9]' : 'group-hover:text-foreground')}>
{item.icon}
</div>
<span className="font-bold">{item.label}</span>
</NavLink>
)}
<nav className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
{/* Main Navigation */}
<NavLink
to="/"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<LayoutDashboard size={18} />
<span className="font-medium text-sm">Dashboard</span>
</NavLink>
<NavLink
to="/browse"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/browse') || isActive('/movies') || isActive('/tv-series') || isActive('/games') || isActive('/adult')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Library size={18} />
<span className="font-medium text-sm">Library</span>
</NavLink>
<NavLink
to="/cast"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/cast')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Users size={18} />
<span className="font-medium text-sm">Actors</span>
</NavLink>
<NavLink
to="/collections"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/collections')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<FolderKanban size={18} />
<span className="font-medium text-sm">Collections</span>
</NavLink>
<NavLink
to="/sources"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/sources')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Database size={18} />
<span className="font-medium text-sm">Sources</span>
</NavLink>
<NavLink
to="/settings"
onClick={() => setIsMobileOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group',
isActive('/settings')
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<Settings size={18} />
<span className="font-medium text-sm">Settings</span>
</NavLink>
{/* MEDIA TYPE Section */}
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Media Type</span>
</div>
))}
<button
onClick={() => handleFilterClick('all')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'all'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Library size={16} />
<span className="text-sm">All</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'all' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.all}
</span>
</button>
{enabledCategories.includes('Movies') && (
<button
onClick={() => handleFilterClick('movies')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'movies' || location.pathname === '/movies'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Film size={16} />
<span className="text-sm">Movies</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'movies' || location.pathname === '/movies' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.movies}
</span>
</button>
)}
{enabledCategories.includes('TV Series') && (
<button
onClick={() => handleFilterClick('series')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'series' || location.pathname === '/tv-series'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Tv size={16} />
<span className="text-sm">Series</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'series' || location.pathname === '/tv-series' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.series}
</span>
</button>
)}
{enabledCategories.includes('Games') && (
<button
onClick={() => handleFilterClick('games')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'games' || location.pathname === '/games'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Gamepad2 size={16} />
<span className="text-sm">Games</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'games' || location.pathname === '/games' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.games}
</span>
</button>
)}
{enabledCategories.includes('Adult') && (
<button
onClick={() => handleFilterClick('adult')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'adult' || location.pathname === '/adult'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Eye size={16} />
<span className="text-sm">Adult</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'adult' || location.pathname === '/adult' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.adult}
</span>
</button>
)}
<button
onClick={() => handleFilterClick('favorites')}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors group',
activeFilter === 'favorites'
? 'bg-[#e8466c]/10 text-[#e8466c]'
: 'text-gray-400 hover:text-white hover:bg-white/5'
)}
>
<div className="flex items-center gap-3">
<Heart size={16} />
<span className="text-sm">Favorites</span>
</div>
<span className={cn(
'text-xs px-2 py-0.5 rounded-full',
activeFilter === 'favorites' ? 'bg-[#e8466c]/20 text-[#e8466c]' : 'bg-white/10 text-gray-500'
)}>
{mediaCounts.favorites}
</span>
</button>
</div>
{/* QUICK FILTER Section */}
<div className="mt-6">
<div className="px-3 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Quick Filter</span>
</div>
<button
onClick={() => handleQuickFilter('most-played')}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors group"
>
<Flame size={16} className="text-orange-500" />
<span className="text-sm">Most Played</span>
</button>
<button
onClick={() => handleQuickFilter('recently-added')}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors group"
>
<Clock size={16} className="text-cyan-500" />
<span className="text-sm">Recently Added</span>
</button>
</div>
</nav>
{/* Bottom section */}
<div className="p-4 border-t border-border/50 space-y-2">
<div className="p-3 border-t border-white/5 space-y-1">
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/5 transition-colors"
>
<Sun size={18} />
<span className="font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
</button>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<LogOut size={18} />
<span className="font-medium">Logout</span>
<Sun size={16} />
<span className="text-sm font-medium">{theme === 'dark' ? 'Light theme' : 'Dark theme'}</span>
</button>
{/* User avatar */}
<div className="flex items-center gap-3 px-3 py-3 mt-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center text-white text-sm font-bold">
N
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">User</p>
</div>
<button
onClick={handleLogout}
className="text-gray-400 hover:text-white transition-colors"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</aside>
</>