1- import { useState , useMemo , useCallback } from "react" ;
2- import { useNavigate , useSearchParams } from "react-router-dom" ;
1+ import { useState , useMemo , useEffect } from "react" ;
2+ import { useNavigate } from "react-router-dom" ;
33import {
44 useReactTable ,
55 getCoreRowModel ,
@@ -10,6 +10,14 @@ import {
1010 type SortingState ,
1111 type RowSelectionState ,
1212} from "@tanstack/react-table" ;
13+ import {
14+ useQueryState ,
15+ useQueryStates ,
16+ parseAsInteger ,
17+ parseAsString ,
18+ parseAsStringLiteral ,
19+ createParser ,
20+ } from "nuqs" ;
1321import { Trash2 , Pause , Play , X } from "lucide-react" ;
1422import { Input } from "@/components/ui/input" ;
1523import { Button } from "@/components/ui/button" ;
@@ -27,7 +35,7 @@ import SeriesFilters, {
2735 type ProgressFilter ,
2836 type StatusFilter ,
2937} from "./SeriesFilters" ;
30- import { useDebounce } from "@/hooks/use-debounce " ;
38+ import { SORT_DELIMITER } from "@/lib/delimiters " ;
3139import {
3240 useBulkDeleteSeries ,
3341 useBulkPauseSeries ,
@@ -38,6 +46,25 @@ import type { Comic } from "@/types";
3846
3947const columnHelper = createColumnHelper < Comic > ( ) ;
4048
49+ const sortParser = createParser ( {
50+ parse ( value : string ) {
51+ const [ id , direction ] = value . split ( SORT_DELIMITER ) ;
52+ if ( ! id ) return null ;
53+ return { id, desc : direction === "desc" } ;
54+ } ,
55+ serialize ( value : { id : string ; desc : boolean } ) {
56+ return `${ value . id } ${ SORT_DELIMITER } ${ value . desc ? "desc" : "asc" } ` ;
57+ } ,
58+ } ) ;
59+
60+ const seriesParams = {
61+ page : parseAsInteger . withDefault ( 0 ) ,
62+ sort : sortParser ,
63+ type : parseAsStringLiteral ( [ "comic" , "manga" ] as const ) ,
64+ progress : parseAsStringLiteral ( [ "0" , "partial" , "100" ] as const ) ,
65+ status : parseAsStringLiteral ( [ "Active" , "Paused" , "Ended" ] as const ) ,
66+ } ;
67+
4168interface SeriesTableProps {
4269 data ?: Comic [ ] ;
4370 isLoading ?: boolean ;
@@ -61,34 +88,39 @@ export default function SeriesTable({
6188 isLoading,
6289} : SeriesTableProps ) {
6390 const navigate = useNavigate ( ) ;
64- const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
65- const [ sorting , setSorting ] = useState < SortingState > ( [ ] ) ;
66- const [ globalFilter , setGlobalFilter ] = useState ( "" ) ;
91+ const [ params , setParams ] = useQueryStates ( seriesParams , {
92+ history : "replace" ,
93+ } ) ;
94+ const [ search , setSearch ] = useQueryState (
95+ "search" ,
96+ parseAsString . withDefault ( "" ) . withOptions ( {
97+ history : "replace" ,
98+ throttleMs : 300 ,
99+ } ) ,
100+ ) ;
101+ const [ searchInput , setSearchInput ] = useState ( search ) ;
102+
103+ // Sync URL-driven search changes (e.g. browser back/forward) into the input
104+ useEffect ( ( ) => {
105+ setSearchInput ( search ) ;
106+ } , [ search ] ) ;
107+
67108 const [ rowSelection , setRowSelection ] = useState < RowSelectionState > ( { } ) ;
68109 const [ confirmDelete , setConfirmDelete ] = useState ( false ) ;
69- const debouncedFilter = useDebounce ( globalFilter , 300 ) ;
70110
71111 const bulkDeleteMutation = useBulkDeleteSeries ( ) ;
72112 const bulkPauseMutation = useBulkPauseSeries ( ) ;
73113 const bulkResumeMutation = useBulkResumeSeries ( ) ;
74114 const { addToast } = useToast ( ) ;
75115
76- const typeFilter = ( searchParams . get ( "type" ) as TypeFilter ) || "all" ;
77- const progressFilter =
78- ( searchParams . get ( "progress" ) as ProgressFilter ) || "all" ;
79- const statusFilter = ( searchParams . get ( "status" ) as StatusFilter ) || "all" ;
80-
81- const updateFilter = useCallback (
82- ( key : string , value : string ) => {
83- const params = new URLSearchParams ( searchParams ) ;
84- if ( value === "all" ) {
85- params . delete ( key ) ;
86- } else {
87- params . set ( key , value ) ;
88- }
89- setSearchParams ( params , { replace : true } ) ;
90- } ,
91- [ searchParams , setSearchParams ] ,
116+ const typeFilter : TypeFilter = params . type ?? "all" ;
117+ const progressFilter : ProgressFilter = params . progress ?? "all" ;
118+ const statusFilter : StatusFilter = params . status ?? "all" ;
119+
120+ const sorting : SortingState = params . sort ? [ params . sort ] : [ ] ;
121+ const pagination = useMemo (
122+ ( ) => ( { pageIndex : params . page , pageSize : 20 } ) ,
123+ [ params . page ] ,
92124 ) ;
93125
94126 const filterCounts = useMemo ( ( ) => {
@@ -279,9 +311,24 @@ export default function SeriesTable({
279311 const table = useReactTable ( {
280312 data : filteredData ,
281313 columns,
282- state : { sorting, globalFilter : debouncedFilter , rowSelection } ,
283- onSortingChange : setSorting ,
284- onGlobalFilterChange : setGlobalFilter ,
314+ state : { sorting, globalFilter : searchInput , rowSelection, pagination } ,
315+ onSortingChange : ( updaterOrValue ) => {
316+ const newSorting =
317+ typeof updaterOrValue === "function"
318+ ? updaterOrValue ( sorting )
319+ : updaterOrValue ;
320+ setParams ( {
321+ sort : newSorting . length > 0 ? newSorting [ 0 ] : null ,
322+ page : null ,
323+ } ) ;
324+ } ,
325+ onPaginationChange : ( updaterOrValue ) => {
326+ const newPagination =
327+ typeof updaterOrValue === "function"
328+ ? updaterOrValue ( pagination )
329+ : updaterOrValue ;
330+ setParams ( { page : newPagination . pageIndex } ) ;
331+ } ,
285332 onRowSelectionChange : ( updater ) => {
286333 setConfirmDelete ( false ) ;
287334 setRowSelection ( updater ) ;
@@ -292,9 +339,20 @@ export default function SeriesTable({
292339 getFilteredRowModel : getFilteredRowModel ( ) ,
293340 getPaginationRowModel : getPaginationRowModel ( ) ,
294341 enableRowSelection : true ,
295- initialState : { pagination : { pageSize : 20 } } ,
296342 } ) ;
297343
344+ const pageCount = table . getPageCount ( ) ;
345+
346+ // Clamp page to valid range (handles negative values and out-of-bounds)
347+ useEffect ( ( ) => {
348+ const maxPage = Math . max ( 0 , pageCount - 1 ) ;
349+ const clampedPage = Math . min ( Math . max ( params . page , 0 ) , maxPage ) ;
350+
351+ if ( clampedPage !== params . page ) {
352+ setParams ( { page : clampedPage === 0 ? null : clampedPage } ) ;
353+ }
354+ } , [ pageCount , params . page , setParams ] ) ;
355+
298356 if ( isLoading ) {
299357 return (
300358 < div className = "space-y-4" >
@@ -317,16 +375,35 @@ export default function SeriesTable({
317375 typeFilter = { typeFilter }
318376 progressFilter = { progressFilter }
319377 statusFilter = { statusFilter }
320- onTypeChange = { ( value ) => updateFilter ( "type" , value ) }
321- onProgressChange = { ( value ) => updateFilter ( "progress" , value ) }
322- onStatusChange = { ( value ) => updateFilter ( "status" , value ) }
378+ onTypeChange = { ( value ) =>
379+ setParams ( {
380+ type : value === "all" ? null : value ,
381+ page : null ,
382+ } )
383+ }
384+ onProgressChange = { ( value ) =>
385+ setParams ( {
386+ progress : value === "all" ? null : value ,
387+ page : null ,
388+ } )
389+ }
390+ onStatusChange = { ( value ) =>
391+ setParams ( {
392+ status : value === "all" ? null : value ,
393+ page : null ,
394+ } )
395+ }
323396 counts = { filterCounts }
324397 />
325398 < div className = "flex items-center gap-2" >
326399 < Input
327400 placeholder = "Search series..."
328- value = { globalFilter ?? "" }
329- onChange = { ( e ) => setGlobalFilter ( e . target . value ) }
401+ value = { searchInput }
402+ onChange = { ( e ) => {
403+ setSearchInput ( e . target . value ) ;
404+ setSearch ( e . target . value || null ) ;
405+ setParams ( { page : null } ) ;
406+ } }
330407 className = "w-[200px]"
331408 />
332409 < span className = "text-sm text-muted-foreground whitespace-nowrap" >
0 commit comments