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.example
/docs
/.windsurf
+1 -1
View File
@@ -544,7 +544,7 @@ function AppContent() {
</LayoutGroup>
{/* 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="flex items-center gap-2 text-sm font-medium text-gray-500">
<span>{mediaCounts.all} total</span>
+113 -39
View File
@@ -2,12 +2,21 @@ import { Media, MediaCategory, Staff } from '@/types';
import MediaCard from './MediaCard';
import MediaTable from './MediaTable';
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 { Badge } from '@/components/ui/badge';
import Loading from '@/components/ui/loading';
import React, { useState, useMemo, useEffect } from 'react';
import { cn } from '@/lib/utils';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
interface BrowseViewProps {
mediaList: Media[];
@@ -110,14 +119,50 @@ export default function BrowseView({
setSelectedSource(null);
};
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
const handlePageChange = (page: number) => {
setCurrentPage(page);
const scrollContainer = document.getElementById('browse-scroll-container');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
// Generate pagination items with ellipsis
const getPaginationItems = () => {
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
@@ -136,9 +181,11 @@ export default function BrowseView({
}, [searchResultsCast, itemsPerPage]);
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">
{/* Sticky Header - Filter + Results Summary + Count */}
<div className="px-6 pt-4 pb-4 bg-background border-b border-white/10 shrink-0 z-10">
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<MediaFilters
mediaList={mediaList}
activeCategory={activeCategory}
@@ -233,12 +280,15 @@ export default function BrowseView({
)}
{/* Results Count */}
<div className="flex items-center justify-between mb-4">
<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">
@@ -294,7 +344,7 @@ export default function BrowseView({
</div>
)}
{/* Content */}
{/* Content - inside scrollable area */}
{loading ? (
<Loading message="Loading media..." />
) : mediaList.length === 0 && !hasCastResults ? (
@@ -342,9 +392,13 @@ export default function BrowseView({
</>
)}
{/* Pagination Controls */}
{/* End of scrollable content area */}
</div>
{/* Sticky Pagination Controls */}
{filteredMedia.length > 0 && (
<div className="mt-8 flex flex-col sm:flex-row items-center justify-between gap-6 border-t border-white/10 pt-6">
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500 font-medium">Items per page:</span>
<select
@@ -361,34 +415,54 @@ export default function BrowseView({
</select>
</div>
<div className="flex items-center gap-6">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="gap-2 font-bold border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white disabled:opacity-50"
>
<ChevronLeft size={16} />
Previous
</Button>
<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>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-[#e8466c]">{currentPage}</span>
<span className="text-sm text-gray-500 font-medium">of</span>
<span className="text-sm font-bold text-gray-300">{totalPages || 1}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="gap-2 font-bold border-white/10 bg-transparent text-gray-300 hover:bg-white/5 hover:text-white disabled:opacity-50"
{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"
)}
>
Next
<ChevronRight size={16} />
</Button>
{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>
)}
+125 -61
View File
@@ -1,5 +1,5 @@
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 {
Search, ArrowUpDown, User, ChevronLeft, ChevronRight, X, Filter,
@@ -35,6 +35,15 @@ import {
} from '@/components/ui/table';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
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 { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/lib/utils';
@@ -210,14 +219,50 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
return filteredStaff.slice(startIndex, startIndex + itemsPerPage);
}, [filteredStaff, currentPage, itemsPerPage]);
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
const handlePageChange = (page: number) => {
setCurrentPage(page);
const scrollContainer = document.getElementById('cast-scroll-container');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
window.scrollTo({ top: 0, behavior: 'smooth' });
// Generate pagination items with ellipsis
const getPaginationItems = () => {
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
@@ -246,9 +291,11 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
return (
<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">
{/* Sticky Header - Filters */}
<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 mb-6">
<div className="flex flex-col gap-4">
{/* Top Row: Search, View Toggle, Count */}
<div className="flex items-center gap-2 flex-wrap">
{/* Search */}
@@ -468,7 +515,10 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</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 */}
{loading ? (
<Loading message="Loading cast..." />
@@ -566,8 +616,7 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</div>
) : (
/* Table View */
<div className="w-full">
<Table className="border rounded-lg border-border/60 w-full table-fixed">
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="hover:bg-transparent border-border/60 bg-muted/30">
<TableHead className="w-14 rounded-tl-lg"></TableHead>
@@ -689,64 +738,79 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
</AnimatePresence>
</TableBody>
</Table>
</div>
)}
{/* Pagination - Modern */}
{/* End of scrollable content area */}
</div>
{/* Sticky Pagination Controls */}
{filteredStaff.length > 0 && (
<div className="mt-10 flex flex-col sm:flex-row items-center justify-between gap-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>Showing</span>
<span className="font-semibold text-foreground">
{Math.min((currentPage - 1) * itemsPerPage + 1, filteredStaff.length)}-{Math.min(currentPage * itemsPerPage, filteredStaff.length)}
</span>
<span>of</span>
<span className="font-semibold text-foreground">{filteredStaff.length}</span>
<span>items</span>
</div>
<div className="flex items-center gap-2">
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => setItemsPerPage(Number(value))}
<div className="px-6 py-4 border-t border-white/10 bg-background shrink-0 z-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<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"
>
<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>
{[12, 20, 36, 48, 60, 100].map(size => (
<option key={size} value={size}>{size}</option>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 ml-2">
<Button
variant="outline"
size="icon"
onClick={handlePrevPage}
disabled={currentPage === 1}
className="h-9 w-9 rounded-lg border-border/60"
>
<ChevronLeft size={16} />
</Button>
<div className="flex items-center gap-1 px-2">
<span className="text-sm font-semibold text-[#6d28d9]">{currentPage}</span>
<span className="text-sm text-muted-foreground">/</span>
<span className="text-sm text-muted-foreground">{totalPages}</span>
</select>
</div>
<Button
variant="outline"
size="icon"
onClick={handleNextPage}
disabled={currentPage === totalPages || totalPages === 0}
className="h-9 w-9 rounded-lg border-border/60"
<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"
)}
>
<ChevronRight size={16} />
</Button>
</div>
{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>
)}
+1 -1
View File
@@ -122,7 +122,7 @@ export default function DashboardView({ mediaList, onMediaClick, loading = false
}
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 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function DetailView({ media, allMedia, onPersonClick }: DetailVie
return (
<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 */}
<div className="relative h-[40vh] md:h-[45vh] overflow-hidden bg-zinc-900">
<img
+1 -3
View File
@@ -117,8 +117,7 @@ export default function MediaTable({
};
return (
<div className="w-full bg-[#0d0f14] rounded-lg border border-white/5 overflow-hidden">
<Table>
<Table className="w-full">
<TableHeader>
<TableRow className="border-b border-white/[0.03] hover:bg-transparent">
<TableHead
@@ -261,6 +260,5 @@ export default function MediaTable({
})}
</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,
}