Introduce pagination component and sticky views
Add a reusable pagination UI (src/components/ui/pagination.tsx) and integrate it into BrowseView and CastView. Replace previous simple prev/next handlers with handlePageChange and a getPaginationItems helper (ellipsis support), move filters/controls into sticky headers, make main content scrollable (browse-scroll-container / cast-scroll-container), and add sticky pagination bars. Also: fix footer to be fixed at bottom in App.tsx, increase bottom padding in DashboardView and DetailView, simplify MediaTable markup to render Table directly, and add /.windsurf to .gitignore. These changes improve UX for large result sets and keep controls accessible while scrolling.
This commit is contained in:
@@ -7,3 +7,4 @@ coverage/
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
/docs
|
/docs
|
||||||
|
/.windsurf
|
||||||
|
|||||||
+1
-1
@@ -544,7 +544,7 @@ function AppContent() {
|
|||||||
</LayoutGroup>
|
</LayoutGroup>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="py-6 px-6 border-t border-white/5 bg-[#0a0c10] mt-auto">
|
<footer className="fixed bottom-0 left-64 right-0 py-4 px-6 border-t border-white/5 bg-[#0a0c10] z-40">
|
||||||
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-500">
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-500">
|
||||||
<span>{mediaCounts.all} total</span>
|
<span>{mediaCounts.all} total</span>
|
||||||
|
|||||||
+274
-200
@@ -2,12 +2,21 @@ import { Media, MediaCategory, Staff } from '@/types';
|
|||||||
import MediaCard from './MediaCard';
|
import MediaCard from './MediaCard';
|
||||||
import MediaTable from './MediaTable';
|
import MediaTable from './MediaTable';
|
||||||
import MediaFilters from './filters/MediaFilters';
|
import MediaFilters from './filters/MediaFilters';
|
||||||
import { LayoutGrid, List, ChevronLeft, ChevronRight, User, Users } from 'lucide-react';
|
import { LayoutGrid, List, User, Users } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import Loading from '@/components/ui/loading';
|
import Loading from '@/components/ui/loading';
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination';
|
||||||
|
|
||||||
interface BrowseViewProps {
|
interface BrowseViewProps {
|
||||||
mediaList: Media[];
|
mediaList: Media[];
|
||||||
@@ -110,14 +119,50 @@ export default function BrowseView({
|
|||||||
setSelectedSource(null);
|
setSelectedSource(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
setCurrentPage(page);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
const scrollContainer = document.getElementById('browse-scroll-container');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
// Generate pagination items with ellipsis
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
const getPaginationItems = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
const items: (number | string)[] = [];
|
||||||
|
const maxVisible = 5;
|
||||||
|
|
||||||
|
if (totalPages <= maxVisible) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always show first page
|
||||||
|
items.push(1);
|
||||||
|
|
||||||
|
if (currentPage > 3) {
|
||||||
|
items.push('ellipsis-start');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show pages around current
|
||||||
|
const start = Math.max(2, currentPage - 1);
|
||||||
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < totalPages - 2) {
|
||||||
|
items.push('ellipsis-end');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show last page
|
||||||
|
if (totalPages > 1) {
|
||||||
|
items.push(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate favorite IDs
|
// Calculate favorite IDs
|
||||||
@@ -136,165 +181,170 @@ export default function BrowseView({
|
|||||||
}, [searchResultsCast, itemsPerPage]);
|
}, [searchResultsCast, itemsPerPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-6 pb-12 px-6 w-full mx-auto">
|
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
|
||||||
{/* Filters Bar */}
|
{/* Sticky Header - Filter + Results Summary + Count */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
|
||||||
<MediaFilters
|
{/* Filters Bar */}
|
||||||
mediaList={mediaList}
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||||
activeCategory={activeCategory}
|
<MediaFilters
|
||||||
selectedGenre={selectedGenre}
|
mediaList={mediaList}
|
||||||
selectedStudio={selectedStudio}
|
activeCategory={activeCategory}
|
||||||
selectedPlatform={selectedPlatform}
|
selectedGenre={selectedGenre}
|
||||||
selectedDeveloper={selectedDeveloper}
|
selectedStudio={selectedStudio}
|
||||||
selectedCategory={selectedCategory}
|
selectedPlatform={selectedPlatform}
|
||||||
selectedSource={selectedSource}
|
selectedDeveloper={selectedDeveloper}
|
||||||
onGenreChange={setSelectedGenre}
|
selectedCategory={selectedCategory}
|
||||||
onStudioChange={setSelectedStudio}
|
selectedSource={selectedSource}
|
||||||
onPlatformChange={setSelectedPlatform}
|
onGenreChange={setSelectedGenre}
|
||||||
onDeveloperChange={setSelectedDeveloper}
|
onStudioChange={setSelectedStudio}
|
||||||
onCategoryChange={setSelectedCategory}
|
onPlatformChange={setSelectedPlatform}
|
||||||
onSourceChange={setSelectedSource}
|
onDeveloperChange={setSelectedDeveloper}
|
||||||
onClearAll={handleClearAll}
|
onCategoryChange={setSelectedCategory}
|
||||||
/>
|
onSourceChange={setSelectedSource}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Grid item size slider - only show in grid mode */}
|
{/* Grid item size slider - only show in grid mode */}
|
||||||
{viewMode === 'grid' && (
|
{viewMode === 'grid' && (
|
||||||
<div className="flex items-center gap-3 bg-[#1a1d26] rounded-xl px-4 py-2 border border-white/10">
|
<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>
|
<span className="text-xs font-bold text-gray-500">Size</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="1"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
value={gridItemSize}
|
value={gridItemSize}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newSize = Number(e.target.value);
|
const newSize = Number(e.target.value);
|
||||||
setGridItemSize(newSize);
|
setGridItemSize(newSize);
|
||||||
onGridItemSizeChange?.(newSize);
|
onGridItemSizeChange?.(newSize);
|
||||||
}}
|
}}
|
||||||
className="w-24 h-2 bg-[#0d0f14] rounded-lg appearance-none cursor-pointer accent-[#e8466c]"
|
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>
|
<span className="text-xs font-bold text-[#e8466c] w-5 text-center">{gridItemSize}</span>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
|
||||||
)}
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-8 transition-all rounded-lg",
|
|
||||||
viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
|
||||||
)}
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
>
|
|
||||||
<List size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</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>
|
</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 */}
|
{/* View Toggle */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center bg-[#1a1d26] rounded-xl p-1 border border-white/10">
|
||||||
<p className="text-sm text-gray-500">
|
<Button
|
||||||
Showing {paginatedMedia.length} of {filteredMedia.length} results
|
variant="ghost"
|
||||||
</p>
|
size="icon"
|
||||||
</div>
|
className={cn(
|
||||||
|
"h-8 w-8 transition-all rounded-lg",
|
||||||
{/* Cast Search Results */}
|
viewMode === 'grid' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||||
{hasSearchResults && hasCastResults && onCastClick && (
|
)}
|
||||||
<div className="mb-8">
|
onClick={() => setViewMode('grid')}
|
||||||
<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">
|
<LayoutGrid size={16} />
|
||||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-[#0d0f14] shrink-0">
|
</Button>
|
||||||
{person.photo ? (
|
<Button
|
||||||
<img
|
variant="ghost"
|
||||||
src={person.photo}
|
size="icon"
|
||||||
alt={person.name}
|
className={cn(
|
||||||
className="w-full h-full object-cover"
|
"h-8 w-8 transition-all rounded-lg",
|
||||||
referrerPolicy="no-referrer"
|
viewMode === 'list' ? "bg-[#0d0f14] text-[#e8466c]" : "text-gray-500 hover:text-gray-300 hover:bg-white/5"
|
||||||
/>
|
)}
|
||||||
) : (
|
onClick={() => setViewMode('list')}
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
>
|
||||||
<User size={20} className="text-gray-600" />
|
<List size={16} />
|
||||||
</div>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<p className="text-sm font-medium text-white truncate group-hover:text-[#e8466c] transition-colors">
|
|
||||||
{person.name}
|
{/* Search Results Summary */}
|
||||||
</p>
|
{hasSearchResults && (
|
||||||
<p className="text-xs text-gray-500 truncate">{person.role}</p>
|
<div className="flex items-center gap-4 mb-4 p-3 bg-[#1a1d26] rounded-lg border border-white/10">
|
||||||
{person.filmography && person.filmography.length > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-gray-600 mt-1">
|
<span className="text-sm text-gray-400">Search results for:</span>
|
||||||
{person.filmography.length} role{person.filmography.length !== 1 ? 's' : ''}
|
<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">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Showing {paginatedMedia.length} of {filteredMedia.length} results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content Area */}
|
||||||
|
<div id="browse-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
|
||||||
|
{/* 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>
|
||||||
)}
|
<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>
|
</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>
|
</div>
|
||||||
{searchResultsCast.length > itemsPerPage && (
|
)}
|
||||||
<p className="text-xs text-gray-500 mt-3 text-center">
|
|
||||||
+{searchResultsCast.length - itemsPerPage} more cast members
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - inside scrollable area */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading message="Loading media..." />
|
<Loading message="Loading media..." />
|
||||||
) : mediaList.length === 0 && !hasCastResults ? (
|
) : mediaList.length === 0 && !hasCastResults ? (
|
||||||
@@ -342,53 +392,77 @@ export default function BrowseView({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
{/* End of scrollable content area */}
|
||||||
{filteredMedia.length > 0 && (
|
</div>
|
||||||
<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-gray-500 font-medium">Items per page:</span>
|
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={(e) => {
|
|
||||||
setItemsPerPage(Number(e.target.value));
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
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, 100].map(size => (
|
|
||||||
<option key={size} value={size}>{size}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
{/* Sticky Pagination Controls */}
|
||||||
<Button
|
{filteredMedia.length > 0 && (
|
||||||
variant="outline"
|
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
|
||||||
size="sm"
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
onClick={handlePrevPage}
|
<div className="flex items-center gap-4">
|
||||||
disabled={currentPage === 1}
|
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||||
className="gap-2 font-bold border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white disabled:opacity-50"
|
<select
|
||||||
>
|
value={itemsPerPage}
|
||||||
<ChevronLeft size={16} />
|
onChange={(e) => {
|
||||||
Previous
|
setItemsPerPage(Number(e.target.value));
|
||||||
</Button>
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
<div className="flex items-center gap-2">
|
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"
|
||||||
<span className="text-sm font-bold text-[#e8466c]">{currentPage}</span>
|
>
|
||||||
<span className="text-sm text-gray-500 font-medium">of</span>
|
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||||
<span className="text-sm font-bold text-gray-300">{totalPages || 1}</span>
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Pagination>
|
||||||
variant="outline"
|
<PaginationContent>
|
||||||
size="sm"
|
<PaginationItem>
|
||||||
onClick={handleNextPage}
|
<PaginationPrevious
|
||||||
disabled={currentPage === totalPages || totalPages === 0}
|
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||||
className="gap-2 font-bold border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white disabled:opacity-50"
|
className={cn(
|
||||||
>
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
Next
|
currentPage === 1 && "pointer-events-none opacity-50"
|
||||||
<ChevronRight size={16} />
|
)}
|
||||||
</Button>
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationItems().map((item, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === item}
|
||||||
|
onClick={() => handlePageChange(item as number)}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10",
|
||||||
|
currentPage === item
|
||||||
|
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
|
||||||
|
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
|
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+129
-65
@@ -1,5 +1,5 @@
|
|||||||
import { Staff, MediaCategory } from '@/types';
|
import { Staff, MediaCategory } from '@/types';
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
|
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
|
||||||
@@ -35,6 +35,15 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination';
|
||||||
import Loading from '@/components/ui/loading';
|
import Loading from '@/components/ui/loading';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -210,14 +219,50 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
|
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
|
||||||
}, [filteredStaff, currentPage, itemsPerPage]);
|
}, [filteredStaff, currentPage, itemsPerPage]);
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
setCurrentPage(page);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
const scrollContainer = document.getElementById('cast-scroll-container');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
// Generate pagination items with ellipsis
|
||||||
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
|
const getPaginationItems = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
const items: (number | string)[] = [];
|
||||||
|
const maxVisible = 5;
|
||||||
|
|
||||||
|
if (totalPages <= maxVisible) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always show first page
|
||||||
|
items.push(1);
|
||||||
|
|
||||||
|
if (currentPage > 3) {
|
||||||
|
items.push('ellipsis-start');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show pages around current
|
||||||
|
const start = Math.max(2, currentPage - 1);
|
||||||
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
items.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < totalPages - 2) {
|
||||||
|
items.push('ellipsis-end');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show last page
|
||||||
|
if (totalPages > 1) {
|
||||||
|
items.push(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Persist view mode
|
// Persist view mode
|
||||||
@@ -246,9 +291,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="pt-16 pb-12 px-4 sm:px-6 max-w-[1920px] mx-auto">
|
<div className="flex flex-col h-[calc(100vh-4rem-4rem)] w-full">
|
||||||
{/* Compact Toolbar - Like MediaFilters */}
|
{/* Sticky Header - Filters */}
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
|
||||||
|
{/* Compact Toolbar - Like MediaFilters */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{/* Top Row: Search, View Toggle, Count */}
|
{/* Top Row: Search, View Toggle, Count */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -468,7 +515,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content Area */}
|
||||||
|
<div id="cast-scroll-container" className="flex-1 overflow-y-auto px-6 pt-4 pb-20">
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading message="Loading cast..." />
|
<Loading message="Loading cast..." />
|
||||||
@@ -566,8 +616,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Table View */
|
/* Table View */
|
||||||
<div className="w-full">
|
<Table className="w-full table-fixed">
|
||||||
<Table className="border rounded-lg border-border/60 w-full table-fixed">
|
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
|
||||||
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
<TableHead className="w-14 rounded-tl-lg"></TableHead>
|
||||||
@@ -689,64 +738,79 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination - Modern */}
|
{/* End of scrollable content area */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky Pagination Controls */}
|
||||||
{filteredStaff.length > 0 && (
|
{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="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<span>Showing</span>
|
<div className="flex items-center gap-4">
|
||||||
<span className="font-semibold text-foreground">
|
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
|
||||||
{Math.min((currentPage - 1) * itemsPerPage + 1, filteredStaff.length)}-{Math.min(currentPage * itemsPerPage, filteredStaff.length)}
|
<select
|
||||||
</span>
|
value={itemsPerPage}
|
||||||
<span>of</span>
|
onChange={(e) => {
|
||||||
<span className="font-semibold text-foreground">{filteredStaff.length}</span>
|
setItemsPerPage(Number(e.target.value));
|
||||||
<span>items</span>
|
setCurrentPage(1);
|
||||||
</div>
|
}}
|
||||||
|
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"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Select
|
{[12, 20, 36, 48, 60, 100].map(size => (
|
||||||
value={itemsPerPage.toString()}
|
<option key={size} value={size}>{size}</option>
|
||||||
onValueChange={(value) => setItemsPerPage(Number(value))}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
|
currentPage === 1 && "pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationItems().map((item, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{item === 'ellipsis-start' || item === 'ellipsis-end' ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === item}
|
||||||
|
onClick={() => handlePageChange(item as number)}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10",
|
||||||
|
currentPage === item
|
||||||
|
? "bg-[#e8466c]/20 text-[#e8466c] border-[#e8466c]/30"
|
||||||
|
: "bg-transparent text-gray-300 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
|
||||||
|
className={cn(
|
||||||
|
"border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white",
|
||||||
|
(currentPage === totalPages || totalPages === 0) && "pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-6 pb-12 px-6 max-w-[1920px] mx-auto">
|
<div className="pt-6 pb-20 px-6 max-w-[1920px] mx-auto">
|
||||||
{/* Welcome Header */}
|
{/* Welcome Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function DetailView({ media, allMedia, onPersonClick }: DetailVie
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="min-h-screen bg-background pb-16">
|
<div className="min-h-screen bg-background pb-20">
|
||||||
{/* Hero Section - Full height from top behind transparent navbar */}
|
{/* Hero Section - Full height from top behind transparent navbar */}
|
||||||
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
|
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
|
||||||
<img
|
<img
|
||||||
|
|||||||
+135
-137
@@ -117,150 +117,148 @@ export default function MediaTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-[#0d0f14] rounded-lg border border-white/5 overflow-hidden">
|
<Table className="w-full">
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow className="border-b border-white/[0.03] hover:bg-transparent">
|
||||||
<TableRow className="border-b border-white/[0.03] hover:bg-transparent">
|
<TableHead
|
||||||
<TableHead
|
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[45%]"
|
||||||
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')}
|
||||||
onClick={() => handleSort('title')}
|
>
|
||||||
>
|
<div className="flex items-center">
|
||||||
<div className="flex items-center">
|
Title <SortIcon field="title" />
|
||||||
Title <SortIcon field="title" />
|
</div>
|
||||||
</div>
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead
|
||||||
<TableHead
|
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[80px]"
|
||||||
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')}
|
||||||
onClick={() => handleSort('category')}
|
>
|
||||||
>
|
<div className="flex items-center">
|
||||||
<div className="flex items-center">
|
Type <SortIcon field="category" />
|
||||||
Type <SortIcon field="category" />
|
</div>
|
||||||
</div>
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead
|
||||||
<TableHead
|
className="text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 transition-colors group w-[18%]"
|
||||||
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')}
|
||||||
onClick={() => handleSort('genre')}
|
>
|
||||||
>
|
<div className="flex items-center">
|
||||||
<div className="flex items-center">
|
Genre <SortIcon field="genre" />
|
||||||
Genre <SortIcon field="genre" />
|
</div>
|
||||||
</div>
|
</TableHead>
|
||||||
</TableHead>
|
<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"
|
||||||
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')}
|
||||||
onClick={() => handleSort('rating')}
|
>
|
||||||
>
|
<div className="flex items-center justify-center">
|
||||||
<div className="flex items-center justify-center">
|
Rating <SortIcon field="rating" />
|
||||||
Rating <SortIcon field="rating" />
|
</div>
|
||||||
</div>
|
</TableHead>
|
||||||
</TableHead>
|
<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"
|
||||||
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')}
|
||||||
onClick={() => handleSort('year')}
|
>
|
||||||
>
|
<div className="flex items-center justify-center">
|
||||||
<div className="flex items-center justify-center">
|
Year <SortIcon field="year" />
|
||||||
Year <SortIcon field="year" />
|
</div>
|
||||||
</div>
|
</TableHead>
|
||||||
</TableHead>
|
<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"
|
||||||
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')}
|
||||||
onClick={() => handleSort('plays')}
|
>
|
||||||
>
|
<div className="flex items-center justify-end">
|
||||||
<div className="flex items-center justify-end">
|
Plays <SortIcon field="plays" />
|
||||||
Plays <SortIcon field="plays" />
|
</div>
|
||||||
</div>
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="w-[40px]"></TableHead>
|
||||||
<TableHead className="w-[40px]"></TableHead>
|
</TableRow>
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
</TableHeader>
|
<TableBody>
|
||||||
<TableBody>
|
{sortedMedia.map((media) => {
|
||||||
{sortedMedia.map((media) => {
|
const categoryInfo = categoryConfig[media.category];
|
||||||
const categoryInfo = categoryConfig[media.category];
|
const CategoryIcon = categoryInfo?.icon;
|
||||||
const CategoryIcon = categoryInfo?.icon;
|
const isFavorite = favoriteIds.has(media.id);
|
||||||
const isFavorite = favoriteIds.has(media.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={media.id}
|
key={media.id}
|
||||||
className="border-b border-white/[0.02] hover:bg-white/[0.02] transition-colors cursor-pointer group"
|
className="border-b border-white/[0.02] hover:bg-white/[0.02] transition-colors cursor-pointer group"
|
||||||
onClick={() => onMediaClick(media)}
|
onClick={() => onMediaClick(media)}
|
||||||
>
|
>
|
||||||
{/* Title Cell with Poster */}
|
{/* Title Cell with Poster */}
|
||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-[#1a1d26]">
|
<div className="relative w-10 h-14 rounded overflow-hidden shrink-0 bg-[#1a1d26]">
|
||||||
<img
|
<img
|
||||||
src={media.poster}
|
src={media.poster}
|
||||||
alt={media.title}
|
alt={media.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-200 truncate group-hover:text-[#e8466c] transition-colors">
|
<div className="text-sm font-medium text-gray-200 truncate group-hover:text-[#e8466c] transition-colors">
|
||||||
{media.title}
|
{media.title}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{/* Type Badge */}
|
{/* Type Badge */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide",
|
||||||
categoryInfo.bgColor,
|
categoryInfo.bgColor,
|
||||||
categoryInfo.color
|
categoryInfo.color
|
||||||
)}>
|
)}>
|
||||||
{CategoryIcon && <CategoryIcon size={9} />}
|
{CategoryIcon && <CategoryIcon size={9} />}
|
||||||
{categoryInfo.label}
|
{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>
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{/* Genre */}
|
{/* Year */}
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
<span className="text-sm text-gray-500 truncate block">
|
<span className="text-sm text-gray-400">{media.year}</span>
|
||||||
{media.genres?.join(', ') || '-'}
|
</TableCell>
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Rating */}
|
{/* Plays */}
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<span className="text-sm text-gray-400">{media.playCount || 0}</span>
|
||||||
<Star size={12} className="text-[#e8466c] fill-[#e8466c]" />
|
</TableCell>
|
||||||
<span className="text-sm font-medium text-gray-300">
|
|
||||||
{media.rating?.toFixed(1) || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Year */}
|
{/* Favorite */}
|
||||||
<TableCell className="text-center">
|
<TableCell>
|
||||||
<span className="text-sm text-gray-400">{media.year}</span>
|
<button
|
||||||
</TableCell>
|
onClick={(e) => handleFavoriteClick(e, media)}
|
||||||
|
className={cn(
|
||||||
{/* Plays */}
|
"p-1 rounded transition-colors",
|
||||||
<TableCell className="text-right">
|
isFavorite
|
||||||
<span className="text-sm text-gray-400">{media.playCount || 0}</span>
|
? "text-[#e8466c]"
|
||||||
</TableCell>
|
: "text-gray-600 hover:text-gray-500"
|
||||||
|
)}
|
||||||
{/* Favorite */}
|
>
|
||||||
<TableCell>
|
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
||||||
<button
|
</button>
|
||||||
onClick={(e) => handleFavoriteClick(e, media)}
|
</TableCell>
|
||||||
className={cn(
|
</TableRow>
|
||||||
"p-1 rounded transition-colors",
|
);
|
||||||
isFavorite
|
})}
|
||||||
? "text-[#e8466c]"
|
</TableBody>
|
||||||
: "text-gray-600 hover:text-gray-500"
|
</Table>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Heart size={14} className={cn(isFavorite && "fill-current")} />
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "outline" : "ghost"}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
text = "Previous",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pl-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
text = "Next",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pr-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user