11"use client" ;
22
3- import { useState , useEffect , useMemo } from "react" ;
3+ import { useState , useEffect , useMemo , useRef } from "react" ;
4+ import { useSearchParams , usePathname } from "next/navigation" ;
45import { Button } from "@/components/ui/button" ;
56import { Card , CardContent } from "@/components/ui/card" ;
6- import { Loader2 , Activity , Users } from "lucide-react" ;
7+ import { Loader2 , Activity , Users , ChevronLeft , ChevronRight } from "lucide-react" ;
78import { PeopleStats } from "@/components/people/PeopleStats" ;
89import { PeopleGrid } from "@/components/people/PeopleGrid" ;
910import { ContributorDetail } from "@/components/people/ContributorDetail" ;
@@ -12,6 +13,14 @@ import { type TeamMember } from "@/lib/team-data";
1213import { Input } from "@/components/ui/input" ;
1314import { Search } from "lucide-react" ;
1415import { useScrollRestoration } from "@/lib/hooks/useScrollRestoration" ;
16+ import {
17+ Select ,
18+ SelectContent ,
19+ SelectItem ,
20+ SelectTrigger ,
21+ SelectValue ,
22+ } from "@/components/ui/select" ;
23+ import { cn } from "@/lib/utils" ;
1524
1625
1726
@@ -62,6 +71,66 @@ export default function PeoplePage() {
6271 const [ loading , setLoading ] = useState ( true ) ;
6372 const [ error , setError ] = useState < string | null > ( null ) ;
6473 const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
74+ const searchParams = useSearchParams ( ) ;
75+ const pathname = usePathname ( ) ;
76+ const contributorsSectionRef = useRef < HTMLDivElement | null > ( null ) ;
77+ // Stable primitive for effect deps so dependency array size never changes
78+ const searchParamsString = typeof searchParams ?. toString === "function" ? searchParams . toString ( ) : "" ;
79+
80+ // Pagination state - synced with URL for shareable links and back/forward
81+ const PAGE_SIZE_OPTIONS = [ 24 , 48 , 96 , 192 ] as const ;
82+ const [ pageSize , setPageSizeState ] = useState < number > ( ( ) => {
83+ if ( typeof searchParams ?. get !== "function" ) return 24 ;
84+ const limit = searchParams . get ( "limit" ) ;
85+ if ( limit ) {
86+ if ( limit === "all" ) return Infinity ;
87+ const parsed = parseInt ( limit , 10 ) ;
88+ if ( PAGE_SIZE_OPTIONS . includes ( parsed as ( typeof PAGE_SIZE_OPTIONS ) [ number ] ) ) return parsed ;
89+ }
90+ return 24 ;
91+ } ) ;
92+ const [ currentPage , setCurrentPageState ] = useState < number > ( ( ) => {
93+ if ( typeof searchParams ?. get !== "function" ) return 1 ;
94+ const page = searchParams . get ( "page" ) ;
95+ if ( page ) {
96+ const parsed = parseInt ( page , 10 ) ;
97+ return parsed > 0 ? parsed : 1 ;
98+ }
99+ return 1 ;
100+ } ) ;
101+
102+ useEffect ( ( ) => {
103+ const params = searchParams ;
104+ if ( typeof params ?. get !== "function" ) return ;
105+ const limit = params . get ( "limit" ) ;
106+ if ( limit ) {
107+ if ( limit === "all" ) {
108+ setPageSizeState ( Infinity ) ;
109+ return ;
110+ }
111+ const parsed = parseInt ( limit , 10 ) ;
112+ if ( PAGE_SIZE_OPTIONS . includes ( parsed as ( typeof PAGE_SIZE_OPTIONS ) [ number ] ) ) {
113+ setPageSizeState ( parsed ) ;
114+ } else {
115+ setPageSizeState ( 24 ) ;
116+ }
117+ } else {
118+ setPageSizeState ( 24 ) ;
119+ }
120+ } , [ searchParamsString ] ) ;
121+
122+ useEffect ( ( ) => {
123+ const params = searchParams ;
124+ if ( typeof params ?. get !== "function" ) return ;
125+ const page = params . get ( "page" ) ;
126+ if ( page ) {
127+ const parsed = parseInt ( page , 10 ) ;
128+ setCurrentPageState ( parsed > 0 ? parsed : 1 ) ;
129+ } else {
130+ setCurrentPageState ( 1 ) ;
131+ }
132+ } , [ searchParamsString ] ) ;
133+
65134 useEffect ( ( ) => {
66135 if ( ! loading && window . location . hash === "#contributors" ) {
67136 const el = document . getElementById ( "contributors" ) ;
@@ -114,6 +183,70 @@ const filteredPeople = useMemo(() => {
114183 } ) ;
115184} , [ people , searchQuery ] ) ;
116185
186+ const totalPages = useMemo ( ( ) => {
187+ if ( pageSize === Infinity ) return 1 ;
188+ return Math . ceil ( filteredPeople . length / pageSize ) ;
189+ } , [ filteredPeople . length , pageSize ] ) ;
190+
191+ const paginatedPeople = useMemo ( ( ) => {
192+ if ( pageSize === Infinity ) return filteredPeople ;
193+ const start = ( currentPage - 1 ) * pageSize ;
194+ return filteredPeople . slice ( start , start + pageSize ) ;
195+ } , [ filteredPeople , pageSize , currentPage ] ) ;
196+
197+ // Reset to page 1 when current page is out of range (e.g. after search/filter)
198+ useEffect ( ( ) => {
199+ if ( currentPage > totalPages && totalPages > 0 ) {
200+ const params = new URLSearchParams ( searchParamsString ) ;
201+ params . delete ( "page" ) ;
202+ setCurrentPageState ( 1 ) ;
203+ if ( typeof window !== "undefined" ) {
204+ window . history . replaceState ( null , "" , `${ pathname } ?${ params . toString ( ) } ` ) ;
205+ }
206+ }
207+ } , [ totalPages , currentPage , searchParamsString , pathname ?? "" ] ) ;
208+
209+ const scrollToContributorsTop = ( ) => {
210+ if ( typeof window === "undefined" ) return ;
211+ const prefersReducedMotion = window . matchMedia ( "(prefers-reduced-motion: reduce)" ) . matches ;
212+ requestAnimationFrame ( ( ) => {
213+ if ( ! contributorsSectionRef . current ) return ;
214+ const rect = contributorsSectionRef . current . getBoundingClientRect ( ) ;
215+ const absoluteTop = rect . top + window . scrollY ;
216+ const offset = 80 ;
217+ window . scrollTo ( {
218+ top : Math . max ( absoluteTop - offset , 0 ) ,
219+ behavior : prefersReducedMotion ? "auto" : "smooth" ,
220+ } ) ;
221+ } ) ;
222+ } ;
223+
224+ const updatePage = ( page : number ) => {
225+ const params = new URLSearchParams ( searchParams ?. toString ( ) ?? "" ) ;
226+ if ( page === 1 ) params . delete ( "page" ) ;
227+ else params . set ( "page" , page . toString ( ) ) ;
228+ setCurrentPageState ( page ) ;
229+ scrollToContributorsTop ( ) ;
230+ if ( typeof window !== "undefined" ) {
231+ window . history . replaceState ( null , "" , `${ pathname } ?${ params . toString ( ) } ` ) ;
232+ }
233+ } ;
234+
235+ const updatePageSize = ( newPageSize : number | "all" ) => {
236+ const params = new URLSearchParams ( searchParams ?. toString ( ) ?? "" ) ;
237+ if ( newPageSize === "all" || newPageSize === Infinity ) {
238+ params . set ( "limit" , "all" ) ;
239+ setPageSizeState ( Infinity ) ;
240+ } else {
241+ params . set ( "limit" , newPageSize . toString ( ) ) ;
242+ setPageSizeState ( newPageSize ) ;
243+ }
244+ params . delete ( "page" ) ;
245+ setCurrentPageState ( 1 ) ;
246+ if ( typeof window !== "undefined" ) {
247+ window . history . replaceState ( null , "" , `${ pathname } ?${ params . toString ( ) } ` ) ;
248+ }
249+ } ;
117250
118251 const handleContributorClick = ( contributor : ContributorEntry ) => {
119252 saveScrollPosition ( ) ;
@@ -274,7 +407,7 @@ const filteredPeople = useMemo(() => {
274407 members = { alumni }
275408 teamType = "alumni"
276409 />
277- < section id = "contributors" className = "mb-8 scroll-mt-28" >
410+ < section id = "contributors" ref = { contributorsSectionRef } className = "mb-8 scroll-mt-28" >
278411 < div className = "mb-8" >
279412 < div className = "mb-4" >
280413 < h2 className = "text-3xl font-bold" >
@@ -296,7 +429,7 @@ const filteredPeople = useMemo(() => {
296429 onContributorClick = { handleContributorClick }
297430 />
298431
299- < div className = "flex items-center justify-between gap-4 py-8" >
432+ < div className = "flex flex-col sm:flex-row sm: items-center justify-between gap-4 py-8" >
300433 < div className = "flex items-center gap-2" >
301434 < Users className = "w-5 h-5 text-muted-foreground" />
302435 < span className = "text-2xl font-bold text-foreground" >
@@ -308,24 +441,128 @@ const filteredPeople = useMemo(() => {
308441 </ span >
309442 </ div >
310443
311- < div className = "relative w-full sm:w-72" >
312- < Search className = "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
313- < Input
314- type = "text"
315- placeholder = "Search contributors..."
316- value = { searchQuery }
317- onChange = { ( e ) => setSearchQuery ( e . target . value ) }
318- className = "pl-9 h-10"
319- />
444+ < div className = "flex flex-wrap items-center gap-4" >
445+ < div className = "relative w-full sm:w-72" >
446+ < Search className = "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
447+ < Input
448+ type = "text"
449+ placeholder = "Search contributors..."
450+ value = { searchQuery }
451+ onChange = { ( e ) => setSearchQuery ( e . target . value ) }
452+ className = "pl-9 h-10"
453+ />
454+ </ div >
455+ { filteredPeople . length > 0 && (
456+ < div className = "flex items-center gap-2" >
457+ < label htmlFor = "people-page-size" className = "text-sm text-muted-foreground whitespace-nowrap" >
458+ Show
459+ </ label >
460+ < Select
461+ value = { pageSize === Infinity ? "all" : pageSize . toString ( ) }
462+ onValueChange = { ( value ) => {
463+ if ( value === "all" ) updatePageSize ( "all" ) ;
464+ else updatePageSize ( parseInt ( value , 10 ) ) ;
465+ } }
466+ >
467+ < SelectTrigger
468+ id = "people-page-size"
469+ size = "sm"
470+ className = "h-9 w-24 border border-[#42B883]/30 hover:bg-[#42B883]/20 focus-visible:ring-2 focus-visible:ring-[#42B883]"
471+ aria-label = "Contributors per page"
472+ >
473+ < SelectValue placeholder = "All" />
474+ </ SelectTrigger >
475+ < SelectContent >
476+ < SelectItem value = "24" > 24</ SelectItem >
477+ < SelectItem value = "48" > 48</ SelectItem >
478+ < SelectItem value = "96" > 96</ SelectItem >
479+ < SelectItem value = "192" > 192</ SelectItem >
480+ < SelectItem value = "all" > All</ SelectItem >
481+ </ SelectContent >
482+ </ Select >
483+ </ div >
484+ ) }
320485 </ div >
321486 </ div >
322487
323488 < PeopleGrid
324- contributors = { filteredPeople }
489+ contributors = { paginatedPeople }
325490 onContributorClick = { handleContributorClick }
326491 viewMode = "grid"
327492 loading = { false }
328493 />
494+
495+ { filteredPeople . length > 0 && pageSize !== Infinity && totalPages > 1 && (
496+ < div className = "flex items-center justify-center gap-2 mt-8" >
497+ < Button
498+ variant = "outline"
499+ size = "sm"
500+ onClick = { ( ) => updatePage ( Math . max ( 1 , currentPage - 1 ) ) }
501+ disabled = { currentPage === 1 }
502+ className = "h-9 border border-[#42B883]/30 hover:bg-[#42B883]/20 disabled:opacity-50 disabled:cursor-not-allowed"
503+ aria-label = "Previous page"
504+ >
505+ < ChevronLeft className = "h-4 w-4" />
506+ < span className = "sr-only" > Previous</ span >
507+ </ Button >
508+ < div className = "flex items-center gap-1" >
509+ { ( ( ) => {
510+ const pages : number [ ] = [ ] ;
511+ if ( totalPages <= 7 ) {
512+ for ( let i = 1 ; i <= totalPages ; i ++ ) pages . push ( i ) ;
513+ } else {
514+ pages . push ( 1 ) ;
515+ if ( currentPage <= 4 ) {
516+ for ( let i = 2 ; i <= 5 ; i ++ ) pages . push ( i ) ;
517+ pages . push ( - 1 ) ;
518+ pages . push ( totalPages ) ;
519+ } else if ( currentPage >= totalPages - 3 ) {
520+ pages . push ( - 1 ) ;
521+ for ( let i = totalPages - 4 ; i <= totalPages ; i ++ ) pages . push ( i ) ;
522+ } else {
523+ pages . push ( - 1 ) ;
524+ pages . push ( currentPage - 1 , currentPage , currentPage + 1 ) ;
525+ pages . push ( - 1 ) ;
526+ pages . push ( totalPages ) ;
527+ }
528+ }
529+ return pages . map ( ( pageNum , idx ) =>
530+ pageNum === - 1 ? (
531+ < span key = { `ellipsis-${ idx } ` } className = "px-2 text-muted-foreground" > …</ span >
532+ ) : (
533+ < Button
534+ key = { pageNum }
535+ variant = { currentPage === pageNum ? "default" : "ghost" }
536+ size = "sm"
537+ onClick = { ( ) => updatePage ( pageNum ) }
538+ className = { cn (
539+ "h-9 w-9 p-0" ,
540+ currentPage === pageNum
541+ ? "bg-[#42B883] text-white hover:bg-[#42B883]/90"
542+ : "hover:bg-[#42B883]/20 hover:text-[#42B883]"
543+ ) }
544+ aria-label = { `Page ${ pageNum } ` }
545+ aria-current = { currentPage === pageNum ? "page" : undefined }
546+ >
547+ { pageNum }
548+ </ Button >
549+ )
550+ ) ;
551+ } ) ( ) }
552+ </ div >
553+ < Button
554+ variant = "outline"
555+ size = "sm"
556+ onClick = { ( ) => updatePage ( Math . min ( totalPages , currentPage + 1 ) ) }
557+ disabled = { currentPage === totalPages }
558+ className = "h-9 border border-[#42B883]/30 hover:bg-[#42B883]/20 disabled:opacity-50 disabled:cursor-not-allowed"
559+ aria-label = "Next page"
560+ >
561+ < ChevronRight className = "h-4 w-4" />
562+ < span className = "sr-only" > Next</ span >
563+ </ Button >
564+ </ div >
565+ ) }
329566 </ div >
330567</ section >
331568
0 commit comments