feat: add nuqs package and integrate NuqsAdapter in layout and users page components

This commit is contained in:
javayhu 2025-08-28 00:47:37 +08:00
parent 422c323467
commit 797ee9b7e5
5 changed files with 106 additions and 28 deletions

View File

@ -111,6 +111,7 @@
"next-intl": "^4.0.0", "next-intl": "^4.0.0",
"next-safe-action": "^7.10.4", "next-safe-action": "^7.10.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"nuqs": "^2.5.1",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"radix-ui": "^1.4.2", "radix-ui": "^1.4.2",
"react": "^19.0.0", "react": "^19.0.0",

31
pnpm-lock.yaml generated
View File

@ -266,6 +266,9 @@ importers:
next-themes: next-themes:
specifier: ^0.4.4 specifier: ^0.4.4
version: 0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 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: postgres:
specifier: ^3.4.5 specifier: ^3.4.5
version: 3.4.5 version: 3.4.5
@ -5388,6 +5391,27 @@ packages:
resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 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: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -11439,6 +11463,13 @@ snapshots:
npm-to-yarn@3.0.1: {} 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-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}

View File

@ -12,6 +12,7 @@ import { routing } from '@/i18n/routing';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl'; import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { Providers } from './providers'; import { Providers } from './providers';
@ -57,15 +58,17 @@ export default async function LocaleLayout({
fontBricolageGrotesque.variable fontBricolageGrotesque.variable
)} )}
> >
<NextIntlClientProvider> <NuqsAdapter>
<Providers locale={locale}> <NextIntlClientProvider>
{children} <Providers locale={locale}>
{children}
<Toaster richColors position="top-right" offset={64} /> <Toaster richColors position="top-right" offset={64} />
<TailwindIndicator /> <TailwindIndicator />
<Analytics /> <Analytics />
</Providers> </Providers>
</NextIntlClientProvider> </NextIntlClientProvider>
</NuqsAdapter>
</body> </body>
</html> </html>
); );

View File

@ -4,31 +4,56 @@ import { UsersTable } from '@/components/admin/users-table';
import { useUsers } from '@/hooks/use-users'; import { useUsers } from '@/hooks/use-users';
import type { SortingState } from '@tanstack/react-table'; import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import {
parseAsIndex,
parseAsInteger,
parseAsString,
useQueryStates,
} from 'nuqs';
import { useMemo } from 'react';
export function UsersPageClient() { export function UsersPageClient() {
const t = useTranslations('Dashboard.admin.users'); const t = useTranslations('Dashboard.admin.users');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [sorting, setSorting] = useState<SortingState>([
{ 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 ( return (
<UsersTable <UsersTable
data={data?.items || []} data={data?.items || []}
total={data?.total || 0} total={data?.total || 0}
pageIndex={pageIndex} pageIndex={page}
pageSize={pageSize} pageSize={pageSize}
search={search} search={search}
sorting={sorting}
loading={isLoading} loading={isLoading}
onSearch={setSearch} onSearch={(newSearch) => setQueryStates({ search: newSearch, page: 0 })}
onPageChange={setPageIndex} onPageChange={(newPageIndex) => setQueryStates({ page: newPageIndex })}
onPageSizeChange={setPageSize} onPageSizeChange={(newPageSize) =>
onSortingChange={setSorting} setQueryStates({ pageSize: newPageSize, page: 0 })
}
onSortingChange={(newSorting) => {
if (newSorting.length > 0) {
setQueryStates({
sortId: newSorting[0].id,
sortDesc: newSorting[0].desc ? 1 : 0,
});
}
}}
/> />
); );
} }

View File

@ -59,6 +59,7 @@ import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Skeleton } from '../ui/skeleton';
interface DataTableColumnHeaderProps<TData, TValue> interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
@ -116,12 +117,27 @@ function DataTableColumnHeader<TData, TValue>({
); );
} }
function TableRowSkeleton({ columns }: { columns: number }) {
return (
<TableRow>
{Array.from({ length: columns }).map((_, index) => (
<TableCell key={index} className="py-4">
<div className="flex items-center gap-2 pl-3">
<Skeleton className="h-6 w-full max-w-32" />
</div>
</TableCell>
))}
</TableRow>
);
}
interface UsersTableProps { interface UsersTableProps {
data: User[]; data: User[];
total: number; total: number;
pageIndex: number; pageIndex: number;
pageSize: number; pageSize: number;
search: string; search: string;
sorting?: SortingState;
loading?: boolean; loading?: boolean;
onSearch: (search: string) => void; onSearch: (search: string) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
@ -138,6 +154,7 @@ export function UsersTable({
pageIndex, pageIndex,
pageSize, pageSize,
search, search,
sorting = [{ id: 'createdAt', desc: true }],
loading, loading,
onSearch, onSearch,
onPageChange, onPageChange,
@ -146,9 +163,6 @@ export function UsersTable({
}: UsersTableProps) { }: UsersTableProps) {
const t = useTranslations('Dashboard.admin.users'); const t = useTranslations('Dashboard.admin.users');
const tTable = useTranslations('Common.table'); const tTable = useTranslations('Common.table');
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
@ -351,7 +365,6 @@ export function UsersTable({
}, },
onSortingChange: (updater) => { onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater; const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
onSortingChange?.(next); onSortingChange?.(next);
}, },
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
@ -444,7 +457,12 @@ export function UsersTable({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {loading ? (
// Show skeleton rows while loading
Array.from({ length: pageSize }).map((_, index) => (
<TableRowSkeleton key={index} columns={columns.length} />
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
@ -466,7 +484,7 @@ export function UsersTable({
colSpan={columns.length} colSpan={columns.length}
className="h-24 text-center" className="h-24 text-center"
> >
{loading ? tTable('loading') : tTable('noResults')} {tTable('noResults')}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -497,7 +515,7 @@ export function UsersTable({
<SelectValue placeholder={pageSize} /> <SelectValue placeholder={pageSize} />
</SelectTrigger> </SelectTrigger>
<SelectContent side="top"> <SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => ( {[5, 10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}> <SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize} {pageSize}
</SelectItem> </SelectItem>