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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user