diff --git a/package.json b/package.json index fd1c3f4..98d362e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "next-cloudinary": "^6.16.0", "next-seo": "^6.8.0", "nextjs-progressbar": "^0.0.16", + "nuqs": "^2.7.2", "postcss": "^8.5.6", "postgres": "^3.4.7", "prettier": "^3.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea436d6..32430ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: nextjs-progressbar: specifier: ^0.0.16 version: 0.0.16(next@15.5.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) + nuqs: + specifier: ^2.7.2 + version: 2.7.2(next@15.5.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -846,6 +849,9 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2229,6 +2235,27 @@ packages: nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + nuqs@2.7.2: + resolution: {integrity: sha512-wOPJoz5om7jMJQick9zU1S/Q+joL+B2DZTZxfCleHEcUzjUnPoujGod4+nAmUWb+G9TwZnyv+mfNqlyfEi8Zag==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^6 || ^7 + react-router-dom: ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + oauth4webapi@3.6.1: resolution: {integrity: sha512-b39+drVyA4aNUptFOhkkmGWnG/BE7dT29SW/8PVYElqp7j/DBqzm5SS1G+MUD07XlTcBOAG+6Cb/35Cx2kHIuQ==} @@ -3373,6 +3400,8 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} + '@standard-schema/spec@1.0.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4858,6 +4887,13 @@ snapshots: nprogress@0.2.0: {} + nuqs@2.7.2(next@15.5.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.1.1 + optionalDependencies: + next: 15.5.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + oauth4webapi@3.6.1: {} object-assign@4.1.1: {} diff --git a/src/app/(public)/_components/search-form.tsx b/src/app/(public)/_components/search-form.tsx index cb52e32..71e5d06 100644 --- a/src/app/(public)/_components/search-form.tsx +++ b/src/app/(public)/_components/search-form.tsx @@ -1,25 +1,29 @@ 'use client'; -import { usePathname, useSearchParams, useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { useForm } from 'react-hook-form'; import { GoX } from 'react-icons/go'; +import { useQueryState } from 'nuqs'; interface FormValues { searchQuery: string; } export function SearchForm() { - const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); + const [searchQuery, setSearchQuery] = useQueryState('q', { + defaultValue: '', + parse: (value: string) => value, + serialize: (value: string) => value || '' + }); const { register, handleSubmit, reset, watch } = useForm({ defaultValues: { - searchQuery: searchParams.get('q') as string + searchQuery: searchQuery } }); - const searchQuery = watch('searchQuery'); + const queryValue = watch('searchQuery'); if (!pathname.startsWith('/repos')) { return null; @@ -28,12 +32,9 @@ export function SearchForm() { function onSubmit({ searchQuery }: FormValues) { if (!pathname.startsWith('/repos')) return; - const reposPathname = pathname as `/repos/${string}`; const trimmedQuery = searchQuery.trim(); if (trimmedQuery !== '') { - const sp = new URLSearchParams(searchParams); - sp.set('q', trimmedQuery); - router.push(`${reposPathname}?${sp.toString()}`); + void setSearchQuery(trimmedQuery); } } @@ -47,11 +48,14 @@ export function SearchForm() { type="text" {...register('searchQuery', { required: true })} /> - {searchQuery && searchQuery.trim() !== '' && ( + {queryValue && queryValue.trim() !== '' && ( diff --git a/src/app/(public)/repos/[language]/page.tsx b/src/app/(public)/repos/[language]/page.tsx index 186eb78..cc9721c 100644 --- a/src/app/(public)/repos/[language]/page.tsx +++ b/src/app/(public)/repos/[language]/page.tsx @@ -64,7 +64,6 @@ export default async function ReposPage({ diff --git a/src/app/(public)/repos/_components/pagination.tsx b/src/app/(public)/repos/_components/pagination.tsx index 5d105a0..07f3faa 100644 --- a/src/app/(public)/repos/_components/pagination.tsx +++ b/src/app/(public)/repos/_components/pagination.tsx @@ -2,33 +2,30 @@ import { Button } from '@/app/(public)/_components/button'; import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react'; -import { useRouter } from 'next/navigation'; import { useTransition } from 'react'; -import type { SearchParams } from '@/types'; +import { useQueryState } from 'nuqs'; const MAX_PER_PAGE = 21; interface PaginationProps { page: number; totalCount: number; - searchParams: SearchParams; } export function Pagination({ page, - totalCount, - searchParams + totalCount }: PaginationProps) { - const router = useRouter(); + const [, setPageParam] = useQueryState('p', { + defaultValue: '1', + parse: (value: string) => value, + serialize: (value: string) => value + }); const [isPending, startTransition] = useTransition(); function changePage(delta: number) { - const params = new URLSearchParams( - Object.entries(searchParams).map(([k, v]) => [k, String(v)]) - ); - params.set('p', String(page + delta)); - + const newPage = page + delta; startTransition(() => { - router.push(`?${params.toString()}`); + void setPageParam(String(newPage)); }); } diff --git a/src/app/(public)/repos/_components/sorter.tsx b/src/app/(public)/repos/_components/sorter.tsx index 545c35c..31f4718 100644 --- a/src/app/(public)/repos/_components/sorter.tsx +++ b/src/app/(public)/repos/_components/sorter.tsx @@ -4,8 +4,9 @@ import { Button } from '@/app/(public)/_components/button'; import { ArrowUpAZ, Code } from 'lucide-react'; import Link from 'next/link'; import languages from '@/assets/languages.json'; -import { usePathname, useSearchParams } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { sortByName } from '@/lib/utils'; +import { useQueryStates, useQueryState } from 'nuqs'; const { mainLanguages } = languages; @@ -23,88 +24,74 @@ enum SortTypes { type Pathname = '/repos' | `/repos/${string}`; export function Sorter() { - const searchParams = useSearchParams(); const pathname = usePathname() as Pathname; + const [sortField] = useQueryState('s', { defaultValue: '' }); + const [sortOrder] = useQueryState('o', { defaultValue: 'desc' }); + const [languages_] = useQueryState('l', { parse: (value: string) => value.split(',').filter(Boolean), serialize: (value: string[]) => value.join(',') }); const navigationItems = [ { name: 'Best match', - onSelect(sp: URLSearchParams) { - sp.delete('o'); - sp.delete('s'); - return sp; + onSelect(): { s: string | null; o: string | null } { + return { s: null, o: null }; } }, { name: 'Most stars', - onSelect(sp: URLSearchParams) { - sp.set('s', 'stars'); - sp.set('o', 'desc'); - return sp; + onSelect() { + return { s: 'stars', o: 'desc' }; } }, { name: 'Fewest stars', - onSelect(sp: URLSearchParams) { - sp.set('s', 'stars'); - sp.set('o', 'asc'); - return sp; + onSelect() { + return { s: 'stars', o: 'asc' }; } }, { name: 'Most forks', - onSelect(sp: URLSearchParams) { - sp.set('s', 'forks'); - sp.set('o', 'desc'); - return sp; + onSelect() { + return { s: 'forks', o: 'desc' }; } }, { name: 'Fewest forks', - onSelect(sp: URLSearchParams) { - sp.set('s', 'forks'); - sp.set('o', 'asc'); - return sp; + onSelect() { + return { s: 'forks', o: 'asc' }; } }, { name: 'Most help wanted issues', - onSelect(sp: URLSearchParams) { - sp.set('s', 'help-wanted-issues'); - sp.set('o', 'desc'); - return sp; + onSelect() { + return { s: 'help-wanted-issues', o: 'desc' }; } }, { name: 'Recently updated', - onSelect(sp: URLSearchParams) { - sp.set('s', 'updated'); - sp.set('o', 'desc'); - return sp; + onSelect() { + return { s: 'updated', o: 'desc' }; } }, { name: 'Least recently updated', - onSelect(sp: URLSearchParams) { - sp.set('s', 'updated'); - sp.set('o', 'asc'); - return sp; + onSelect() { + return { s: 'updated', o: 'asc' }; } } ]; function selectedSort(): SortTypes { - if (searchParams.get('o') === 'asc') { - if (searchParams.get('s') === 'stars') return SortTypes.FewestStars; - if (searchParams.get('s') === 'forks') return SortTypes.FewestForks; - if (searchParams.get('s') === 'updated') + if (sortOrder === 'asc') { + if (sortField === 'stars') return SortTypes.FewestStars; + if (sortField === 'forks') return SortTypes.FewestForks; + if (sortField === 'updated') return SortTypes.LeastRecentlyUpdated; return SortTypes.BestMatch; - } else if (searchParams.get('o') === 'desc') { - if (searchParams.get('s') === 'stars') return SortTypes.MostStars; - if (searchParams.get('s') === 'forks') return SortTypes.MostForks; - if (searchParams.get('s') === 'updated') return SortTypes.RecentlyUpdated; - if (searchParams.get('s') === 'help-wanted-issues') + } else if (sortOrder === 'desc') { + if (sortField === 'stars') return SortTypes.MostStars; + if (sortField === 'forks') return SortTypes.MostForks; + if (sortField === 'updated') return SortTypes.RecentlyUpdated; + if (sortField === 'help-wanted-issues') return SortTypes.MostHelpWantedIssues; return SortTypes.BestMatch; } else { @@ -127,12 +114,17 @@ export function Sorter() {
    {mainLanguages.sort(sortByName).map(language => { - const sp = new URLSearchParams(searchParams); - sp.delete('p'); + const languageParams = `l=${language.toLowerCase()}`; + const currentParams = new URLSearchParams(); + if (sortField) currentParams.set('s', sortField); + if (sortOrder) currentParams.set('o', sortOrder); + const queryString = currentParams.toString(); + const fullQuery = queryString ? `${languageParams}&${queryString}` : languageParams; + return (
  • {language} @@ -151,16 +143,19 @@ export function Sorter() {
      {navigationItems.map((item, index) => { - const sp = item.onSelect(new URLSearchParams(searchParams)); - sp.delete('p'); - if (item.name === SortTypes.BestMatch) { - sp.delete('o'); - sp.delete('s'); + const { s, o } = item.onSelect(); + const currentParams = new URLSearchParams(); + if (s) currentParams.set('s', s); + if (o) currentParams.set('o', o); + if (languages_ && languages_.length > 0) { + currentParams.set('l', languages_.join(',')); } + const queryString = currentParams.toString(); + return (
    • {item.name} diff --git a/src/app/(public)/repos/_components/stars-filter.tsx b/src/app/(public)/repos/_components/stars-filter.tsx index d2fcc1d..407a58f 100644 --- a/src/app/(public)/repos/_components/stars-filter.tsx +++ b/src/app/(public)/repos/_components/stars-filter.tsx @@ -2,12 +2,11 @@ import { useParams, - usePathname, - useRouter, - useSearchParams + usePathname } from 'next/navigation'; import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { useQueryStates } from 'nuqs'; interface FormValues { startStars: number | ''; @@ -17,18 +16,18 @@ interface FormValues { type Pathname = '/repos' | `/repos/${string}`; export function StarsFilter() { - const router = useRouter(); const pathname = usePathname() as Pathname; - const searchParams = useSearchParams(); const params = useParams(); + const [{ startStars, endStars, p }, setStarsParams] = useQueryStates({ + startStars: { parse: (value: string) => (value ? +value : ''), serialize: (value: number | '') => value === '' ? '' : String(value) }, + endStars: { parse: (value: string) => (value ? +value : ''), serialize: (value: number | '') => value === '' ? '' : String(value) }, + p: { parse: (value: string) => (value ? +value : 1), serialize: (value: number) => String(value) } + }); + const { handleSubmit, control, reset } = useForm({ defaultValues: { - startStars: !searchParams.get('startStars') - ? '' - : +(searchParams.get('startStars') as string), - endStars: !searchParams.get('endStars') - ? '' - : +(searchParams.get('endStars') as string) + startStars: startStars === '' ? '' : startStars || '', + endStars: endStars === '' ? '' : endStars || '' } }); @@ -36,21 +35,24 @@ export function StarsFilter() { useEffect(() => reset(), [params.language]); function onSubmit({ startStars, endStars }: FormValues) { - const sp = new URLSearchParams(searchParams); if ( typeof endStars === 'number' && typeof startStars === 'number' && endStars < startStars ) { reset({ startStars, endStars: '' }); - sp.delete('endStars'); - sp.set('startStars', startStars.toString()); + void setStarsParams({ + startStars, + endStars: '', + p: 1 + }); } else { - sp.set('startStars', startStars.toString()); - sp.set('endStars', endStars.toString()); + void setStarsParams({ + startStars: typeof startStars === 'number' ? startStars : '', + endStars: typeof endStars === 'number' ? endStars : '', + p: 1 + }); } - sp.delete('p'); - router.push(`${pathname}?${sp.toString()}`); } return ( diff --git a/src/app/(public)/repos/page.tsx b/src/app/(public)/repos/page.tsx index 3b03988..7b5466c 100644 --- a/src/app/(public)/repos/page.tsx +++ b/src/app/(public)/repos/page.tsx @@ -64,7 +64,6 @@ export default async function ReposPage({