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:
+223
-228
@@ -1,18 +1,13 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { Media, MediaCategory, Staff } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree, Tag } from 'lucide-react';
|
||||
import MediaTable from './MediaTable';
|
||||
import MediaFilters from './filters/MediaFilters';
|
||||
import { LayoutGrid, List, ChevronLeft, ChevronRight, User, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
interface BrowseViewProps {
|
||||
mediaList: Media[];
|
||||
@@ -22,13 +17,26 @@ interface BrowseViewProps {
|
||||
gridItemSize?: number;
|
||||
onGridItemSizeChange?: (size: number) => void;
|
||||
loading?: boolean;
|
||||
searchResultsCast?: Staff[];
|
||||
onCastClick?: (person: Staff) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange, loading = false }: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
export default function BrowseView({
|
||||
mediaList,
|
||||
onMediaClick,
|
||||
activeCategory,
|
||||
itemsPerPage: initialItemsPerPage = 12,
|
||||
gridItemSize: initialGridItemSize = 5,
|
||||
onGridItemSizeChange,
|
||||
loading = false,
|
||||
searchResultsCast = [],
|
||||
onCastClick,
|
||||
searchQuery = ''
|
||||
}: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
@@ -53,14 +61,6 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
|
||||
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
|
||||
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
|
||||
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.series || []))), [mediaList]);
|
||||
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||
@@ -76,21 +76,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filteredMedia, sortBy]);
|
||||
|
||||
const sortedMedia = useMemo(() => {
|
||||
const list = [...filteredMedia];
|
||||
if (sortBy === 'title-asc') {
|
||||
return list.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
if (sortBy === 'title-desc') {
|
||||
return list.sort((a, b) => b.title.localeCompare(a.title));
|
||||
}
|
||||
return list;
|
||||
}, [filteredMedia, sortBy]);
|
||||
}, [filteredMedia]);
|
||||
|
||||
const gridColsClass = useMemo(() => {
|
||||
// Map slider value (1-10) to grid columns
|
||||
const colsMap: Record<number, string> = {
|
||||
1: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
@@ -106,12 +94,21 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
||||
}, [gridItemSize]);
|
||||
|
||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
||||
const totalPages = Math.ceil(filteredMedia.length / itemsPerPage);
|
||||
|
||||
const paginatedMedia = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return sortedMedia.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [sortedMedia, currentPage, itemsPerPage]);
|
||||
return filteredMedia.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredMedia, currentPage, itemsPerPage]);
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
||||
@@ -123,173 +120,72 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Calculate favorite IDs
|
||||
const favoriteIds = useMemo(() => {
|
||||
return new Set(mediaList.filter(m => m.rating && m.rating >= 8).map(m => m.id));
|
||||
}, [mediaList]);
|
||||
|
||||
// Check if we have search results
|
||||
const hasSearchResults = searchQuery.trim().length > 0;
|
||||
const hasCastResults = searchResultsCast.length > 0;
|
||||
const hasMediaResults = mediaList.length > 0;
|
||||
|
||||
// Pagination for cast results (show first 12)
|
||||
const paginatedCast = useMemo(() => {
|
||||
return searchResultsCast.slice(0, itemsPerPage);
|
||||
}, [searchResultsCast, itemsPerPage]);
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
<div className="pt-6 pb-12 px-6 w-full mx-auto">
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<Star size={16} />
|
||||
{selectedGenre || 'Genres'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
|
||||
{allGenres.sort().map(genre => (
|
||||
<DropdownMenuItem key={genre} onClick={() => setSelectedGenre(genre)}>{genre}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
Studios
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
|
||||
{allStudios.sort().map(studio => (
|
||||
<DropdownMenuItem key={studio} onClick={() => setSelectedStudio(studio)}>{studio}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Platform Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<Monitor size={16} />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
|
||||
{allPlatforms.sort().map(platform => (
|
||||
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Developer Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<Users size={16} />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
|
||||
{allDevelopers.sort().map(developer => (
|
||||
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Category Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<FolderTree size={16} />
|
||||
{selectedCategory || 'Series'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>--- Alle ---</DropdownMenuItem>
|
||||
{allCategories.sort().map(category => (
|
||||
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Source Filter */}
|
||||
{allSources.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedSource ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
|
||||
<Tag size={16} />
|
||||
{selectedSource || 'Source'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedSource(null)}>All Sources</DropdownMenuItem>
|
||||
{allSources.sort().map(source => (
|
||||
<DropdownMenuItem key={source} onClick={() => setSelectedSource(source)}>{source}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory || selectedSource) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-muted-foreground font-bold hover:text-[#6d28d9] transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
setSelectedSource(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<MediaFilters
|
||||
mediaList={mediaList}
|
||||
activeCategory={activeCategory}
|
||||
selectedGenre={selectedGenre}
|
||||
selectedStudio={selectedStudio}
|
||||
selectedPlatform={selectedPlatform}
|
||||
selectedDeveloper={selectedDeveloper}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedSource={selectedSource}
|
||||
onGenreChange={setSelectedGenre}
|
||||
onStudioChange={setSelectedStudio}
|
||||
onPlatformChange={setSelectedPlatform}
|
||||
onDeveloperChange={setSelectedDeveloper}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
onSourceChange={setSelectedSource}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Grid item size slider */}
|
||||
<div className="flex items-center gap-3 bg-muted/50 backdrop-blur-sm rounded-xl px-4 py-2.5 border border-border/50">
|
||||
<span className="text-xs font-bold text-muted-foreground">Size</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={gridItemSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
setGridItemSize(newSize);
|
||||
onGridItemSizeChange?.(newSize);
|
||||
}}
|
||||
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 text-muted-foreground font-bold backdrop-blur-sm border-border/50">
|
||||
<ArrowUpDown size={16} />
|
||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('title-asc')}>Title (A-Z)</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('title-desc')}>Title (Z-A)</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Grid item size slider - only show in grid mode */}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="flex items-center gap-3 bg-[#1a1d26] rounded-xl px-4 py-2 border border-white/10">
|
||||
<span className="text-xs font-bold text-gray-500">Size</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={gridItemSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
setGridItemSize(newSize);
|
||||
onGridItemSizeChange?.(newSize);
|
||||
}}
|
||||
className="w-24 h-2 bg-[#0d0f14] rounded-lg appearance-none cursor-pointer accent-[#e8466c]"
|
||||
/>
|
||||
<span className="text-xs font-bold text-[#e8466c] w-5 text-center">{gridItemSize}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center bg-muted/50 backdrop-blur-sm rounded-xl p-1 border border-border/50">
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center bg-[#1a1d26] rounded-xl p-1 border border-white/10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all rounded-lg",
|
||||
viewMode === 'grid' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
|
||||
viewMode === 'grid' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
@@ -300,7 +196,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-all rounded-lg",
|
||||
viewMode === 'list' ? "bg-background shadow-sm text-[#6d28d9]" : "text-muted-foreground hover:bg-background/50"
|
||||
viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
@@ -310,57 +206,156 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results Summary */}
|
||||
{hasSearchResults && (
|
||||
<div className="flex items-center gap-4 mb-4 p-3 bg-[#1a1d26] rounded-lg border border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-400">Search results for:</span>
|
||||
<Badge variant="secondary" className="bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30">
|
||||
"{searchQuery}"
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
{hasMediaResults && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<LayoutGrid size={14} />
|
||||
<span>{mediaList.length} media</span>
|
||||
</div>
|
||||
)}
|
||||
{hasCastResults && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Users size={14} />
|
||||
<span>{searchResultsCast.length} cast</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {paginatedMedia.length} of {filteredMedia.length} results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cast Search Results */}
|
||||
{hasSearchResults && hasCastResults && onCastClick && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Users size={18} className="text-[#e8466c]" />
|
||||
<h3 className="text-lg font-bold text-white">Cast Results</h3>
|
||||
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
|
||||
{searchResultsCast.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
||||
{paginatedCast.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
onClick={() => onCastClick(person)}
|
||||
className="group cursor-pointer bg-[#1a1d26] rounded-lg p-3 border border-white/10 hover:border-[#e8466c]/50 transition-all duration-300 hover:bg-[#1f232c]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-[#0d0f14] shrink-0">
|
||||
{person.photo ? (
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<User size={20} className="text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate group-hover:text-[#e8466c] transition-colors">
|
||||
{person.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{person.role}</p>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{person.filmography.length} role{person.filmography.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{searchResultsCast.length > itemsPerPage && (
|
||||
<p className="text-xs text-gray-500 mt-3 text-center">
|
||||
+{searchResultsCast.length - itemsPerPage} more cast members
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<Loading message="Loading media..." />
|
||||
) : mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<Search size={32} />
|
||||
) : mediaList.length === 0 && !hasCastResults ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
|
||||
<div className="w-16 h-16 bg-[#1a1d26] rounded-full flex items-center justify-center mb-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold">No results found</p>
|
||||
<p className="text-lg font-bold text-gray-300">No results found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<p className="text-sm">No media results found for this search</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? cn(gridColsClass, "gap-x-4 gap-y-8")
|
||||
: "flex flex-col gap-2"
|
||||
)}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedMedia.map((media) => (
|
||||
viewMode === 'grid' ? (
|
||||
<>
|
||||
{hasSearchResults && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<LayoutGrid size={18} className="text-[#e8466c]" />
|
||||
<h3 className="text-lg font-bold text-white">Media Results</h3>
|
||||
<Badge variant="secondary" className="bg-[#1a1d26] text-gray-400">
|
||||
{mediaList.length}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' ? (
|
||||
<MediaTable
|
||||
mediaList={paginatedMedia}
|
||||
onMediaClick={onMediaClick}
|
||||
favoriteIds={favoriteIds}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(gridColsClass, "gap-x-4 gap-y-8")}>
|
||||
{paginatedMedia.map((media) => (
|
||||
<MediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
showBadge={true}
|
||||
showFavorite={true}
|
||||
/>
|
||||
) : (
|
||||
<MediaListItem
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{mediaList.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border pt-8">
|
||||
{filteredMedia.length > 0 && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-white/10 pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
||||
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="bg-muted border-none rounded-md px-2 py-1 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
className="bg-[#1a1d26] border border-white/10 rounded-md px-2 py-1 text-sm font-medium text-gray-300 focus:ring-2 focus:ring-[#e8466c] outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -372,16 +367,16 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-border"
|
||||
className="gap-2 font-bold border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
||||
<span className="text-sm font-bold text-[#e8466c]">{currentPage}</span>
|
||||
<span className="text-sm text-gray-500 font-medium">of</span>
|
||||
<span className="text-sm font-bold text-gray-300">{totalPages || 1}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -389,7 +384,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-border"
|
||||
className="gap-2 font-bold border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
|
||||
+395
-273
@@ -1,10 +1,25 @@
|
||||
import { Staff, Media } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'motion/react';
|
||||
import { ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye, ChevronDown, ListFilter } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
ArrowLeft, Calendar, MapPin, Briefcase, Film, User, Ruler, Palette, Eye,
|
||||
BookOpen, Theater, ArrowUpAZ, ArrowDownAZ, ArrowUpDown, Star
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CastDetailViewProps {
|
||||
person: Staff;
|
||||
@@ -31,51 +46,64 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
// Sort options
|
||||
const sortOptions = [
|
||||
{ value: 'year', label: 'Year', icon: Calendar },
|
||||
{ value: 'title', label: 'Title', icon: ArrowUpAZ },
|
||||
{ value: 'role', label: 'Role', icon: Briefcase },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[50vh] md:h-[60vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={person.photo}
|
||||
<div className="min-h-screen bg-background pb-16">
|
||||
{/* Compact Hero Section */}
|
||||
<div className="relative h-[35vh] md:h-[40vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover opacity-40 blur-xl scale-110"
|
||||
className="w-full h-full object-cover opacity-30 blur-xl scale-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute inset-0 flex items-end px-6 pb-12">
|
||||
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-8">
|
||||
<motion.div
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
||||
|
||||
<div className="absolute inset-0 flex items-end px-4 sm:px-6 pb-8">
|
||||
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="h-48 md:h-72 rounded-2xl overflow-hidden border-4 border-background shadow-2xl shrink-0"
|
||||
className="shrink-0"
|
||||
>
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<Avatar className="h-32 md:h-40 w-auto aspect-[3/4] rounded-none border-3 border-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<AvatarFallback className="rounded-none text-3xl">
|
||||
<User className="h-12 w-12" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 text-center md:text-left pb-4">
|
||||
<div className="flex-1 text-center md:text-left pb-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-foreground mb-4 drop-shadow-sm">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
||||
{person.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-3">
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-2">
|
||||
{person.occupations?.map(occ => (
|
||||
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-bold px-4 py-1.5 backdrop-blur-sm">
|
||||
<Badge key={occ} variant="secondary" className="bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 font-medium px-3 py-1 text-xs">
|
||||
{occ}
|
||||
</Badge>
|
||||
))}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold px-4 py-1.5">
|
||||
{person.filmography.length} Role{person.filmography.length !== 1 ? 's' : ''}
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-medium px-3 py-1 text-xs">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,289 +112,383 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 bg-white/30 hover:bg-white/50 text-white rounded-2xl backdrop-blur-md transition-all duration-300 hover:scale-110 border border-white/20"
|
||||
className="absolute top-20 left-4 sm:left-6 bg-white/20 hover:bg-white/40 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="max-w-[1920px] mx-auto px-6 mt-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
|
||||
<h3 className="text-2xl font-black text-foreground">Personal Info</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Calendar size={20} />
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar Info - Modern shadcn Design */}
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
{/* Personal Info Card */}
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-[#6d28d9]/10 flex items-center justify-center">
|
||||
<User size={12} className="text-[#6d28d9]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Date</p>
|
||||
<p className="font-bold text-foreground">{person.birthDate || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<MapPin size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Birth Place</p>
|
||||
<p className="font-bold text-foreground">{person.birthPlace || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Briefcase size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Known For</p>
|
||||
<p className="font-bold text-foreground">{person.role}</p>
|
||||
Personal Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* Birth Date */}
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Born</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{person.birthDate || '—'}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Birth Place */}
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Origin</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate max-w-[140px]" title={person.birthPlace || undefined}>
|
||||
{person.birthPlace || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Known For */}
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
||||
<Briefcase size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Role</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs font-normal bg-[#6d28d9]/10 text-[#6d28d9] border-none">
|
||||
{person.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Ethnicity - only if present */}
|
||||
{(person.ethnicity || person.adult_specifics?.ethnicity) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<User size={20} />
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-md bg-[#6d28d9]/10 flex items-center justify-center text-[#6d28d9]">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Ethnicity</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate max-w-[140px]">
|
||||
{person.adult_specifics?.ethnicity || person.ethnicity}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Ethnicity</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.ethnicity || person.ethnicity}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-3xl p-8 space-y-6 border border-border/50">
|
||||
<h3 className="text-2xl font-black text-foreground">Measurements</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Height</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.height || person.height} cm</p>
|
||||
</div>
|
||||
{/* Measurements Card - Only if data exists */}
|
||||
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight ||
|
||||
person.adult_specifics?.measurements || person.bust_size || person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-[#6d28d9]/10 flex items-center justify-center">
|
||||
<Ruler size={12} className="text-[#6d28d9]" />
|
||||
</div>
|
||||
|
||||
|
||||
{(person.weight || person.adult_specifics?.weight) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Ruler size={20} />
|
||||
Measurements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* Height & Weight Grid */}
|
||||
{(person.adult_specifics?.height || person.height || person.adult_specifics?.weight || person.weight) && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
{(person.adult_specifics?.height || person.height) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Height</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{person.adult_specifics?.height || person.height}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">cm</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(person.adult_specifics?.weight || person.weight) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Weight</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{person.adult_specifics?.weight || person.weight}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">kg</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Weight</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.weight || person.weight} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Measurements (Bust-Waist-Hip) */}
|
||||
{(person.adult_specifics?.measurements || person.bust_size || person.cup_size || person.waist_size || person.hip_size) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Ruler size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Measurements</p>
|
||||
<p className="font-bold text-foreground">
|
||||
<>
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1.5">Figure</p>
|
||||
<p className="text-sm font-medium font-mono tracking-wide">
|
||||
{person.adult_specifics?.measurements || (
|
||||
<>
|
||||
{person.bust_size && `${person.bust_size}`}
|
||||
{person.cup_size && person.cup_size}
|
||||
{person.bust_size || person.cup_size ? '-' : ''}
|
||||
{person.waist_size && `${person.waist_size}`}
|
||||
{person.waist_size ? '-' : ''}
|
||||
{person.hip_size && `${person.hip_size}`}
|
||||
{person.bust_size && <span className="inline-flex items-center gap-0.5">{person.bust_size}{person.cup_size && <span className="text-xs text-muted-foreground">{person.cup_size}</span>}</span>}
|
||||
{(person.bust_size || person.cup_size) && person.waist_size && <span className="text-muted-foreground mx-1">—</span>}
|
||||
{person.waist_size && <span>{person.waist_size}</span>}
|
||||
{person.hip_size && <span className="text-muted-foreground mx-1">—</span>}
|
||||
{person.hip_size && <span>{person.hip_size}</span>}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Palette size={20} />
|
||||
{/* Hair & Eyes Grid */}
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
{(person.hair_color || person.adult_specifics?.hair_color) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Palette size={12} className="text-[#6d28d9]" />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Hair</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{person.adult_specifics?.hair_color || person.hair_color}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Hair Color</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.hair_color || person.hair_color}</p>
|
||||
)}
|
||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Eye size={12} className="text-[#6d28d9]" />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Eyes</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{person.adult_specifics?.eye_color || person.eye_color}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(person.eye_color || person.adult_specifics?.eye_color) && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Eye Color</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics?.eye_color || person.eye_color}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.adult_specifics?.tattoos && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Tattoos</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics.tattoos}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{person.adult_specifics?.piercings && (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-background flex items-center justify-center text-[#6d28d9] shadow-sm border border-border/50">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">Piercings</p>
|
||||
<p className="font-bold text-foreground">{person.adult_specifics.piercings}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Bio & Roles */}
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
{person.bio && (
|
||||
<section>
|
||||
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
Biography
|
||||
</h2>
|
||||
<p className="text-foreground leading-relaxed text-lg">
|
||||
{person.bio}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-3xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<User className="text-[#6d28d9]" />
|
||||
Characters
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{person.filmography.map(item => (
|
||||
<div
|
||||
key={`${item.id}-char`}
|
||||
className="flex items-center gap-4 p-5 rounded-2xl bg-muted/50 border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border-2 border-background">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Character</p>
|
||||
<h4 className="font-black text-foreground truncate">{item.characterName || item.role}</h4>
|
||||
<button
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="text-xs font-bold text-[#6d28d9] hover:underline mt-1 text-left transition-colors"
|
||||
>
|
||||
in {item.title}
|
||||
</button>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
|
||||
{item.category}
|
||||
</Badge>
|
||||
{/* Tattoos & Piercings */}
|
||||
{(person.adult_specifics?.tattoos || person.adult_specifics?.piercings) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 divide-x divide-border">
|
||||
{person.adult_specifics?.tattoos && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Tattoos</p>
|
||||
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.tattoos}</p>
|
||||
</div>
|
||||
)}
|
||||
{person.adult_specifics?.piercings && (
|
||||
<div className="px-4 py-3 hover:bg-muted/30 transition-colors">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Piercings</p>
|
||||
<p className="text-xs font-medium text-foreground line-clamp-2">{person.adult_specifics.piercings}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-black text-foreground flex items-center gap-3">
|
||||
<Film className="text-[#6d28d9]" />
|
||||
Filmography
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
className="rounded-xl border-border hover:border-[#6d28d9]/50 transition-all duration-300"
|
||||
>
|
||||
<ListFilter size={16} />
|
||||
</Button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'year' | 'title' | 'role')}
|
||||
className="bg-muted/50 backdrop-blur-sm border border-border/50 rounded-xl px-4 py-2 text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-[#6d28d9]/50"
|
||||
>
|
||||
<option value="year">Year</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="role">Role</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{sortedFilmography.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="group flex items-center gap-4 p-4 rounded-2xl bg-card border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div className="w-16 h-20 rounded-xl overflow-hidden shrink-0 shadow-sm border border-border/30">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-1">
|
||||
{item.year || 'Unknown'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border/50">
|
||||
{item.role}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Main Bio & Roles - Wider */}
|
||||
<div className="lg:col-span-3">
|
||||
<Tabs defaultValue={person.bio ? 'bio' : 'filmography'} className="w-full">
|
||||
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto">
|
||||
{person.bio && (
|
||||
<TabsTrigger value="bio" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<BookOpen size={14} />
|
||||
Biography
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<>
|
||||
<TabsTrigger value="characters" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Theater size={14} />
|
||||
Characters
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="filmography" className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Film size={14} />
|
||||
Filmography
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{person.bio && (
|
||||
<TabsContent value="bio" className="mt-0">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">Biography</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-foreground leading-relaxed text-sm">
|
||||
{person.bio}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<>
|
||||
<TabsContent value="characters" className="mt-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{person.filmography.map((item, index) => (
|
||||
<motion.div
|
||||
key={`${item.id}-char`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Card
|
||||
className="hover:border-[#6d28d9]/30 hover:shadow-md transition-all duration-200 cursor-pointer group border-border/60"
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
>
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Character</p>
|
||||
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#6d28d9] transition-colors">
|
||||
{item.characterName || item.role}
|
||||
</h4>
|
||||
<p className="text-xs text-[#6d28d9] truncate">{item.title}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="filmography" className="mt-0">
|
||||
{/* Sort Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{person.filmography.length} {person.filmography.length === 1 ? 'title' : 'titles'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 rounded-lg text-xs border-border/60"
|
||||
>
|
||||
<ArrowUpDown size={14} className="mr-1.5" />
|
||||
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
|
||||
{sortOptions.find(o => o.value === sortBy)?.label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Sort by
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{sortOptions.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (sortBy === option.value) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(option.value);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<option.icon size={14} />
|
||||
{option.label}
|
||||
</span>
|
||||
{sortBy === option.value && (
|
||||
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#6d28d9]" /> : <ArrowDownAZ size={14} className="text-[#6d28d9]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Filmography Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedFilmography.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Card
|
||||
onClick={() => handleMediaClick(item.id.toString())}
|
||||
className="group cursor-pointer hover:border-[#6d28d9]/30 hover:shadow-md transition-all duration-200 border-border/60"
|
||||
>
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<div className="w-12 h-16 rounded-none overflow-hidden shrink-0 bg-muted border border-border/40">
|
||||
<img
|
||||
src={item.poster || person.photo}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-foreground truncate text-sm group-hover:text-[#6d28d9] transition-colors">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{item.year || 'Unknown'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 font-normal">
|
||||
{item.role}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 bg-muted font-normal">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+565
-253
@@ -1,10 +1,40 @@
|
||||
import { Staff, MediaCategory } from '@/types';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
|
||||
import {
|
||||
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
|
||||
LayoutGrid, Table2, Eye, Calendar, Star, ArrowUpAZ, ArrowDownAZ,
|
||||
Briefcase, Film, Users, ChevronUp, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -30,14 +60,19 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
return (localStorage.getItem('castSortOrder') as 'asc' | 'desc') || 'desc';
|
||||
});
|
||||
const [filterOccupation, setFilterOccupation] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterOccupation') || '';
|
||||
const saved = localStorage.getItem('castFilterOccupation');
|
||||
return saved && saved !== '' ? saved : 'all';
|
||||
});
|
||||
const [filterMediaType, setFilterMediaType] = useState<string>(() => {
|
||||
return localStorage.getItem('castFilterMediaType') || '';
|
||||
const saved = localStorage.getItem('castFilterMediaType');
|
||||
return saved && saved !== '' ? saved : 'all';
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'table'>(() => {
|
||||
return (localStorage.getItem('castViewMode') as 'grid' | 'table') || 'grid';
|
||||
});
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
|
||||
// Sync itemsPerPage with prop when API settings are loaded
|
||||
useEffect(() => {
|
||||
@@ -71,11 +106,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
setSearchQuery('');
|
||||
setSortBy('roleCount');
|
||||
setSortOrder('desc');
|
||||
setFilterOccupation('');
|
||||
setFilterMediaType('');
|
||||
setFilterOccupation('all');
|
||||
setFilterMediaType('all');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterOccupation || filterMediaType || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
const hasActiveFilters = searchQuery || (filterOccupation && filterOccupation !== 'all') || (filterMediaType && filterMediaType !== 'all') || sortBy !== 'roleCount' || sortOrder !== 'desc';
|
||||
|
||||
useEffect(() => {
|
||||
const loadCast = async () => {
|
||||
@@ -110,12 +145,12 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
}
|
||||
|
||||
// Filter by occupation
|
||||
if (filterOccupation && !s.occupations?.includes(filterOccupation)) {
|
||||
if (filterOccupation && filterOccupation !== 'all' && !s.occupations?.includes(filterOccupation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Filter by media type
|
||||
if (filterMediaType && !s.media_types?.includes(filterMediaType)) {
|
||||
if (filterMediaType && filterMediaType !== 'all' && !s.media_types?.includes(filterMediaType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -185,260 +220,537 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
|
||||
<div>
|
||||
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
Cast & Staff
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-medium text-lg">Discover the people behind your favorite media</p>
|
||||
</div>
|
||||
// Persist view mode
|
||||
useEffect(() => {
|
||||
localStorage.setItem('castViewMode', viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
||||
<Input
|
||||
placeholder="Search cast..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-full md:w-[300px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showFilters ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
className={`rounded-xl h-11 w-11 transition-all duration-300 ${showFilters ? 'bg-gradient-to-br from-[#6d28d9] to-[#8b5cf6] text-white border-[#6d28d9]' : 'border-border hover:border-[#6d28d9]/50'}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-xl h-11 w-11 border-border hover:border-[#6d28d9]/50 transition-all duration-300"
|
||||
onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
>
|
||||
<ArrowUpDown size={20} />
|
||||
</Button>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-xl h-11 w-11 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-300"
|
||||
onClick={handleResetFilters}
|
||||
title="Reset filters"
|
||||
// Sort handler for table
|
||||
const handleSort = (column: typeof sortBy) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Sort options with labels
|
||||
const sortOptions = [
|
||||
{ value: 'name', label: 'Name', icon: ArrowUpAZ },
|
||||
{ value: 'role', label: 'Role', icon: Briefcase },
|
||||
{ value: 'birthDate', label: 'Birth Date', icon: Calendar },
|
||||
{ value: 'height', label: 'Height', icon: ArrowUpDown },
|
||||
{ value: 'roleCount', label: 'Role Count', icon: Star },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="pt-16 pb-12 px-4 sm:px-6 max-w-[1920px] mx-auto">
|
||||
{/* Compact Toolbar - Like MediaFilters */}
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
{/* Top Row: Search, View Toggle, Count */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input
|
||||
placeholder="Search cast..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-muted/50 border-none rounded-lg text-sm focus-visible:ring-[#6d28d9]/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
onValueChange={(value: string | string[]) => {
|
||||
const v = Array.isArray(value) ? value[0] : value;
|
||||
if (v === 'grid' || v === 'table') {
|
||||
setViewMode(v);
|
||||
}
|
||||
}}
|
||||
className="bg-muted/50 p-0.5 rounded-lg"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
<ToggleGroupItem value="grid" aria-label="Grid view" className="h-8 w-8 p-0 rounded-md data-[state=on]:bg-background data-[state=on]:shadow-sm">
|
||||
<LayoutGrid size={16} />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="table" aria-label="Table view" className="h-8 w-8 p-0 rounded-md data-[state=on]:bg-background data-[state=on]:shadow-sm">
|
||||
<Table2 size={16} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{/* Count Badge */}
|
||||
<Badge variant="secondary" className="h-8 px-2.5 bg-muted/80 text-muted-foreground font-normal">
|
||||
{filteredStaff.length} {filteredStaff.length === 1 ? 'person' : 'people'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Filter Dropdowns */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Sort Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||
(sortBy !== 'roleCount' || sortOrder !== 'desc')
|
||||
? "border-[#6d28d9]/30 bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20"
|
||||
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown size={14} className="mr-1.5" />
|
||||
{sortOrder === 'asc' ? <ArrowUpAZ size={14} className="mr-1.5" /> : <ArrowDownAZ size={14} className="mr-1.5" />}
|
||||
{sortOptions.find(o => o.value === sortBy)?.label || 'Sort'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Sort by
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{sortOptions.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (sortBy === option.value) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(option.value);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<option.icon size={14} />
|
||||
{option.label}
|
||||
</span>
|
||||
{sortBy === option.value && (
|
||||
sortOrder === 'asc' ? <ArrowUpAZ size={14} className="text-[#6d28d9]" /> : <ArrowDownAZ size={14} className="text-[#6d28d9]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Occupation Filter */}
|
||||
{uniqueOccupations.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||
filterOccupation && filterOccupation !== 'all'
|
||||
? "border-[#6d28d9]/30 bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20"
|
||||
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Briefcase size={14} className="mr-1.5" />
|
||||
{filterOccupation && filterOccupation !== 'all' ? filterOccupation : 'Occupation'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Filter by Occupation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setFilterOccupation('all')}>
|
||||
All Occupations
|
||||
</DropdownMenuItem>
|
||||
{uniqueOccupations.map(occ => (
|
||||
<DropdownMenuItem key={occ} onClick={() => setFilterOccupation(occ)}>
|
||||
{occ}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Media Type Filter */}
|
||||
{uniqueMediaTypes.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-lg border text-xs font-medium transition-colors",
|
||||
filterMediaType && filterMediaType !== 'all'
|
||||
? "border-[#6d28d9]/30 bg-[#6d28d9]/10 text-[#6d28d9] hover:bg-[#6d28d9]/20"
|
||||
: "border-border/60 bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Film size={14} className="mr-1.5" />
|
||||
{filterMediaType && filterMediaType !== 'all' ? filterMediaType : 'Media Type'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-muted-foreground">
|
||||
Filter by Media Type
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setFilterMediaType('all')}>
|
||||
All Media Types
|
||||
</DropdownMenuItem>
|
||||
{uniqueMediaTypes.map(type => (
|
||||
<DropdownMenuItem key={type} onClick={() => setFilterMediaType(type)}>
|
||||
{type}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Clear All */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetFilters}
|
||||
className="h-8 px-2 text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Filter Badges */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 hover:bg-[#6d28d9]/20 cursor-pointer"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
Search: {searchQuery}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{filterOccupation && filterOccupation !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 hover:bg-[#6d28d9]/20 cursor-pointer"
|
||||
onClick={() => setFilterOccupation('all')}
|
||||
>
|
||||
{filterOccupation}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{filterMediaType && filterMediaType !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20 hover:bg-[#6d28d9]/20 cursor-pointer"
|
||||
onClick={() => setFilterMediaType('all')}
|
||||
>
|
||||
{filterMediaType}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{(sortBy !== 'roleCount' || sortOrder !== 'desc') && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 px-2 text-xs bg-muted text-muted-foreground hover:bg-muted/80 cursor-pointer"
|
||||
onClick={() => { setSortBy('roleCount'); setSortOrder('desc'); }}
|
||||
>
|
||||
Sort: {sortOptions.find(o => o.value === sortBy)?.label}
|
||||
<X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-muted/50 backdrop-blur-sm rounded-2xl p-6 mb-6 border border-border/50"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Sort By</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="birthDate">Birth Date</option>
|
||||
<option value="height">Height</option>
|
||||
<option value="roleCount">Role Count</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Occupation</label>
|
||||
<select
|
||||
value={filterOccupation}
|
||||
onChange={(e) => setFilterOccupation(e.target.value)}
|
||||
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||
>
|
||||
<option value="">All Occupations</option>
|
||||
{uniqueOccupations.map(occ => (
|
||||
<option key={occ} value={occ}>{occ}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-bold text-foreground mb-2 block">Media Type</label>
|
||||
<select
|
||||
value={filterMediaType}
|
||||
onChange={(e) => setFilterMediaType(e.target.value)}
|
||||
className="w-full bg-background border-border/50 rounded-xl px-4 py-3 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||
>
|
||||
<option value="">All Media Types</option>
|
||||
{uniqueMediaTypes.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{searchQuery && (
|
||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
Search: {searchQuery}
|
||||
<button onClick={() => setSearchQuery('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{filterOccupation && (
|
||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
Occupation: {filterOccupation}
|
||||
<button onClick={() => setFilterOccupation('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{filterMediaType && (
|
||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
Media Type: {filterMediaType}
|
||||
<button onClick={() => setFilterMediaType('')} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{(sortBy !== 'name' || sortOrder !== 'asc') && (
|
||||
<Badge variant="secondary" className="gap-1 bg-[#6d28d9]/10 text-[#6d28d9] border-[#6d28d9]/20">
|
||||
Sort: {sortBy} ({sortOrder})
|
||||
<button onClick={() => { setSortBy('name'); setSortOrder('asc'); }} className="hover:text-foreground">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{/* Content Area */}
|
||||
{loading ? (
|
||||
<Loading message="Loading cast..." />
|
||||
) : filteredStaff.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6">
|
||||
<User size={40} />
|
||||
</div>
|
||||
<p className="text-xl font-bold">No cast members found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View - Modern Cards */
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedStaff.map((person) => (
|
||||
<motion.div
|
||||
key={person.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card
|
||||
className="group cursor-pointer overflow-hidden hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 border-border/60"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
{/* Card Header with Avatar and Info */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-12 w-12 rounded-lg border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300 shadow-sm">
|
||||
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" className="object-cover" />
|
||||
<AvatarFallback className="rounded-lg bg-muted">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300 text-sm leading-tight">
|
||||
{person.name}
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
|
||||
{person.role}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5">
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 bg-muted">
|
||||
<Star className="w-2.5 h-2.5 mr-0.5" />
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{person.filmography.length} roles</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{person.birthDate && (
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
|
||||
<Calendar className="w-2.5 h-2.5" />
|
||||
{new Date(person.birthDate).getFullYear()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading message="Loading cast..." />
|
||||
) : filteredStaff.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
|
||||
<User size={40} />
|
||||
{/* Latest Role Section */}
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="bg-muted/50 rounded-lg p-2 flex items-center gap-2 border border-border/40 group-hover:border-[#6d28d9]/20 transition-colors">
|
||||
<div className="w-8 h-11 rounded overflow-hidden shrink-0 bg-background border border-border/40">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide leading-none">Latest</p>
|
||||
<p className="text-[11px] font-medium text-foreground truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-[10px] text-[#6d28d9] truncate">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<p className="text-xl font-bold">No cast members found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedStaff.map((person) => (
|
||||
<motion.div
|
||||
key={person.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="group bg-card rounded-2xl p-5 shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 hover:shadow-[#6d28d9]/10 transition-all duration-300 cursor-pointer"
|
||||
onClick={() => onPersonClick(person)}
|
||||
) : (
|
||||
/* Table View */
|
||||
<div className="w-full">
|
||||
<Table className="border rounded-lg border-border/60 w-full table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
||||
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:text-[#6d28d9] transition-colors"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Name
|
||||
{sortBy === 'name' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:text-[#6d28d9] transition-colors"
|
||||
onClick={() => handleSort('role')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Role
|
||||
{sortBy === 'role' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Latest Work</TableHead>
|
||||
<TableHead
|
||||
className="hidden sm:table-cell cursor-pointer hover:text-[#6d28d9] transition-colors text-right"
|
||||
onClick={() => handleSort('roleCount')}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Roles
|
||||
{sortBy === 'roleCount' && (sortOrder === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-10 rounded-tr-lg"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedStaff.map((person) => (
|
||||
<motion.tr
|
||||
key={person.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
"group cursor-pointer border-border/40 transition-colors",
|
||||
hoveredRow === person.id ? "bg-muted/60" : "hover:bg-muted/40"
|
||||
)}
|
||||
onMouseEnter={() => setHoveredRow(person.id)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<TableCell className="py-3">
|
||||
<Avatar className="h-10 w-10 rounded-lg border border-border/50">
|
||||
<AvatarImage src={person.photo} alt={person.name} referrerPolicy="no-referrer" />
|
||||
<AvatarFallback className="rounded-lg bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="group-hover:text-[#6d28d9] transition-colors">{person.name}</span>
|
||||
{person.birthDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(person.birthDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="font-normal bg-muted/80 text-muted-foreground">
|
||||
{person.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{person.filmography && person.filmography.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-10 rounded overflow-hidden shrink-0 bg-muted">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-xs text-muted-foreground">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-right">
|
||||
{person.filmography ? (
|
||||
<Badge variant="outline" className="font-medium">
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPersonClick(person);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination - Modern */}
|
||||
{filteredStaff.length > 0 && (
|
||||
<div className="mt-10 flex flex-col sm:flex-row items-center justify-between gap-4 pt-6 border-t border-border/50">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>Showing</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{Math.min((currentPage - 1) * itemsPerPage + 1, filteredStaff.length)}-{Math.min(currentPage * itemsPerPage, filteredStaff.length)}
|
||||
</span>
|
||||
<span>of</span>
|
||||
<span className="font-semibold text-foreground">{filteredStaff.length}</span>
|
||||
<span>items</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={(value) => setItemsPerPage(Number(value))}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-[#6d28d9] transition-colors duration-300">
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{person.name}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{person.role}
|
||||
</p>
|
||||
</div>
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold text-[10px] px-2 py-0.5 shrink-0">
|
||||
{person.filmography.length}
|
||||
</Badge>
|
||||
)}
|
||||
<SelectTrigger className="w-20 h-9 rounded-lg text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<SelectItem key={size} value={size.toString()}>{size}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="h-9 w-9 rounded-lg border-border/60"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-sm font-semibold text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-muted-foreground">/</span>
|
||||
<span className="text-sm text-muted-foreground">{totalPages}</span>
|
||||
</div>
|
||||
|
||||
{person.filmography && person.filmography.length > 0 && (
|
||||
<div className="bg-muted/50 backdrop-blur-sm rounded-xl p-3 flex items-center gap-3 border border-border/30">
|
||||
<div className="w-10 h-12 rounded-lg overflow-hidden shrink-0 bg-background border border-border/30">
|
||||
<img
|
||||
src={person.filmography[0].poster || person.photo}
|
||||
alt={person.filmography[0].title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest leading-none mb-1">Latest Role</p>
|
||||
<p className="text-xs font-bold text-foreground truncate">{person.filmography[0].title}</p>
|
||||
<p className="text-[10px] text-[#6d28d9] font-bold truncate mt-1">{person.filmography[0].role}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredStaff.length > 0 && (
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-border/50 pt-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground font-medium">Items per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
}}
|
||||
className="bg-muted/50 backdrop-blur-sm border-none rounded-xl px-3 py-2 text-sm font-bold text-foreground focus:ring-2 focus:ring-[#6d28d9]/50 outline-none"
|
||||
>
|
||||
{[12, 20, 36, 48, 60].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-[#6d28d9]">{currentPage}</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">of</span>
|
||||
<span className="text-sm font-bold text-foreground">{totalPages || 1}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="h-9 w-9 rounded-lg border-border/60"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="gap-2 font-bold border-border hover:border-[#6d28d9]/50 rounded-xl transition-all duration-300"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+185
-211
@@ -1,9 +1,22 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import { Film, Tv, Music, Book, Gamepad2, Users, Star, TrendingUp, Clock, Hash, Play, Award } from 'lucide-react';
|
||||
import {
|
||||
Film,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Users,
|
||||
Heart,
|
||||
FolderKanban,
|
||||
Database,
|
||||
Sparkles,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import Loading from '@/components/ui/loading';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DashboardViewProps {
|
||||
mediaList: Media[];
|
||||
@@ -12,253 +25,214 @@ interface DashboardViewProps {
|
||||
}
|
||||
|
||||
export default function DashboardView({ mediaList, onMediaClick, loading = false }: DashboardViewProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const totalMedia = mediaList.length;
|
||||
const categories = mediaList.reduce((acc, media) => {
|
||||
acc[media.category] = (acc[media.category] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<MediaCategory, number>);
|
||||
|
||||
const totalRating = mediaList.reduce((sum, media) => sum + (media.rating || 0), 0);
|
||||
const avgRating = totalRating > 0 ? (totalRating / mediaList.filter(m => m.rating).length).toFixed(1) : '0.0';
|
||||
|
||||
const totalPlaytime = mediaList.reduce((sum, media) => sum + (media.playtime || 0), 0);
|
||||
const totalPlayCount = mediaList.reduce((sum, media) => sum + (media.playCount || 0), 0);
|
||||
const favoritesCount = mediaList.filter(m => m.rating && m.rating >= 8).length;
|
||||
|
||||
return {
|
||||
totalMedia,
|
||||
categories,
|
||||
avgRating,
|
||||
totalPlaytime,
|
||||
totalPlayCount
|
||||
movies: categories['Movies'] || 0,
|
||||
series: categories['TV Series'] || 0,
|
||||
games: categories['Games'] || 0,
|
||||
adult: categories['Adult'] || 0,
|
||||
actors: new Set(mediaList.flatMap(m => m.staff?.map(s => s.id) || [])).size,
|
||||
collections: 3, // Placeholder
|
||||
favorites: favoritesCount
|
||||
};
|
||||
}, [mediaList]);
|
||||
|
||||
// Get recently added media (sorted by some indicator - using index as proxy)
|
||||
// Get recently added media
|
||||
const recentMedia = useMemo(() => {
|
||||
return [...mediaList].slice(0, 8);
|
||||
return [...mediaList].slice(0, 10);
|
||||
}, [mediaList]);
|
||||
|
||||
// Get top rated media
|
||||
const topRatedMedia = useMemo(() => {
|
||||
// Get favorites
|
||||
const favoritesMedia = useMemo(() => {
|
||||
return [...mediaList]
|
||||
.filter(m => m.rating && m.rating > 0)
|
||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
||||
.filter(m => m.rating && m.rating >= 8)
|
||||
.slice(0, 8);
|
||||
}, [mediaList]);
|
||||
|
||||
// Get most played media
|
||||
const mostPlayedMedia = useMemo(() => {
|
||||
return [...mediaList]
|
||||
.filter(m => m.playCount && m.playCount > 0)
|
||||
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
|
||||
.slice(0, 8);
|
||||
}, [mediaList]);
|
||||
|
||||
// Category icons mapping
|
||||
const categoryIcons: Record<MediaCategory, any> = {
|
||||
'Anime': Tv,
|
||||
'Movies': Film,
|
||||
'TV Series': Tv,
|
||||
'Music': Music,
|
||||
'Books': Book,
|
||||
'Games': Gamepad2,
|
||||
'Consoles': Gamepad2,
|
||||
'Adult': Users
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<MediaCategory, string> = {
|
||||
'Anime': 'bg-purple-500/10 text-purple-500 border-purple-500/20',
|
||||
'Movies': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
'TV Series': 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||
'Music': 'bg-pink-500/10 text-pink-500 border-pink-500/20',
|
||||
'Books': 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||
'Games': 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
'Consoles': 'bg-orange-500/10 text-orange-500 border-orange-500/20',
|
||||
'Adult': 'bg-gray-500/10 text-gray-500 border-gray-500/20'
|
||||
};
|
||||
|
||||
const formatPlaytime = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
// Category card config
|
||||
const categoryCards = [
|
||||
{
|
||||
key: 'movies',
|
||||
label: 'MOVIES',
|
||||
count: stats.movies,
|
||||
icon: Film,
|
||||
color: 'from-blue-500/20 to-blue-600/10',
|
||||
iconBg: 'bg-blue-500/20',
|
||||
path: '/movies'
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
label: 'SERIES',
|
||||
count: stats.series,
|
||||
icon: Tv,
|
||||
color: 'from-green-500/20 to-green-600/10',
|
||||
iconBg: 'bg-green-500/20',
|
||||
path: '/tv-series'
|
||||
},
|
||||
{
|
||||
key: 'games',
|
||||
label: 'GAMES',
|
||||
count: stats.games,
|
||||
icon: Gamepad2,
|
||||
color: 'from-purple-500/20 to-purple-600/10',
|
||||
iconBg: 'bg-purple-500/20',
|
||||
path: '/games'
|
||||
},
|
||||
{
|
||||
key: 'adult',
|
||||
label: 'ADULT',
|
||||
count: stats.adult,
|
||||
icon: Eye,
|
||||
color: 'from-rose-500/20 to-rose-600/10',
|
||||
iconBg: 'bg-rose-500/20',
|
||||
path: '/adult'
|
||||
},
|
||||
{
|
||||
key: 'actors',
|
||||
label: 'ACTORS',
|
||||
count: stats.actors,
|
||||
icon: Users,
|
||||
color: 'from-amber-500/20 to-amber-600/10',
|
||||
iconBg: 'bg-amber-500/20',
|
||||
path: '/cast'
|
||||
},
|
||||
{
|
||||
key: 'collections',
|
||||
label: 'COLLECTIONS',
|
||||
count: stats.collections,
|
||||
icon: FolderKanban,
|
||||
color: 'from-cyan-500/20 to-cyan-600/10',
|
||||
iconBg: 'bg-cyan-500/20',
|
||||
path: '/collections'
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="Loading dashboard..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-5xl font-black text-foreground mb-3 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-medium text-lg">Overview of your media collection</p>
|
||||
</div>
|
||||
<div className="pt-6 pb-12 px-6 max-w-[1920px] mx-auto">
|
||||
{/* Welcome Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Welcome to MediaVault
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm ml-11">Your media library at a glance</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-[#6d28d9]/10 to-[#8b5cf6]/5 border border-[#6d28d9]/20 hover:border-[#6d28d9]/40 transition-all duration-300 hover:shadow-lg hover:shadow-[#6d28d9]/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[#6d28d9]/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Hash className="w-10 h-10 text-[#6d28d9]" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{stats.totalMedia}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Media Items</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-yellow-500/10 to-amber-500/5 border border-yellow-500/20 hover:border-yellow-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-yellow-500/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-yellow-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Star className="w-10 h-10 text-yellow-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Average</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{stats.avgRating}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Rating</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-8"
|
||||
>
|
||||
{categoryCards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={card.key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
onClick={() => navigate(card.path)}
|
||||
className={`relative overflow-hidden rounded-xl p-5 bg-gradient-to-br ${card.color} border border-white/5 hover:border-white/10 transition-all duration-300 cursor-pointer group`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">{card.label}</p>
|
||||
<p className="text-3xl font-bold text-white">{card.count}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-10 rounded-lg ${card.iconBg} flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Favorites Section */}
|
||||
{favoritesMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-green-500/10 to-emerald-500/5 border border-green-500/20 hover:border-green-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-green-500/10"
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-green-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Play className="w-10 h-10 text-green-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{stats.totalPlayCount}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Play Count</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br from-blue-500/10 to-cyan-500/5 border border-blue-500/20 hover:border-blue-500/40 transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Clock className="w-10 h-10 text-blue-500" />
|
||||
<span className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Total</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-foreground">{formatPlaytime(stats.totalPlaytime)}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium mt-1">Playtime</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-muted/50 to-muted/30 border border-border mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<TrendingUp className="w-6 h-6 text-[#6d28d9]" />
|
||||
Category Breakdown
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
{(Object.keys(stats.categories) as MediaCategory[]).map((category) => {
|
||||
const Icon = categoryIcons[category];
|
||||
const count = stats.categories[category] || 0;
|
||||
const percentage = stats.totalMedia > 0 ? ((count / stats.totalMedia) * 100).toFixed(1) : '0';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className={`rounded-xl p-5 border backdrop-blur-sm transition-all duration-300 hover:scale-105 hover:shadow-lg ${categoryColors[category]} flex flex-col items-center justify-center gap-2`}
|
||||
>
|
||||
<Icon className="w-7 h-7" />
|
||||
<div className="text-xs font-bold uppercase tracking-wider">{category}</div>
|
||||
<div className="text-3xl font-black">{count}</div>
|
||||
<div className="text-xs font-medium opacity-75">{percentage}%</div>
|
||||
<div
|
||||
onClick={() => navigate('/browse?favorites=true')}
|
||||
className="relative overflow-hidden rounded-xl p-6 bg-gradient-to-r from-[#e8466c]/10 to-[#f47298]/5 border border-[#e8466c]/20 hover:border-[#e8466c]/30 transition-all duration-300 cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#e8466c]/20 flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-[#e8466c]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#e8466c] uppercase tracking-wider">FAVORITES</p>
|
||||
<p className="text-2xl font-bold text-white">{favoritesMedia.length} <span className="text-sm font-normal text-gray-400">items in your favorites</span></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex items-center gap-2 text-gray-400 group-hover:text-white transition-colors">
|
||||
<span className="text-sm font-medium">View Favorites</span>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Recent Media */}
|
||||
{/* Recently Added Section */}
|
||||
{recentMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="mb-10"
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<Clock className="w-6 h-6 text-[#6d28d9]" />
|
||||
Recent Additions
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-[#e8466c]" />
|
||||
<h2 className="text-sm font-bold text-white uppercase tracking-wider">Recently Added</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/browse?sort=recent')}
|
||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
View All <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-4">
|
||||
{recentMedia.map((media) => (
|
||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Top Rated Media */}
|
||||
{topRatedMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<Award className="w-6 h-6 text-[#6d28d9]" />
|
||||
Top Rated
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
||||
{topRatedMedia.map((media) => (
|
||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Most Played Media */}
|
||||
{mostPlayedMedia.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="mb-10"
|
||||
>
|
||||
<h2 className="text-2xl font-black text-foreground mb-6 flex items-center gap-3">
|
||||
<Play className="w-6 h-6 text-[#6d28d9]" />
|
||||
Most Played
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-6">
|
||||
{mostPlayedMedia.map((media) => (
|
||||
<MediaCard key={media.id} media={media} onClick={onMediaClick} />
|
||||
<MediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onClick={onMediaClick}
|
||||
showBadge={true}
|
||||
showFavorite={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -266,11 +240,11 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
||||
|
||||
{/* Empty State */}
|
||||
{mediaList.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-muted-foreground">
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-2xl flex items-center justify-center mb-6 backdrop-blur-sm border border-border/50">
|
||||
<Hash size={40} />
|
||||
<div className="flex flex-col items-center justify-center py-32 text-gray-400">
|
||||
<div className="w-20 h-20 bg-white/5 rounded-2xl flex items-center justify-center mb-6 border border-white/10">
|
||||
<Database className="w-10 h-10" />
|
||||
</div>
|
||||
<p className="text-xl font-bold">No media found</p>
|
||||
<p className="text-xl font-bold text-white">No media found</p>
|
||||
<p className="text-sm">Start by adding media to your collection</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+377
-137
@@ -1,9 +1,21 @@
|
||||
import { Media, Staff } from '@/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, Calendar, Clock } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ArrowLeft, Calendar, Clock, Play, Star, Users, Disc, Layers,
|
||||
Tv, BookOpen, Gamepad2, Film, Music, Package, Heart, Bookmark,
|
||||
MoreHorizontal, Share2, ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import OverviewTab from './details/tabs/OverviewTab';
|
||||
import CastTab from './details/tabs/CastTab';
|
||||
import SeasonsTab from './details/tabs/SeasonsTab';
|
||||
@@ -16,164 +28,392 @@ interface DetailViewProps {
|
||||
onPersonClick: (person: Staff) => void;
|
||||
}
|
||||
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
'Anime': <Tv className="w-4 h-4" />,
|
||||
'Movies': <Film className="w-4 h-4" />,
|
||||
'TV Series': <Tv className="w-4 h-4" />,
|
||||
'Music': <Music className="w-4 h-4" />,
|
||||
'Books': <BookOpen className="w-4 h-4" />,
|
||||
'Games': <Gamepad2 className="w-4 h-4" />,
|
||||
'Consoles': <Package className="w-4 h-4" />,
|
||||
'Adult': <Film className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'watching': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'reading': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'listening': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'playing': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'completed': 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
'planned': 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
'dropped': 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
'on-hold': 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
export default function DetailView({ media, allMedia, onPersonClick }: DetailViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const [progress, setProgress] = useState(70.8);
|
||||
const [progress] = useState(media.playCount ? Math.min(100, (media.playCount * 10)) : 0);
|
||||
|
||||
const hasEpisodes = media.episodes && media.episodes.length > 0;
|
||||
const hasTracks = media.tracks && media.tracks.length > 0;
|
||||
const hasCast = media.staff && media.staff.length > 0;
|
||||
const hasFranchise = media.category === 'Games' && media.series && media.series.length > 0;
|
||||
const tabs = [
|
||||
'Overview',
|
||||
...(hasCast ? ['Cast'] : []),
|
||||
'Actions',
|
||||
'History',
|
||||
...(hasEpisodes ? ['Seasons'] : []),
|
||||
...(hasTracks ? ['Tracks'] : []),
|
||||
...(hasFranchise ? ['Series'] : []),
|
||||
'Reviews',
|
||||
'Suggestions',
|
||||
'Watch On'
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState(tabs[0]);
|
||||
// Determine default tab based on available content
|
||||
const getDefaultTab = () => {
|
||||
if (hasEpisodes) return 'seasons';
|
||||
if (hasTracks) return 'tracks';
|
||||
if (hasCast) return 'cast';
|
||||
return 'overview';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getDefaultTab());
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: BookOpen, hidden: false },
|
||||
{ id: 'cast', label: 'Cast', icon: Users, hidden: !hasCast },
|
||||
{ id: 'seasons', label: 'Seasons', icon: Layers, hidden: !hasEpisodes },
|
||||
{ id: 'tracks', label: 'Tracks', icon: Disc, hidden: !hasTracks },
|
||||
{ id: 'series', label: 'Series', icon: Gamepad2, hidden: !hasFranchise },
|
||||
].filter(tab => !tab.hidden);
|
||||
|
||||
const statusBadgeClass = media.status ? statusColors[media.status] : 'bg-muted text-muted-foreground border-border';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Banner */}
|
||||
<div className="relative h-[450px] w-full overflow-hidden">
|
||||
<img
|
||||
src={media.banner || media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/50 to-transparent" />
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background pb-16">
|
||||
{/* Hero Section - Full height from top behind transparent navbar */}
|
||||
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
src={media.banner || media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover opacity-40 blur-sm scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-24 left-6 p-3 bg-black/30 hover:bg-black/50 backdrop-blur-md text-white rounded-2xl transition-all duration-300 hover:scale-110 z-10 border border-white/20 lg:left-80"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Back Button - z-50 to ensure clickable */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute top-4 left-4 sm:left-6 z-50 bg-black/30 hover:bg-black/50 text-white rounded-xl backdrop-blur-md transition-all duration-300 hover:scale-105 border border-white/20 h-10 w-10"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[1920px] mx-auto px-6 py-8 pb-24 -mt-32 relative z-10">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Left Column: Cover Image */}
|
||||
<div className="w-full lg:w-[400px] shrink-0">
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className={`rounded-2xl overflow-hidden shadow-2xl bg-card border border-border/50 ${
|
||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||
'aspect-[2/3]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Quick Actions - z-50 to ensure clickable */}
|
||||
<div className="absolute top-4 right-4 sm:right-6 z-50 flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add to favorites</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bookmark</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Share</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-white/10 hover:bg-white/30 text-white rounded-xl backdrop-blur-md border border-white/20 h-10 w-10"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More options</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Info */}
|
||||
<div className="flex-1">
|
||||
{/* Header with tags */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<h1 className="text-4xl lg:text-5xl font-black text-foreground">
|
||||
{media.title}
|
||||
</h1>
|
||||
{media.status && (
|
||||
<Badge className={
|
||||
media.status === 'watching' || media.status === 'reading' || media.status === 'listening' || media.status === 'playing'
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30 font-bold'
|
||||
: media.status === 'completed'
|
||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 font-bold'
|
||||
: 'bg-gray-500/20 text-gray-400 border-gray-500/30 font-bold'
|
||||
}>
|
||||
{media.status.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30 font-bold">{media.completionStatus.toUpperCase()}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Hero Content - pt-16 to account for navbar + buttons */}
|
||||
<div className="absolute inset-0 pt-16 flex items-end px-4 sm:px-6 pb-8">
|
||||
<div className="max-w-[1920px] mx-auto w-full flex flex-col md:flex-row items-center md:items-end gap-6">
|
||||
{/* Poster */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Avatar className={`h-40 md:h-48 w-auto rounded-none border-4 border-background shadow-2xl ${
|
||||
media.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
media.aspectRatio === '1/1' ? 'aspect-square' :
|
||||
'aspect-[2/3]'
|
||||
}`}>
|
||||
<AvatarImage
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<AvatarFallback className="rounded-none text-3xl bg-muted">
|
||||
{categoryIcons[media.category] || <Film className="h-12 w-12" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
{/* Show Details */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar size={16} />
|
||||
<span>{media.year}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{media.status ? media.status.charAt(0).toUpperCase() + media.status.slice(1) : 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock size={16} />
|
||||
<span>{media.playtime ? `${media.playtime}h` : '12h 30m'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-foreground">Progress</span>
|
||||
<span className="text-sm font-bold text-[#6d28d9]">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#6d28d9] to-[#8b5cf6] transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-6 border-b border-border/50 pb-4">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-[#6d28d9]/10 text-[#6d28d9]'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
{/* Title & Meta */}
|
||||
<div className="flex-1 text-center md:text-left pb-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mb-3">
|
||||
{categoryIcons[media.category] && (
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">
|
||||
{categoryIcons[media.category]}
|
||||
<span className="ml-1">{media.category}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{media.type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{media.type}
|
||||
</Badge>
|
||||
)}
|
||||
{media.status && (
|
||||
<Badge variant="outline" className={`text-xs font-medium ${statusBadgeClass}`}>
|
||||
{media.status.charAt(0).toUpperCase() + media.status.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20 text-xs font-medium">
|
||||
{media.completionStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-foreground mb-3 tracking-tight">
|
||||
{media.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{media.year}</span>
|
||||
</div>
|
||||
{media.rating && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="w-4 h-4 text-amber-500" />
|
||||
<span>{media.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{media.playtime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{media.playtime}h played</span>
|
||||
</div>
|
||||
)}
|
||||
{hasEpisodes && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tv className="w-4 h-4" />
|
||||
<span>{media.episodes!.length} episodes</span>
|
||||
</div>
|
||||
)}
|
||||
{hasTracks && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Disc className="w-4 h-4" />
|
||||
<span>{media.tracks!.length} tracks</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Primary Action */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Button size="lg" className="rounded-xl px-8 shadow-lg">
|
||||
<Play className="w-5 h-5 mr-2 fill-current" />
|
||||
Play
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 mt-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Left Sidebar - Info Cards */}
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
{/* Progress Card */}
|
||||
{progress > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Progress</span>
|
||||
<span className="text-sm font-bold text-primary">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Studios */}
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Film className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Studios
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.studios.map(studio => (
|
||||
<Badge key={studio} variant="secondary" className="text-xs">
|
||||
{studio}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Platforms (for Games) */}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Gamepad2 className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Platforms
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="text-xs">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Developers (for Games) */}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Developers
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="text-xs">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
{media.source && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<ExternalLink className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Source
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{media.source}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'Overview' && <OverviewTab media={media} />}
|
||||
{/* Main Content - Tabs */}
|
||||
<div className="lg:col-span-3">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-4 w-full justify-start bg-muted/50 p-1 rounded-lg h-auto flex-wrap">
|
||||
{tabItems.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-1.5 text-xs rounded-md px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* Cast Tab */}
|
||||
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
|
||||
<CastTab staff={media.staff} onPersonClick={onPersonClick} />
|
||||
)}
|
||||
{/* Seasons Tab */}
|
||||
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
|
||||
<SeasonsTab episodes={media.episodes} />
|
||||
)}
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<OverviewTab media={media} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tracks Tab */}
|
||||
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
|
||||
<TracksTab tracks={media.tracks} />
|
||||
)}
|
||||
{hasCast && (
|
||||
<TabsContent value="cast" className="mt-0">
|
||||
<CastTab staff={media.staff!} onPersonClick={onPersonClick} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Series Tab */}
|
||||
{media.category === 'Games' && media.series && media.series.length > 0 && activeTab === 'Series' && (
|
||||
<SeriesTab media={media} allMedia={allMedia} onMediaClick={(media) => window.location.href = `/${media.id}`} />
|
||||
)}
|
||||
{hasEpisodes && (
|
||||
<TabsContent value="seasons" className="mt-0">
|
||||
<SeasonsTab episodes={media.episodes!} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasTracks && (
|
||||
<TabsContent value="tracks" className="mt-0">
|
||||
<TracksTab tracks={media.tracks!} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{hasFranchise && (
|
||||
<TabsContent value="series" className="mt-0">
|
||||
<SeriesTab media={media} allMedia={allMedia} onMediaClick={(m) => navigate(`/media/${m.id}`)} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+501
-56
@@ -1,15 +1,78 @@
|
||||
import { Media } from '@/types';
|
||||
import React, { useState } from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Star,
|
||||
Heart,
|
||||
Gamepad2,
|
||||
Film,
|
||||
Tv,
|
||||
Eye,
|
||||
Play,
|
||||
Calendar,
|
||||
Hash,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface MediaCardProps {
|
||||
key?: string;
|
||||
media: Media;
|
||||
onClick: (media: Media) => void;
|
||||
showBadge?: boolean;
|
||||
showFavorite?: boolean;
|
||||
isFavorite?: boolean;
|
||||
onFavoriteToggle?: (media: Media) => void;
|
||||
variant?: 'default' | 'compact' | 'hero' | 'minimal';
|
||||
}
|
||||
|
||||
export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
const categoryConfig: Record<
|
||||
MediaCategory,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive'; icon: React.ElementType | null }
|
||||
> = {
|
||||
Anime: { label: 'ANIME', variant: 'secondary', icon: null },
|
||||
Movies: { label: 'MOVIE', variant: 'secondary', icon: Film },
|
||||
'TV Series': { label: 'SERIES', variant: 'secondary', icon: Tv },
|
||||
Music: { label: 'MUSIC', variant: 'secondary', icon: null },
|
||||
Books: { label: 'BOOK', variant: 'secondary', icon: null },
|
||||
Games: { label: 'GAME', variant: 'secondary', icon: Gamepad2 },
|
||||
Consoles: { label: 'CONSOLE', variant: 'secondary', icon: null },
|
||||
Adult: { label: 'ADULT', variant: 'destructive', icon: Eye },
|
||||
};
|
||||
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{ label: string; color: string; ringColor: string }
|
||||
> = {
|
||||
watching: { label: 'Watching', color: 'bg-blue-500', ringColor: 'ring-blue-500' },
|
||||
completed: { label: 'Completed', color: 'bg-green-500', ringColor: 'ring-green-500' },
|
||||
planned: { label: 'Planned', color: 'bg-gray-500', ringColor: 'ring-gray-500' },
|
||||
dropped: { label: 'Dropped', color: 'bg-red-500', ringColor: 'ring-red-500' },
|
||||
reading: { label: 'Reading', color: 'bg-amber-500', ringColor: 'ring-amber-500' },
|
||||
listening: { label: 'Listening', color: 'bg-purple-500', ringColor: 'ring-purple-500' },
|
||||
playing: { label: 'Playing', color: 'bg-indigo-500', ringColor: 'ring-indigo-500' },
|
||||
'on-hold': { label: 'On Hold', color: 'bg-orange-500', ringColor: 'ring-orange-500' },
|
||||
};
|
||||
|
||||
export default function MediaCard({
|
||||
media,
|
||||
onClick,
|
||||
showBadge = true,
|
||||
showFavorite = true,
|
||||
isFavorite = false,
|
||||
onFavoriteToggle,
|
||||
variant = 'default'
|
||||
}: MediaCardProps) {
|
||||
const statusColors = {
|
||||
watching: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
@@ -44,64 +107,446 @@ export default function MediaCard({ media, onClick }: MediaCardProps) {
|
||||
'1/1': 'aspect-[1/1]',
|
||||
}[getAspectRatio()];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
const categoryInfo = categoryConfig[media.category];
|
||||
const CategoryIcon = categoryInfo?.icon;
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleFavoriteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.(media);
|
||||
};
|
||||
|
||||
const formatPlayCount = (count?: number) => {
|
||||
if (!count) return null;
|
||||
if (count === 1) return '1x played';
|
||||
if (count < 1000) return `${count}x played`;
|
||||
return `${(count / 1000).toFixed(1)}k played`;
|
||||
};
|
||||
|
||||
const renderRating = () => {
|
||||
if (!media.rating) return null;
|
||||
const stars = Math.floor(media.rating / 2);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
size={10}
|
||||
className={cn(
|
||||
i < stars
|
||||
? 'text-primary fill-primary'
|
||||
: 'text-muted-foreground/50'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs font-semibold">{media.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Rating: {media.rating}/10</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategoryBadge = () => {
|
||||
if (!showBadge || !categoryInfo) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant={categoryInfo.variant}
|
||||
className="absolute top-2 right-2 z-20 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider backdrop-blur-sm bg-opacity-90"
|
||||
>
|
||||
{CategoryIcon && <CategoryIcon size={10} />}
|
||||
{categoryInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFavoriteButton = () => {
|
||||
if (!showFavorite) return null;
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: isHovered ? 1 : 0, scale: isHovered ? 1 : 0.8 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-2 left-2 z-20"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleFavoriteClick}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full backdrop-blur-sm transition-all duration-200',
|
||||
isFavorite
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-black/50 text-white hover:bg-black/70 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Heart
|
||||
size={14}
|
||||
className={cn('transition-transform', isFavorite && 'fill-current scale-110')}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusIndicator = () => {
|
||||
if (!media.status) return null;
|
||||
const status = statusConfig[media.status];
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-2 z-20 w-3 h-3 rounded-full border-2 border-background shadow-md',
|
||||
status.color,
|
||||
showFavorite ? 'left-10' : 'left-2'
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Status: {status.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCompactVariant = () => (
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
whileHover={{ y: -8, scale: 1.02 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-2xl overflow-hidden bg-card transition-all duration-500 shadow-lg group-hover:shadow-2xl group-hover:shadow-[#6d28d9]/20",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
{/* Rating Badge */}
|
||||
{media.rating && (
|
||||
<div className="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-500 transform translate-y-[-10px] group-hover:translate-y-0">
|
||||
<Star size={12} className="text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-xs font-bold text-white">{media.rating}</span>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 bg-muted/50 transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'ring-2 ring-primary/20 shadow-xl'
|
||||
)}
|
||||
|
||||
{media.status && (
|
||||
<div className={cn(
|
||||
"absolute top-3 left-3 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-lg z-10",
|
||||
statusColors[media.status]
|
||||
)} />
|
||||
)}
|
||||
|
||||
{/* Glow Effect on Hover */}
|
||||
<div className="absolute inset-0 rounded-2xl ring-2 ring-[#6d28d9]/0 group-hover:ring-[#6d28d9]/50 transition-all duration-500 pointer-events-none" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<h3 className="text-sm font-bold text-foreground line-clamp-2 group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{media.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{media.year}
|
||||
</p>
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground/50">•</span>
|
||||
<p className="text-xs font-medium text-muted-foreground/70 line-clamp-1">
|
||||
{media.genres[0]}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
||||
{renderCategoryBadge()}
|
||||
{renderFavoriteButton()}
|
||||
{renderStatusIndicator()}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2">
|
||||
<h3 className="text-xs font-semibold text-white line-clamp-1">{media.title}</h3>
|
||||
<p className="text-[10px] text-white/60">{media.year}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderMinimalVariant = () => (
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent opacity-0 transition-opacity duration-300',
|
||||
isHovered && 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
{showFavorite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleFavoriteClick}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 h-7 w-7 rounded-full backdrop-blur-sm opacity-0 transition-opacity duration-300',
|
||||
isHovered && 'opacity-100',
|
||||
isFavorite
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-black/50 text-white hover:bg-black/70'
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={cn(isFavorite && 'fill-current')} />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 p-3 translate-y-2 opacity-0 transition-all duration-300',
|
||||
isHovered && 'translate-y-0 opacity-100'
|
||||
)}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-white line-clamp-2">{media.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-white/70">{media.year}</span>
|
||||
{media.rating && (
|
||||
<>
|
||||
<span className="text-[10px] text-white/50">•</span>
|
||||
<span className="text-[10px] text-white/70">{media.rating.toFixed(1)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderDefaultVariant = () => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 bg-card transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'ring-2 ring-primary/30 shadow-2xl'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/40 to-transparent" />
|
||||
|
||||
{renderCategoryBadge()}
|
||||
{renderFavoriteButton()}
|
||||
{renderStatusIndicator()}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 space-y-2">
|
||||
<h3 className="text-sm font-bold text-white line-clamp-2 leading-tight">
|
||||
{media.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">{renderRating()}</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-white/10" />
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 text-white/70">
|
||||
<Calendar size={11} />
|
||||
{media.year}
|
||||
</span>
|
||||
{media.playCount && media.playCount > 0 && (
|
||||
<>
|
||||
<span className="text-white/30">•</span>
|
||||
<span className="flex items-center gap-1 text-white/70">
|
||||
<Play size={11} />
|
||||
{formatPlayCount(media.playCount)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<>
|
||||
<span className="text-white/30">•</span>
|
||||
<span className="truncate max-w-[100px] text-white/50">
|
||||
{media.studios[0]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{media.genres.slice(0, 2).map((genre) => (
|
||||
<Badge key={genre} variant="outline" className="text-[9px] py-0 h-4 border-white/20 text-white/60">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
{media.genres.length > 2 && (
|
||||
<Badge variant="outline" className="text-[9px] py-0 h-4 border-white/20 text-white/60">
|
||||
+{media.genres.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHovered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-primary/5 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{media.title}</p>
|
||||
{media.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{media.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs pt-1">
|
||||
<span>{media.category}</span>
|
||||
{media.year && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{media.year}</span>
|
||||
</>
|
||||
)}
|
||||
{media.rating && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{media.rating}/10</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const renderHeroVariant = () => (
|
||||
<motion.div
|
||||
layoutId={`media-${media.id}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => onClick(media)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-0 bg-card transition-all duration-300',
|
||||
aspectRatioClass,
|
||||
isHovered && 'ring-2 ring-primary/30 shadow-2xl'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-muted">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover object-center"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" />
|
||||
|
||||
{renderCategoryBadge()}
|
||||
{renderFavoriteButton()}
|
||||
{renderStatusIndicator()}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-3">
|
||||
{media.rating && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Trophy size={12} className="mr-1" />
|
||||
{media.rating.toFixed(1)}/10
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-bold text-white line-clamp-2 leading-tight">
|
||||
{media.title}
|
||||
</h3>
|
||||
|
||||
{media.description && (
|
||||
<p className="text-sm text-white/70 line-clamp-2">{media.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1 text-white/80">
|
||||
<Calendar size={14} />
|
||||
{media.year}
|
||||
</span>
|
||||
{media.playCount && media.playCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-white/80">
|
||||
<Play size={14} />
|
||||
{formatPlayCount(media.playCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{media.genres.slice(0, 4).map((genre) => (
|
||||
<Badge key={genre} variant="outline" className="text-xs border-white/20 text-white/70">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHovered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-primary/5 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const renderVariant = () => {
|
||||
switch (variant) {
|
||||
case 'compact':
|
||||
return renderCompactVariant();
|
||||
case 'minimal':
|
||||
return renderMinimalVariant();
|
||||
case 'hero':
|
||||
return renderHeroVariant();
|
||||
default:
|
||||
return renderDefaultVariant();
|
||||
}
|
||||
};
|
||||
|
||||
return renderVariant();
|
||||
}
|
||||
|
||||
@@ -1,101 +1,114 @@
|
||||
import { Media } from '@/types';
|
||||
import React from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import { Star, Play, Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Star, Heart, Gamepad2, Film, Tv, Eye } from 'lucide-react';
|
||||
|
||||
interface MediaListItemProps {
|
||||
key?: string;
|
||||
media: Media;
|
||||
onClick: (media: Media) => void;
|
||||
isFavorite?: boolean;
|
||||
onFavoriteToggle?: (media: Media) => void;
|
||||
}
|
||||
|
||||
export default function MediaListItem({ media, onClick }: MediaListItemProps) {
|
||||
const statusColors = {
|
||||
watching: 'bg-blue-500',
|
||||
completed: 'bg-green-500',
|
||||
planned: 'bg-gray-500',
|
||||
dropped: 'bg-red-500',
|
||||
reading: 'bg-amber-500',
|
||||
listening: 'bg-purple-500',
|
||||
playing: 'bg-indigo-500',
|
||||
'on-hold': 'bg-orange-500',
|
||||
};
|
||||
const categoryConfig: Record<MediaCategory, { label: string; color: string; bgColor: string; icon: any }> = {
|
||||
'Anime': { label: 'ANIME', color: 'text-purple-300', bgColor: 'bg-purple-500/30', icon: null },
|
||||
'Movies': { label: 'MOVIE', color: 'text-blue-300', bgColor: 'bg-blue-500/30', icon: Film },
|
||||
'TV Series': { label: 'SERIES', color: 'text-green-300', bgColor: 'bg-green-500/30', icon: Tv },
|
||||
'Music': { label: 'MUSIC', color: 'text-pink-300', bgColor: 'bg-pink-500/30', icon: null },
|
||||
'Books': { label: 'BOOK', color: 'text-yellow-300', bgColor: 'bg-yellow-500/30', icon: null },
|
||||
'Games': { label: 'GAME', color: 'text-indigo-300', bgColor: 'bg-indigo-500/30', icon: Gamepad2 },
|
||||
'Consoles': { label: 'CONSOLE', color: 'text-orange-300', bgColor: 'bg-orange-500/30', icon: null },
|
||||
'Adult': { label: 'ADULT', color: 'text-rose-300', bgColor: 'bg-rose-500/30', icon: Eye },
|
||||
};
|
||||
|
||||
const getAspectRatio = () => {
|
||||
if (media.aspectRatio) return media.aspectRatio;
|
||||
switch (media.category) {
|
||||
case 'Music': return '1/1';
|
||||
case 'Games':
|
||||
case 'Adult': return '16/9';
|
||||
default: return '2/3';
|
||||
}
|
||||
};
|
||||
export default function MediaListItem({ media, onClick, isFavorite = false, onFavoriteToggle }: MediaListItemProps) {
|
||||
const categoryInfo = categoryConfig[media.category];
|
||||
const CategoryIcon = categoryInfo?.icon;
|
||||
|
||||
const aspectRatioClass = {
|
||||
'2/3': 'w-24 h-32',
|
||||
'16/9': 'w-48 h-27', // 16:9 ratio for w-48 is approx h-27
|
||||
'1/1': 'w-24 h-24',
|
||||
}[getAspectRatio()];
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.(media);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="group flex items-center gap-6 p-5 rounded-xl hover:bg-muted/50 transition-all duration-300 cursor-pointer border border-border/50 hover:border-[#6d28d9]/30 hover:shadow-lg hover:shadow-[#6d28d9]/10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="group flex items-center px-4 py-2 hover:bg-white/[0.02] transition-colors cursor-pointer border-b border-white/[0.02] last:border-b-0"
|
||||
onClick={() => onClick(media)}
|
||||
>
|
||||
<div className={cn(
|
||||
"relative rounded-xl overflow-hidden shrink-0 shadow-md bg-card transition-all duration-300 group-hover:scale-105 border border-border/30",
|
||||
aspectRatioClass
|
||||
)}>
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
|
||||
{media.status && (
|
||||
<div className={cn(
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full border border-white/20 shadow-sm",
|
||||
statusColors[media.status]
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-black text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{media.title}
|
||||
</h3>
|
||||
<span className="text-sm font-bold text-muted-foreground">({media.year})</span>
|
||||
{/* TITLE Column: Poster + Title + Rating (like screenshot 2) */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3 mr-4">
|
||||
{/* Poster Thumbnail */}
|
||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-[#1a1d26]">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-muted-foreground">
|
||||
<Star size={14} className="text-yellow-500" fill="currentColor" />
|
||||
{media.rating || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{media.genres?.slice(0, 3).join(' • ') || 'Anime'}
|
||||
{/* Title + Rating stacked */}
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-200 truncate group-hover:text-[#e8466c] transition-colors">
|
||||
{media.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Star size={10} className="text-[#e8466c] fill-[#e8466c]" />
|
||||
<span className="text-xs font-medium text-[#e8466c]">
|
||||
{media.rating?.toFixed(1) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 max-w-2xl">
|
||||
{media.description || "No description available for this title."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
|
||||
<Play size={18} fill="currentColor" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="rounded-xl text-muted-foreground hover:text-[#6d28d9] hover:bg-[#6d28d9]/10 transition-all duration-300">
|
||||
<Bookmark size={18} />
|
||||
</Button>
|
||||
{/* TYPE Column */}
|
||||
<div className="w-[70px] shrink-0 mr-4">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
||||
categoryInfo.bgColor,
|
||||
categoryInfo.color
|
||||
)}>
|
||||
{CategoryIcon && <CategoryIcon size={9} />}
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* GENRE Column */}
|
||||
<div className="w-[140px] shrink-0 mr-4">
|
||||
<span className="text-sm text-gray-500 truncate block">
|
||||
{media.genres?.slice(0, 2).join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* YEAR Column */}
|
||||
<div className="w-[60px] shrink-0 text-center mr-4">
|
||||
<span className="text-sm text-gray-400">{media.year}</span>
|
||||
</div>
|
||||
|
||||
{/* PLAYS Column */}
|
||||
<div className="w-[50px] shrink-0 text-right mr-4">
|
||||
<span className="text-sm text-gray-400">{media.playCount || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* FAVORITE Column (Heart) */}
|
||||
<div className="w-8 shrink-0 flex justify-end">
|
||||
<button
|
||||
onClick={handleFavoriteClick}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
isFavorite
|
||||
? "text-[#e8466c]"
|
||||
: "text-gray-600 hover:text-gray-500"
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Star,
|
||||
Heart,
|
||||
Gamepad2,
|
||||
Film,
|
||||
Tv,
|
||||
Eye,
|
||||
Music,
|
||||
BookOpen,
|
||||
Monitor,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface MediaTableProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
onFavoriteToggle?: (media: Media) => void;
|
||||
favoriteIds?: Set<string>;
|
||||
}
|
||||
|
||||
type SortField = 'title' | 'category' | 'genre' | 'rating' | 'year' | 'plays';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const categoryConfig: Record<MediaCategory, {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
icon: React.ElementType | null;
|
||||
}> = {
|
||||
'Anime': { label: 'ANIME', color: 'text-purple-300', bgColor: 'bg-purple-500/30', icon: null },
|
||||
'Movies': { label: 'MOVIE', color: 'text-blue-300', bgColor: 'bg-blue-500/30', icon: Film },
|
||||
'TV Series': { label: 'SERIES', color: 'text-green-300', bgColor: 'bg-green-500/30', icon: Tv },
|
||||
'Music': { label: 'MUSIC', color: 'text-pink-300', bgColor: 'bg-pink-500/30', icon: Music },
|
||||
'Books': { label: 'BOOK', color: 'text-yellow-300', bgColor: 'bg-yellow-500/30', icon: BookOpen },
|
||||
'Games': { label: 'GAME', color: 'text-indigo-300', bgColor: 'bg-indigo-500/30', icon: Gamepad2 },
|
||||
'Consoles': { label: 'CONSOLE', color: 'text-orange-300', bgColor: 'bg-orange-500/30', icon: Monitor },
|
||||
'Adult': { label: 'ADULT', color: 'text-rose-300', bgColor: 'bg-rose-500/30', icon: Eye },
|
||||
};
|
||||
|
||||
export default function MediaTable({
|
||||
mediaList,
|
||||
onMediaClick,
|
||||
onFavoriteToggle,
|
||||
favoriteIds = new Set()
|
||||
}: MediaTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField>('title');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedMedia = useMemo(() => {
|
||||
const sorted = [...mediaList];
|
||||
sorted.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'title':
|
||||
comparison = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case 'category':
|
||||
comparison = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'genre':
|
||||
const genreA = a.genres?.[0] || '';
|
||||
const genreB = b.genres?.[0] || '';
|
||||
comparison = genreA.localeCompare(genreB);
|
||||
break;
|
||||
case 'rating':
|
||||
comparison = (b.rating || 0) - (a.rating || 0);
|
||||
break;
|
||||
case 'year':
|
||||
comparison = b.year.localeCompare(a.year);
|
||||
break;
|
||||
case 'plays':
|
||||
comparison = (b.playCount || 0) - (a.playCount || 0);
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
return sorted;
|
||||
}, [mediaList, sortField, sortDirection]);
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) {
|
||||
return <ArrowUpDown size={14} className="text-gray-600 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />;
|
||||
}
|
||||
return sortDirection === 'asc'
|
||||
? <ArrowUp size={14} className="text-[#e8466c] ml-1" />
|
||||
: <ArrowDown size={14} className="text-[#e8466c] ml-1" />;
|
||||
};
|
||||
|
||||
const handleFavoriteClick = (e: React.MouseEvent, media: Media) => {
|
||||
e.stopPropagation();
|
||||
onFavoriteToggle?.(media);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bg-[#0d0f14] rounded-lg border border-white/5 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-white/[0.03] hover:bg-transparent">
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[45%]"
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Title <SortIcon field="title" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[80px]"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Type <SortIcon field="category" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[18%]"
|
||||
onClick={() => handleSort('genre')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Genre <SortIcon field="genre" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[70px] text-center"
|
||||
onClick={() => handleSort('rating')}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Rating <SortIcon field="rating" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[60px] text-center"
|
||||
onClick={() => handleSort('year')}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
Year <SortIcon field="year" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[60px] text-right"
|
||||
onClick={() => handleSort('plays')}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
Plays <SortIcon field="plays" />
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedMedia.map((media) => {
|
||||
const categoryInfo = categoryConfig[media.category];
|
||||
const CategoryIcon = categoryInfo?.icon;
|
||||
const isFavorite = favoriteIds.has(media.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={media.id}
|
||||
className="border-b border-white/[0.02] hover:bg-white/[0.02] transition-colors cursor-pointer group"
|
||||
onClick={() => onMediaClick(media)}
|
||||
>
|
||||
{/* Title Cell with Poster */}
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-[#1a1d26]">
|
||||
<img
|
||||
src={media.poster}
|
||||
alt={media.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-200 truncate group-hover:text-[#e8466c] transition-colors">
|
||||
{media.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Type Badge */}
|
||||
<TableCell>
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
||||
categoryInfo.bgColor,
|
||||
categoryInfo.color
|
||||
)}>
|
||||
{CategoryIcon && <CategoryIcon size={9} />}
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Genre */}
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-500 truncate block">
|
||||
{media.genres?.join(', ') || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Rating */}
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
|
||||
<span className="text-sm font-medium text-gray-300">
|
||||
{media.rating?.toFixed(1) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Year */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-sm text-gray-400">{media.year}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Plays */}
|
||||
<TableCell className="text-right">
|
||||
<span className="text-sm text-gray-400">{media.playCount || 0}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Favorite */}
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={(e) => handleFavoriteClick(e, media)}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
isFavorite
|
||||
? "text-[#e8466c]"
|
||||
: "text-gray-600 hover:text-gray-500"
|
||||
)}
|
||||
>
|
||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+336
-135
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Staff } from '@/types';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Users, ChevronDown, ChevronUp, User } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface CastTabProps {
|
||||
staff: Staff[];
|
||||
@@ -7,40 +13,93 @@ interface CastTabProps {
|
||||
}
|
||||
|
||||
export default function CastTab({ staff, onPersonClick }: CastTabProps) {
|
||||
const [castLimit, setCastLimit] = useState(6);
|
||||
const [showAllCast, setShowAllCast] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayLimit = 8;
|
||||
|
||||
const displayedCast = showAllCast ? staff : (staff?.slice(0, castLimit) || []);
|
||||
const hasMoreCast = (staff?.length || 0) > castLimit;
|
||||
const displayedCast = showAll ? staff : staff.slice(0, displayLimit);
|
||||
const hasMore = staff.length > displayLimit;
|
||||
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
{displayedCast.map(person => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
|
||||
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
))}
|
||||
{hasMoreCast && (
|
||||
<button
|
||||
onClick={() => setShowAllCast(!showAllCast)}
|
||||
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
<span className="font-bold text-[#6d28d9]">
|
||||
{showAllCast ? 'Show Less' : `+${staff!.length - castLimit} more`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Cast & Crew
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{staff.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cast Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{displayedCast.map((person, index) => (
|
||||
<motion.div
|
||||
key={person.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Card
|
||||
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60"
|
||||
onClick={() => onPersonClick(person)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-14 w-10 rounded-lg border border-border/30">
|
||||
<AvatarImage
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
className="object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{person.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{person.characterName || person.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Show More/Less Button */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="gap-2 rounded-lg"
|
||||
>
|
||||
{showAll ? (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
Show Less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
Show {staff.length - displayLimit} More
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Media } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BookOpen, Tag } from 'lucide-react';
|
||||
|
||||
interface OverviewTabProps {
|
||||
media: Media;
|
||||
@@ -7,21 +9,84 @@ interface OverviewTabProps {
|
||||
|
||||
export default function OverviewTab({ media }: OverviewTabProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Genre Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{media.genres?.map(genre => (
|
||||
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* Genres */}
|
||||
{media.genres && media.genres.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Tag className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Genres
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{media.genres.map(genre => (
|
||||
<Badge
|
||||
key={genre}
|
||||
variant="secondary"
|
||||
className="text-xs px-3 py-1 bg-primary/5 text-primary border-primary/20 hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
{genre}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{media.tags && media.tags.length > 0 && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Tag className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Tags
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{media.tags.map(tag => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-xs px-3 py-1 border-border/50 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: media.description || '' }}
|
||||
/>
|
||||
</>
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardHeader className="py-3 px-4 border-b border-border/40">
|
||||
<CardTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-primary/10 flex items-center justify-center">
|
||||
<BookOpen className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
Synopsis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{media.description ? (
|
||||
<div
|
||||
className="text-foreground leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: media.description }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm italic">
|
||||
No description available.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Episode } from '@/types';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Search, MoreHorizontal, ListFilter, ChevronDown } from 'lucide-react';
|
||||
import { Search, Play, Clock, Calendar, ChevronDown, Tv } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
interface SeasonsTabProps {
|
||||
episodes: Episode[];
|
||||
@@ -51,82 +52,133 @@ export default function SeasonsTab({ episodes }: SeasonsTabProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-20">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
|
||||
<span className="opacity-40">{episodes.length}</span> Episode{episodes.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-muted-foreground">
|
||||
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Tv className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Episodes
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{episodes.length}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search episodes..."
|
||||
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Seasons */}
|
||||
<div className="space-y-3">
|
||||
{Object.keys(episodesBySeason)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(season => (
|
||||
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => toggleSeason(season)}
|
||||
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
|
||||
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
|
||||
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
className={`transition-transform duration-300 text-muted-foreground ${
|
||||
expandedSeasons.has(season) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expandedSeasons.has(season) && (
|
||||
<div className="p-6 pt-0 space-y-6">
|
||||
{episodesBySeason[season].map(episode => (
|
||||
<div key={episode.id} className="group cursor-pointer">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-2xl overflow-hidden shadow-sm relative border border-border/30">
|
||||
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
|
||||
</div>
|
||||
<div className="flex-1 py-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
E{episode.episode_number} • {episode.title}
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-muted-foreground">{episode.air_date} • {episode.duration}m</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{episode.description}
|
||||
</p>
|
||||
</div>
|
||||
<Collapsible
|
||||
key={season}
|
||||
open={expandedSeasons.has(season)}
|
||||
onOpenChange={() => toggleSeason(season)}
|
||||
>
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="py-3 px-4 cursor-pointer hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-foreground">Season {season}</h3>
|
||||
<Badge variant="outline" className="text-xs border-primary/30 text-primary">
|
||||
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator className="mt-6 bg-border/50" />
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${
|
||||
expandedSeasons.has(season) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border/50">
|
||||
{episodesBySeason[season].map((episode, index) => (
|
||||
<div
|
||||
key={episode.id}
|
||||
className="group p-4 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Thumbnail */}
|
||||
<div className="w-full sm:w-[160px] shrink-0 aspect-video rounded-lg overflow-hidden relative bg-muted border border-border/30">
|
||||
{episode.thumbnail ? (
|
||||
<img
|
||||
src={episode.thumbnail}
|
||||
alt={episode.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/90 text-primary-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Episode {episode.episode_number}
|
||||
</p>
|
||||
<h4 className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
|
||||
{episode.title}
|
||||
</h4>
|
||||
{episode.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{episode.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||
{episode.duration > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{episode.duration}m</span>
|
||||
</div>
|
||||
)}
|
||||
{episode.air_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{episode.air_date}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Media } from '@/types';
|
||||
import MediaCard from '../../MediaCard';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Gamepad2, Layers } from 'lucide-react';
|
||||
|
||||
interface SeriesTabProps {
|
||||
media: Media;
|
||||
@@ -20,35 +22,85 @@ export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabPr
|
||||
|
||||
if (seriesGames.length === 0) {
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Series</h2>
|
||||
<p className="text-muted-foreground">No other games found in the same series.</p>
|
||||
</section>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Series
|
||||
</h2>
|
||||
</div>
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Gamepad2 className="w-12 h-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No other games found in the same series.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="text-2xl font-black text-foreground mb-6">Series</h2>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{media.series?.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="px-3 py-1 bg-[#6d28d9]/10 text-[#6d28d9] border border-[#6d28d9]/30 rounded-full text-sm font-bold"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Series
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{seriesGames.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{media.series?.map((s) => (
|
||||
<Badge
|
||||
key={s}
|
||||
variant="outline"
|
||||
className="text-xs border-primary/30 text-primary"
|
||||
>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
|
||||
{/* Games Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{seriesGames.map((game) => (
|
||||
<MediaCard
|
||||
<Card
|
||||
key={game.id}
|
||||
media={game}
|
||||
className="group cursor-pointer hover:border-primary/30 hover:shadow-md transition-all duration-200 border-border/60 overflow-hidden"
|
||||
onClick={() => onMediaClick(game)}
|
||||
/>
|
||||
>
|
||||
<div className={`aspect-[2/3] overflow-hidden bg-muted ${
|
||||
game.aspectRatio === '16/9' ? 'aspect-video' :
|
||||
game.aspectRatio === '1/1' ? 'aspect-square' : 'aspect-[2/3]'
|
||||
}`}>
|
||||
<img
|
||||
src={game.poster}
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<p className="font-medium text-sm text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{game.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{game.year}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,84 @@
|
||||
import { Track } from '@/types';
|
||||
import { Search, MoreHorizontal, ListFilter } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, Play, Disc, Clock } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface TracksTabProps {
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
export default function TracksTab({ tracks }: TracksTabProps) {
|
||||
const formatDuration = (seconds: number | null) => {
|
||||
if (!seconds) return '—';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-20">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
|
||||
<span className="opacity-40">{tracks.length}</span> Track{tracks.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
|
||||
<div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Disc className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<MoreHorizontal size={20} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
|
||||
<ListFilter size={20} />
|
||||
</Button>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Tracks
|
||||
</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tracks.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search tracks..."
|
||||
className="pl-9 w-full sm:w-[200px] bg-muted/50 border-none rounded-lg h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{tracks.map(track => (
|
||||
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
|
||||
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
|
||||
{track.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{track.artist}</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
|
||||
{/* Tracks List */}
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border/50">
|
||||
{tracks.map((track, index) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center gap-4 p-3 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Track Number / Play Button */}
|
||||
<div className="w-8 text-center">
|
||||
<span className="text-sm text-muted-foreground group-hover:hidden">
|
||||
{track.track_number}
|
||||
</span>
|
||||
<div className="hidden group-hover:flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-primary fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
|
||||
{track.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{track.artist}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatDuration(track.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import React from 'react';
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Star,
|
||||
Building2,
|
||||
Monitor,
|
||||
Users,
|
||||
FolderTree,
|
||||
Database,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface MediaFiltersProps {
|
||||
mediaList: Media[];
|
||||
activeCategory: MediaCategory;
|
||||
selectedGenre: string | null;
|
||||
selectedStudio: string | null;
|
||||
selectedPlatform: string | null;
|
||||
selectedDeveloper: string | null;
|
||||
selectedCategory: string | null;
|
||||
selectedSource: string | null;
|
||||
onGenreChange: (value: string | null) => void;
|
||||
onStudioChange: (value: string | null) => void;
|
||||
onPlatformChange: (value: string | null) => void;
|
||||
onDeveloperChange: (value: string | null) => void;
|
||||
onCategoryChange: (value: string | null) => void;
|
||||
onSourceChange: (value: string | null) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
export default function MediaFilters({
|
||||
mediaList,
|
||||
activeCategory,
|
||||
selectedGenre,
|
||||
selectedStudio,
|
||||
selectedPlatform,
|
||||
selectedDeveloper,
|
||||
selectedCategory,
|
||||
selectedSource,
|
||||
onGenreChange,
|
||||
onStudioChange,
|
||||
onPlatformChange,
|
||||
onDeveloperChange,
|
||||
onCategoryChange,
|
||||
onSourceChange,
|
||||
onClearAll
|
||||
}: MediaFiltersProps) {
|
||||
// Extract unique filter values
|
||||
const genres = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.genres || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const studios = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.studios || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const platforms = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.platforms || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const developers = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.developers || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const categories = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.flatMap(m => m.series || []))).sort(),
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const sources = React.useMemo(() =>
|
||||
Array.from(new Set(mediaList.map(m => m.source).filter(Boolean))).sort() as string[],
|
||||
[mediaList]
|
||||
);
|
||||
|
||||
const hasActiveFilters = selectedGenre || selectedStudio || selectedPlatform ||
|
||||
selectedDeveloper || selectedCategory || selectedSource;
|
||||
|
||||
// Get available filters based on category
|
||||
const getAvailableFilters = () => {
|
||||
const baseFilters = ['genre'];
|
||||
|
||||
switch (activeCategory) {
|
||||
case 'Movies':
|
||||
case 'TV Series':
|
||||
return [...baseFilters, 'studio'];
|
||||
case 'Games':
|
||||
return [...baseFilters, 'platform', 'developer', 'category'];
|
||||
case 'Adult':
|
||||
return [...baseFilters, 'studio'];
|
||||
default:
|
||||
return baseFilters;
|
||||
}
|
||||
};
|
||||
|
||||
const availableFilters = getAvailableFilters();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Genre Filter - Always available */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedGenre
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Star size={14} className="mr-2" />
|
||||
{selectedGenre || 'Genres'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
||||
Filter by Genre
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onGenreChange(null)}>
|
||||
All Genres
|
||||
</DropdownMenuItem>
|
||||
{genres.map(genre => (
|
||||
<DropdownMenuItem key={genre} onClick={() => onGenreChange(genre)}>
|
||||
{genre}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Studio Filter - For Movies/Series/Adult */}
|
||||
{availableFilters.includes('studio') && studios.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedStudio
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Building2 size={14} className="mr-2" />
|
||||
{selectedStudio || 'Studios'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
||||
Filter by Studio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onStudioChange(null)}>
|
||||
All Studios
|
||||
</DropdownMenuItem>
|
||||
{studios.map(studio => (
|
||||
<DropdownMenuItem key={studio} onClick={() => onStudioChange(studio)}>
|
||||
{studio}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Platform Filter - For Games */}
|
||||
{availableFilters.includes('platform') && platforms.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedPlatform
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Monitor size={14} className="mr-2" />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
||||
Filter by Platform
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onPlatformChange(null)}>
|
||||
All Platforms
|
||||
</DropdownMenuItem>
|
||||
{platforms.map(platform => (
|
||||
<DropdownMenuItem key={platform} onClick={() => onPlatformChange(platform)}>
|
||||
{platform}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Developer Filter - For Games */}
|
||||
{availableFilters.includes('developer') && developers.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedDeveloper
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Users size={14} className="mr-2" />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
||||
Filter by Developer
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDeveloperChange(null)}>
|
||||
All Developers
|
||||
</DropdownMenuItem>
|
||||
{developers.map(developer => (
|
||||
<DropdownMenuItem key={developer} onClick={() => onDeveloperChange(developer)}>
|
||||
{developer}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Category/Series Filter - For Games */}
|
||||
{availableFilters.includes('category') && categories.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedCategory
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<FolderTree size={14} className="mr-2" />
|
||||
{selectedCategory || 'Series'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
||||
Filter by Series
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onCategoryChange(null)}>
|
||||
All Series
|
||||
</DropdownMenuItem>
|
||||
{categories.map(category => (
|
||||
<DropdownMenuItem key={category} onClick={() => onCategoryChange(category)}>
|
||||
{category}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Source Filter */}
|
||||
{sources.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"h-9 px-3 rounded-xl border text-sm font-medium transition-colors inline-flex items-center justify-center",
|
||||
selectedSource
|
||||
? "border-[#e8466c]/30 bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20"
|
||||
: "border-white/10 bg-transparent text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Database size={14} className="mr-2" />
|
||||
{selectedSource || 'Source'}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem disabled className="text-xs font-semibold text-gray-500 uppercase">
|
||||
Filter by Source
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onSourceChange(null)}>
|
||||
All Sources
|
||||
</DropdownMenuItem>
|
||||
{sources.map(source => (
|
||||
<DropdownMenuItem key={source} onClick={() => onSourceChange(source)}>
|
||||
{source}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Clear All Filters */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="h-9 px-3 inline-flex items-center justify-center text-gray-400 hover:text-white hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={14} className="mr-2" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Active Filter Badges */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-1 ml-2">
|
||||
{selectedGenre && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onGenreChange(null)}
|
||||
>
|
||||
{selectedGenre} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStudio && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onStudioChange(null)}
|
||||
>
|
||||
{selectedStudio} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedPlatform && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onPlatformChange(null)}
|
||||
>
|
||||
{selectedPlatform} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedDeveloper && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onDeveloperChange(null)}
|
||||
>
|
||||
{selectedDeveloper} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
>
|
||||
{selectedCategory} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{selectedSource && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#e8466c]/10 text-[#e8466c] border-[#e8466c]/20 hover:bg-[#e8466c]/20 cursor-pointer"
|
||||
onClick={() => onSourceChange(null)}
|
||||
>
|
||||
{selectedSource} <X size={12} className="ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useLocation, useNavigate, NavLink } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { MediaCategory } from '@/types';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
Users,
|
||||
FolderKanban,
|
||||
Database,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
Film,
|
||||
Tv,
|
||||
Gamepad2,
|
||||
Heart,
|
||||
Eye,
|
||||
Flame,
|
||||
Clock,
|
||||
User,
|
||||
Music,
|
||||
BookOpen,
|
||||
Monitor,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
|
||||
// shadcn/ui sidebar components
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface AppSidebarProps {
|
||||
enabledCategories: MediaCategory[];
|
||||
onToggleCategory: (category: MediaCategory) => void;
|
||||
pageTitle?: string;
|
||||
mediaCounts?: Record<string, number>;
|
||||
activeFilter?: string;
|
||||
onFilterChange?: (filter: string) => void;
|
||||
user?: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AppSidebar({
|
||||
enabledCategories,
|
||||
pageTitle = 'MediaVault',
|
||||
mediaCounts = {},
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
user,
|
||||
}: AppSidebarProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log('Logout clicked');
|
||||
};
|
||||
|
||||
// Category config with icons, colors and routes
|
||||
const categoryConfig: Record<MediaCategory, { icon: any; label: string; route: string; color: string }> = {
|
||||
'Anime': { icon: Tv, label: 'Anime', route: '/anime', color: 'text-purple-400' },
|
||||
'Movies': { icon: Film, label: 'Movies', route: '/movies', color: 'text-blue-400' },
|
||||
'TV Series': { icon: Tv, label: 'Series', route: '/tv-series', color: 'text-green-400' },
|
||||
'Music': { icon: Music, label: 'Music', route: '/music', color: 'text-pink-400' },
|
||||
'Books': { icon: BookOpen, label: 'Books', route: '/books', color: 'text-yellow-400' },
|
||||
'Adult': { icon: Eye, label: 'Adult', route: '/adult', color: 'text-rose-400' },
|
||||
'Consoles': { icon: Monitor, label: 'Consoles', route: '/consoles', color: 'text-orange-400' },
|
||||
'Games': { icon: Gamepad2, label: 'Games', route: '/games', color: 'text-indigo-400' },
|
||||
};
|
||||
|
||||
const handleFilterClick = (filter: string) => {
|
||||
onFilterChange?.(filter);
|
||||
if (filter === 'favorites') {
|
||||
navigate('/browse?favorites=true');
|
||||
return;
|
||||
}
|
||||
// Find route for category
|
||||
const config = categoryConfig[filter as MediaCategory];
|
||||
if (config) {
|
||||
navigate(config.route);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickFilter = (filter: string) => {
|
||||
const routes: Record<string, string> = {
|
||||
'most-played': '/browse?sort=plays',
|
||||
'recently-added': '/browse?sort=recent',
|
||||
};
|
||||
navigate(routes[filter] || '/browse');
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
// Build category routes for Library isActive check
|
||||
const categoryRoutes = enabledCategories.map(cat => categoryConfig[cat].route);
|
||||
|
||||
const mainNavItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', isActive: isActive('/') },
|
||||
{ to: '/browse', icon: Library, label: 'Library', isActive: isActive('/browse') || categoryRoutes.some(route => isActive(route)) },
|
||||
{ to: '/cast', icon: Users, label: 'Actors', isActive: isActive('/cast') },
|
||||
//{ to: '/collections', icon: FolderKanban, label: 'Collections', isActive: isActive('/collections') },
|
||||
{ to: '/import', icon: Download, label: 'Import', isActive: isActive('/import') },
|
||||
//{ to: '/sources', icon: Database, label: 'Sources', isActive: isActive('/sources') },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings', isActive: isActive('/settings') },
|
||||
];
|
||||
|
||||
// Build media type filters from enabled categories
|
||||
const mediaTypeFilters = enabledCategories.map(cat => {
|
||||
const config = categoryConfig[cat];
|
||||
return {
|
||||
id: cat.toLowerCase().replace(/\s+/g, '-'),
|
||||
icon: config.icon,
|
||||
label: config.label,
|
||||
count: mediaCounts[cat] || 0,
|
||||
color: config.color,
|
||||
category: cat,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r border-white/5 bg-[#0d0f14]">
|
||||
<SidebarHeader className="p-4">
|
||||
<NavLink to="/" className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#e8466c] to-[#f47298] flex items-center justify-center shadow-lg shadow-[#e8466c]/20">
|
||||
<Database className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white tracking-tight">{pageTitle}</span>
|
||||
</NavLink>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="px-2">
|
||||
{/* Main Navigation */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Navigation
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainNavItems.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={item.isActive}
|
||||
className={cn(
|
||||
'transition-colors w-full',
|
||||
item.isActive
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<NavLink to={item.to} className="flex items-center gap-2 w-full">
|
||||
<item.icon className={cn('w-4 h-4 shrink-0', item.isActive ? 'text-[#e8466c]' : '')} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Media Type Filters */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Media Type
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mediaTypeFilters.map((filter) => {
|
||||
const isFilterActive = activeFilter === filter.id;
|
||||
return (
|
||||
<SidebarMenuItem key={filter.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleFilterClick(filter.category)}
|
||||
isActive={isFilterActive}
|
||||
className={cn(
|
||||
'transition-colors w-full justify-start gap-2',
|
||||
isFilterActive
|
||||
? 'bg-[#e8466c]/10 text-[#e8466c] hover:bg-[#e8466c]/20'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<filter.icon
|
||||
className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
isFilterActive ? 'text-[#e8466c]' : filter.color || ''
|
||||
)}
|
||||
/>
|
||||
<span className="truncate flex-1 text-left">{filter.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
|
||||
isFilterActive
|
||||
? 'bg-[#e8466c]/20 text-[#e8466c]'
|
||||
: 'bg-white/10 text-gray-500'
|
||||
)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Quick Filters
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleQuickFilter('most-played')}
|
||||
className="text-gray-400 hover:bg-white/5 hover:text-white w-full justify-start gap-2"
|
||||
>
|
||||
<Flame className="w-4 h-4 text-orange-400 shrink-0" />
|
||||
<span className="truncate">Most Played</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleQuickFilter('recently-added')}
|
||||
className="text-gray-400 hover:bg-white/5 hover:text-white w-full justify-start gap-2"
|
||||
>
|
||||
<Clock className="w-4 h-4 text-cyan-400 shrink-0" />
|
||||
<span className="truncate">Recently Added</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-2">
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
className="w-full justify-start gap-2 text-gray-400 hover:text-white hover:bg-white/5"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<>
|
||||
<Sun className="w-4 h-4 text-amber-400" />
|
||||
<span>Light Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="w-4 h-4 text-indigo-400" />
|
||||
<span>Dark Mode</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Profile */}
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-white/5 mt-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c] text-xs">
|
||||
{user.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-2 py-2 rounded-lg bg-white/5 mt-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-[#e8466c]/20 text-[#e8466c]">
|
||||
<User className="w-4 h-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">Guest</p>
|
||||
<p className="text-xs text-gray-500">Not logged in</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="w-full justify-start gap-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 mt-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Logout</span>
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 select-none after:absolute after:inset-0 after: after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, ...props }, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full w-full flex-1 bg-primary transition-all duration-500"
|
||||
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user