Skip to content

Commit 80c1777

Browse files
feat: Persist Series page table state in URL via nuqs (#129)
* feat: Persist Series page table state in URL via nuqs Pagination, sorting, search, and filters now survive page refresh. Replaces the mixed useSearchParams/useState approach with nuqs useQueryStates for type-safe URL state management. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Update uv.lock to match current version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Sync uv.lock in release-please workflow instead of broken standalone The standalone sync-uv-lock.yml never ran because GITHUB_TOKEN events don't trigger other workflows. Move the lockfile sync into release-please.yml as a second job so it fires reliably after version bumps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address PR review feedback (#129) - Fix page clamping to handle negative values and use pageCount directly - Use searchInput for instant table filtering instead of throttled search - Add useEffect to sync URL-driven search changes into input state - Fix sync-uv-lock job condition to use string comparison instead of negation - Harden PR branch selection with --state open, --base main, --limit 1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6f02538 commit 80c1777

4 files changed

Lines changed: 158 additions & 66 deletions

File tree

.github/workflows/release-please.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,54 @@ jobs:
2222
with:
2323
token: ${{ secrets.GITHUB_TOKEN }}
2424

25+
# Sync uv.lock after release-please bumps pyproject.toml version.
26+
# This runs in the same workflow because GITHUB_TOKEN events don't
27+
# trigger other workflows — the standalone sync-uv-lock.yml never fired.
28+
sync-uv-lock:
29+
needs: release-please
30+
if: ${{ needs.release-please.outputs.release_created != 'true' }}
31+
runs-on: ubuntu-latest
32+
steps:
33+
- name: Find release PR branch
34+
id: pr
35+
env:
36+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37+
GH_REPO: ${{ github.repository }}
38+
run: |
39+
BRANCH=$(
40+
gh pr list \
41+
--state open \
42+
--label "autorelease: pending" \
43+
--base main \
44+
--limit 1 \
45+
--json headRefName \
46+
--jq '.[0].headRefName // empty'
47+
)
48+
if [ -z "$BRANCH" ]; then
49+
echo "found=false" >> "$GITHUB_OUTPUT"
50+
else
51+
echo "found=true" >> "$GITHUB_OUTPUT"
52+
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
53+
fi
54+
55+
- uses: actions/checkout@v6
56+
if: steps.pr.outputs.found == 'true'
57+
with:
58+
ref: ${{ steps.pr.outputs.branch }}
59+
60+
- uses: astral-sh/setup-uv@v7
61+
if: steps.pr.outputs.found == 'true'
62+
63+
- name: Sync uv.lock
64+
if: steps.pr.outputs.found == 'true'
65+
run: |
66+
uv lock
67+
git config user.name "github-actions[bot]"
68+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
69+
git add uv.lock
70+
git diff --cached --quiet || git commit -m "chore: sync uv.lock with pyproject.toml version"
71+
git push
72+
2573
docker-build:
2674
needs: release-please
2775
if: ${{ needs.release-please.outputs.release_created }}

.github/workflows/sync-uv-lock.yml

Lines changed: 0 additions & 33 deletions
This file was deleted.

frontend/src/components/series/SeriesTable.tsx

Lines changed: 109 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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";
33
import {
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";
1321
import { Trash2, Pause, Play, X } from "lucide-react";
1422
import { Input } from "@/components/ui/input";
1523
import { 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";
3139
import {
3240
useBulkDeleteSeries,
3341
useBulkPauseSeries,
@@ -38,6 +46,25 @@ import type { Comic } from "@/types";
3846

3947
const 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+
4168
interface 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">

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)