Skip to content

Commit 43d2081

Browse files
feat: add responsive pagination to contributors list
1 parent 6266318 commit 43d2081

1 file changed

Lines changed: 251 additions & 14 deletions

File tree

app/people/page.tsx

Lines changed: 251 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
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";
45
import { Button } from "@/components/ui/button";
56
import { Card, CardContent } from "@/components/ui/card";
6-
import { Loader2, Activity, Users } from "lucide-react";
7+
import { Loader2, Activity, Users, ChevronLeft, ChevronRight } from "lucide-react";
78
import { PeopleStats } from "@/components/people/PeopleStats";
89
import { PeopleGrid } from "@/components/people/PeopleGrid";
910
import { ContributorDetail } from "@/components/people/ContributorDetail";
@@ -12,6 +13,14 @@ import { type TeamMember } from "@/lib/team-data";
1213
import { Input } from "@/components/ui/input";
1314
import { Search } from "lucide-react";
1415
import { 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

Comments
 (0)