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
+165 -49
View File
@@ -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>
);
}