11"use client" ;
22
3- import { useDeferredValue , useMemo , useState } from "react" ;
3+ import clsx from "@/components/utils/clsx" ;
4+ import { IconChevronLeft , IconChevronRight } from "@/components/icons" ;
5+ import { useDeferredValue , useEffect , useMemo , useState } from "react" ;
46import type { TaskFacet , TaskIndexItem } from "@/lib/task-index" ;
57import { taskHasPreview } from "@/lib/html-companions" ;
68import {
@@ -63,6 +65,93 @@ function FacetSection({
6365 ) ;
6466}
6567
68+ const TASKS_PER_PAGE = 15 ;
69+
70+ function parsePageParam ( value : string | null ) {
71+ const parsed = Number . parseInt ( value ?? "1" , 10 ) ;
72+ if ( ! Number . isFinite ( parsed ) || parsed < 1 ) return 1 ;
73+ return parsed ;
74+ }
75+
76+ function PaginationControls ( {
77+ currentPage,
78+ totalPages,
79+ totalItems,
80+ startIndex,
81+ endIndex,
82+ onPageChange
83+ } : {
84+ currentPage : number ;
85+ totalPages : number ;
86+ totalItems : number ;
87+ startIndex : number ;
88+ endIndex : number ;
89+ onPageChange : ( page : number ) => void ;
90+ } ) {
91+ if ( totalPages <= 1 ) return null ;
92+
93+ const pages = Array . from ( { length : totalPages } , ( _ , index ) => index + 1 ) ;
94+
95+ return (
96+ < div className = "tb-frame flex flex-col gap-4 bg-[#fffdf9] px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5" >
97+ < div className = "text-sm text-slate-700" >
98+ Showing < span className = "font-bold text-[#25314d]" > { startIndex + 1 } </ span > -
99+ < span className = "font-bold text-[#25314d]" > { endIndex } </ span > of{ " " }
100+ < span className = "font-bold text-[#25314d]" > { totalItems } </ span > tasks
101+ </ div >
102+
103+ < div className = "flex flex-wrap items-center gap-2" >
104+ < button
105+ type = "button"
106+ className = { clsx (
107+ "tb-focus-ring inline-flex min-w-[112px] items-center justify-center gap-2 rounded-[16px] border-2 border-[#25314d] px-4 py-2 text-sm font-bold text-[#25314d]" ,
108+ currentPage === 1 ? "cursor-not-allowed bg-slate-100 text-slate-400" : "bg-white hover:bg-[#eef8ff]"
109+ ) }
110+ onClick = { ( ) => onPageChange ( currentPage - 1 ) }
111+ disabled = { currentPage === 1 }
112+ >
113+ < IconChevronLeft className = "size-4" />
114+ Previous
115+ </ button >
116+
117+ < div className = "flex flex-wrap items-center gap-2" >
118+ { pages . map ( ( page ) => (
119+ < button
120+ key = { page }
121+ type = "button"
122+ className = { clsx (
123+ "tb-focus-ring min-w-11 rounded-[14px] border-2 px-3 py-2 text-sm font-bold" ,
124+ page === currentPage
125+ ? "border-[#25314d] bg-[#25314d] text-white"
126+ : "border-[#25314d] bg-white text-[#25314d] hover:bg-[#eef8ff]"
127+ ) }
128+ onClick = { ( ) => onPageChange ( page ) }
129+ aria-current = { page === currentPage ? "page" : undefined }
130+ >
131+ { page }
132+ </ button >
133+ ) ) }
134+ </ div >
135+
136+ < button
137+ type = "button"
138+ className = { clsx (
139+ "tb-focus-ring inline-flex min-w-[112px] items-center justify-center gap-2 rounded-[16px] border-2 border-[#25314d] px-4 py-2 text-sm font-bold text-[#25314d]" ,
140+ currentPage === totalPages
141+ ? "cursor-not-allowed bg-slate-100 text-slate-400"
142+ : "bg-white hover:bg-[#eef8ff]"
143+ ) }
144+ onClick = { ( ) => onPageChange ( currentPage + 1 ) }
145+ disabled = { currentPage === totalPages }
146+ >
147+ Next
148+ < IconChevronRight className = "size-4" />
149+ </ button >
150+ </ div >
151+ </ div >
152+ ) ;
153+ }
154+
66155export function GalleryClient ( {
67156 tasks
68157} : {
@@ -72,6 +161,7 @@ export function GalleryClient({
72161 const [ query , setQuery ] = useState < string > ( "" ) ;
73162 const [ selected , setSelected ] = useState < SelectedFacets > ( ( ) => emptySelectedFacets ( ) ) ;
74163 const [ activeRepo , setActiveRepo ] = useState < string | null > ( null ) ;
164+ const [ currentPage , setCurrentPage ] = useState ( 1 ) ;
75165 const [ openFacets , setOpenFacets ] = useState < {
76166 maturity : boolean ;
77167 preview : boolean ;
@@ -91,6 +181,11 @@ export function GalleryClient({
91181 ( ) => filterTasks ( mergedTasks , deferredQuery , selected ) ,
92182 [ deferredQuery , mergedTasks , selected ]
93183 ) ;
184+ const totalPages = Math . max ( 1 , Math . ceil ( filtered . length / TASKS_PER_PAGE ) ) ;
185+ const safeCurrentPage = Math . min ( currentPage , totalPages ) ;
186+ const startIndex = ( safeCurrentPage - 1 ) * TASKS_PER_PAGE ;
187+ const endIndex = Math . min ( startIndex + TASKS_PER_PAGE , filtered . length ) ;
188+ const visibleTasks = filtered . slice ( startIndex , endIndex ) ;
94189 const activeTask = useMemo (
95190 ( ) => mergedTasks . find ( ( task ) => task . repo === activeRepo ) ?? null ,
96191 [ activeRepo , mergedTasks ]
@@ -105,7 +200,41 @@ export function GalleryClient({
105200 selected . modality . size > 0 ||
106201 selected . language . size > 0 ;
107202
203+ useEffect ( ( ) => {
204+ if ( typeof window === "undefined" ) return undefined ;
205+
206+ const syncPageFromLocation = ( ) => {
207+ const params = new URLSearchParams ( window . location . search ) ;
208+ setCurrentPage ( parsePageParam ( params . get ( "page" ) ) ) ;
209+ } ;
210+
211+ syncPageFromLocation ( ) ;
212+ window . addEventListener ( "popstate" , syncPageFromLocation ) ;
213+ return ( ) => window . removeEventListener ( "popstate" , syncPageFromLocation ) ;
214+ } , [ ] ) ;
215+
216+ useEffect ( ( ) => {
217+ if ( typeof window === "undefined" ) return ;
218+
219+ const params = new URLSearchParams ( window . location . search ) ;
220+ if ( safeCurrentPage === 1 ) params . delete ( "page" ) ;
221+ else params . set ( "page" , String ( safeCurrentPage ) ) ;
222+
223+ const queryString = params . toString ( ) ;
224+ const nextUrl = queryString ? `${ window . location . pathname } ?${ queryString } ` : window . location . pathname ;
225+ window . history . replaceState ( null , "" , nextUrl ) ;
226+ } , [ safeCurrentPage ] ) ;
227+
228+ function updatePage ( page : number ) {
229+ setCurrentPage ( Math . max ( 1 , Math . min ( page , totalPages ) ) ) ;
230+ }
231+
232+ function resetToFirstPage ( ) {
233+ if ( safeCurrentPage !== 1 ) updatePage ( 1 ) ;
234+ }
235+
108236 function toggleFacet ( facet : TaskFacet , value : string ) {
237+ resetToFirstPage ( ) ;
109238 setSelected ( ( current ) => {
110239 const next : SelectedFacets = {
111240 maturity : new Set ( current . maturity ) ,
@@ -122,6 +251,7 @@ export function GalleryClient({
122251 }
123252
124253 function clearAll ( ) {
254+ resetToFirstPage ( ) ;
125255 setQuery ( "" ) ;
126256 setSelected ( emptySelectedFacets ( ) ) ;
127257 }
@@ -165,7 +295,10 @@ export function GalleryClient({
165295 < input
166296 id = "task-explorer-search"
167297 value = { query }
168- onChange = { ( event ) => setQuery ( event . target . value ) }
298+ onChange = { ( event ) => {
299+ resetToFirstPage ( ) ;
300+ setQuery ( event . target . value ) ;
301+ } }
169302 placeholder = "Search title, task ID, preview ID, repo..."
170303 className = "tb-focus-ring mt-3 w-full rounded-[18px] border-2 border-[#25314d] bg-white px-4 py-3 text-sm text-[#25314d] placeholder:text-slate-400"
171304 />
@@ -221,6 +354,15 @@ export function GalleryClient({
221354 </ aside >
222355
223356 < section className = "space-y-4 lg:col-span-8 xl:col-span-9" >
357+ < PaginationControls
358+ currentPage = { safeCurrentPage }
359+ totalPages = { totalPages }
360+ totalItems = { filtered . length }
361+ startIndex = { startIndex }
362+ endIndex = { endIndex }
363+ onPageChange = { updatePage }
364+ />
365+
224366 { filtered . length === 0 ? (
225367 < div className = "tb-frame p-10 text-center" >
226368 < div className = "font-heading text-3xl font-bold text-[#25314d]" > No matches</ div >
@@ -234,14 +376,23 @@ export function GalleryClient({
234376 </ div >
235377 </ div >
236378 ) : (
237- filtered . map ( ( task ) => (
379+ visibleTasks . map ( ( task ) => (
238380 < TaskRow
239381 key = { task . repo }
240382 task = { task }
241383 onOpen = { ( nextTask ) => setActiveRepo ( nextTask . repo ) }
242384 />
243385 ) )
244386 ) }
387+
388+ < PaginationControls
389+ currentPage = { safeCurrentPage }
390+ totalPages = { totalPages }
391+ totalItems = { filtered . length }
392+ startIndex = { startIndex }
393+ endIndex = { endIndex }
394+ onPageChange = { updatePage }
395+ />
245396 </ section >
246397 </ div >
247398
0 commit comments