From 797ee9b7e5d1d0ea21ba350e8fc47edeff9eb603 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 28 Aug 2025 00:47:37 +0800 Subject: [PATCH] feat: add nuqs package and integrate NuqsAdapter in layout and users page components --- package.json | 1 + pnpm-lock.yaml | 31 +++++++++++++++++ src/app/[locale]/layout.tsx | 19 ++++++----- src/components/admin/users-page.tsx | 51 +++++++++++++++++++++------- src/components/admin/users-table.tsx | 32 +++++++++++++---- 5 files changed, 106 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 247b99e..4e9ca5c 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "next-intl": "^4.0.0", "next-safe-action": "^7.10.4", "next-themes": "^0.4.4", + "nuqs": "^2.5.1", "postgres": "^3.4.5", "radix-ui": "^1.4.2", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9af570d..0154037 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: next-themes: specifier: ^0.4.4 version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + nuqs: + specifier: ^2.5.1 + version: 2.5.1(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) postgres: specifier: ^3.4.5 version: 3.4.5 @@ -5388,6 +5391,27 @@ packages: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + nuqs@2.5.1: + resolution: {integrity: sha512-YvAyI01gaEfS6U2iTcfffKccGkqYRnGmLoCHvDjK4ShgtB0tKmYgC7+ez9PmdaiDmrLR+y1qHzfQC66T0VFwWQ==} + 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 + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -11439,6 +11463,13 @@ snapshots: npm-to-yarn@3.0.1: {} + nuqs@2.5.1(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.0.0 + optionalDependencies: + next: 15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + object-assign@4.1.1: {} object-inspect@1.13.4: {} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 0932f26..611e83e 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -12,6 +12,7 @@ import { routing } from '@/i18n/routing'; import { cn } from '@/lib/utils'; import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl'; import { notFound } from 'next/navigation'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import type { ReactNode } from 'react'; import { Toaster } from 'sonner'; import { Providers } from './providers'; @@ -57,15 +58,17 @@ export default async function LocaleLayout({ fontBricolageGrotesque.variable )} > - - - {children} + + + + {children} - - - - - + + + + + + ); diff --git a/src/components/admin/users-page.tsx b/src/components/admin/users-page.tsx index dfd94b0..39f4a7f 100644 --- a/src/components/admin/users-page.tsx +++ b/src/components/admin/users-page.tsx @@ -4,31 +4,56 @@ import { UsersTable } from '@/components/admin/users-table'; import { useUsers } from '@/hooks/use-users'; import type { SortingState } from '@tanstack/react-table'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { + parseAsIndex, + parseAsInteger, + parseAsString, + useQueryStates, +} from 'nuqs'; +import { useMemo } from 'react'; export function UsersPageClient() { const t = useTranslations('Dashboard.admin.users'); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const [search, setSearch] = useState(''); - const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true }, - ]); - const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting); + const [{ page, pageSize, search, sortId, sortDesc }, setQueryStates] = + useQueryStates({ + page: parseAsIndex.withDefault(0), // parseAsIndex adds +1 to URL, so 0-based internally, 1-based in URL + pageSize: parseAsInteger.withDefault(10), + search: parseAsString.withDefault(''), + sortId: parseAsString.withDefault('createdAt'), + sortDesc: parseAsInteger.withDefault(1), + }); + + const sorting: SortingState = useMemo( + () => [{ id: sortId, desc: Boolean(sortDesc) }], + [sortId, sortDesc] + ); + + // page is already 0-based internally thanks to parseAsIndex + const { data, isLoading } = useUsers(page, pageSize, search, sorting); return ( setQueryStates({ search: newSearch, page: 0 })} + onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })} + onPageSizeChange={(newPageSize) => + setQueryStates({ pageSize: newPageSize, page: 0 }) + } + onSortingChange={(newSorting) => { + if (newSorting.length > 0) { + setQueryStates({ + sortId: newSorting[0].id, + sortDesc: newSorting[0].desc ? 1 : 0, + }); + } + }} /> ); } diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index f9f54f6..baf21ee 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -59,6 +59,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Badge } from '../ui/badge'; import { Label } from '../ui/label'; +import { Skeleton } from '../ui/skeleton'; interface DataTableColumnHeaderProps extends React.HTMLAttributes { @@ -116,12 +117,27 @@ function DataTableColumnHeader({ ); } +function TableRowSkeleton({ columns }: { columns: number }) { + return ( + + {Array.from({ length: columns }).map((_, index) => ( + +
+ +
+
+ ))} +
+ ); +} + interface UsersTableProps { data: User[]; total: number; pageIndex: number; pageSize: number; search: string; + sorting?: SortingState; loading?: boolean; onSearch: (search: string) => void; onPageChange: (page: number) => void; @@ -138,6 +154,7 @@ export function UsersTable({ pageIndex, pageSize, search, + sorting = [{ id: 'createdAt', desc: true }], loading, onSearch, onPageChange, @@ -146,9 +163,6 @@ export function UsersTable({ }: UsersTableProps) { const t = useTranslations('Dashboard.admin.users'); const tTable = useTranslations('Common.table'); - const [sorting, setSorting] = useState([ - { id: 'createdAt', desc: true }, - ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); @@ -351,7 +365,6 @@ export function UsersTable({ }, onSortingChange: (updater) => { const next = typeof updater === 'function' ? updater(sorting) : updater; - setSorting(next); onSortingChange?.(next); }, onColumnFiltersChange: setColumnFilters, @@ -444,7 +457,12 @@ export function UsersTable({ ))} - {table.getRowModel().rows?.length ? ( + {loading ? ( + // Show skeleton rows while loading + Array.from({ length: pageSize }).map((_, index) => ( + + )) + ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {loading ? tTable('loading') : tTable('noResults')} + {tTable('noResults')} )} @@ -497,7 +515,7 @@ export function UsersTable({ - {[10, 20, 30, 40, 50].map((pageSize) => ( + {[5, 10, 20, 30, 40, 50].map((pageSize) => ( {pageSize}