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:
Lars Behrends
2026-04-26 15:43:41 +02:00
parent 4605b251be
commit b0cb8ca0a2
8 changed files with 672 additions and 405 deletions
+1
View File
@@ -7,3 +7,4 @@ coverage/
.env* .env*
!.env.example !.env.example
/docs /docs
/.windsurf
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)} )}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
View File
@@ -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>
); );
} }
+130
View File
@@ -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,
}