feat: add nuqs package and integrate NuqsAdapter in layout and users page components
This commit is contained in:
parent
422c323467
commit
797ee9b7e5
@ -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
31
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user