🎉 chore(init): project initialization

This commit is contained in:
web@ppanel
2024-11-14 01:22:43 +07:00
commit 829edfa824
479 changed files with 61413 additions and 0 deletions
@@ -0,0 +1,58 @@
'use client';
import { Input } from '@shadcn/ui/input';
import { Table } from '@tanstack/react-table';
import { Combobox } from '../combobox';
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
filters?: any;
}
export function ColumnFilter<TData>({ table, params, filters }: ColumnFilterProps<TData>) {
const updateFilter = (key: string, value: any) => {
table.setColumnFilters((prev) => {
const newFilters = prev.filter((filter) => filter.id !== key);
if (value) {
newFilters.push({ id: key, value });
}
return newFilters;
});
};
return (
<div className='flex gap-2'>
{params.map((param) => {
if (param.options) {
return (
<Combobox
key={param.key}
className='w-32'
placeholder={param.placeholder || 'Choose...'}
value={filters[param.key] || ''}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
/>
);
}
return (
<Input
key={param.key}
className='w-32'
placeholder={param.placeholder || 'Search...'}
value={filters[param.key] || ''}
onChange={(event) => updateFilter(param.key, event.target.value)}
/>
);
})}
</div>
);
}
@@ -0,0 +1,76 @@
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@shadcn/ui/dropdown-menu';
import { cn } from '@shadcn/ui/lib/utils';
import { flexRender, Header } from '@tanstack/react-table';
interface ColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
header: Header<TData, TValue>;
text?: Partial<{
asc: string;
desc: string;
hide: string;
}>;
}
export function ColumnHeader<TData, TValue>({
header,
className,
text,
}: ColumnHeaderProps<TData, TValue>) {
const column = header.column;
const title = header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext());
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn('flex w-full items-center', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-full justify-start !bg-transparent p-0 text-sm'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
{text?.asc || 'ASC'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
{text?.desc || 'DESC'}
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
Hide
{text?.hide || 'Hide'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,47 @@
'use client';
import { MixerHorizontalIcon } from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@shadcn/ui/dropdown-menu';
import { Table } from '@tanstack/react-table';
import { ReactNode } from 'react';
interface ColumnToggleProps<TData> {
table: Table<TData>;
}
export function ColumnToggle<TData>({ table }: ColumnToggleProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='icon'>
<MixerHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'>
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map((column) => {
const columns = table.getAllColumns().filter((item) => item.getIsVisible());
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
disabled={columns.length === 1 && columns?.[0]?.id === column.id}
>
{column.columnDef.header as ReactNode}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+1
View File
@@ -0,0 +1 @@
export { ProTable, type ProTableActions, type ProTableProps } from './pro-table';
+105
View File
@@ -0,0 +1,105 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
import { Table } from '@tanstack/react-table';
interface PaginationText {
textRowsPerPage?: string;
textPageOf?: (pageIndex: number, pageCount: number) => string;
}
interface PaginationProps<TData> {
table: Table<TData>;
text?: PaginationText;
}
export function Pagination<TData>({ table, text }: PaginationProps<TData>) {
return (
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground flex-1 whitespace-nowrap'>
{text?.textPageOf?.(table.getState().pagination.pageIndex + 1, table.getPageCount()) ||
`Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
</div>
<div className='flex items-center gap-2'>
<div className='flex items-center space-x-2'>
<p className='font-medium'>{text?.textRowsPerPage || 'Rows per page'}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant='outline'
size='icon'
className='hidden lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Select
value={`${table.getState().pagination.pageIndex + 1}`}
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
>
<SelectTrigger className='w-[70px]'>
<SelectValue placeholder='Select page number' />
</SelectTrigger>
<SelectContent className='w-12'>
{Array.from({ length: table.getPageCount() }, (_, i) => (
<SelectItem key={i} value={`${i + 1}`}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant='outline'
size='icon'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='hidden lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@shadcn/ui/alert';
import { Button } from '@shadcn/ui/button';
import { Checkbox } from '@shadcn/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@shadcn/ui/table';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useSize } from 'ahooks';
import { ListRestart, Loader, RefreshCcw } from 'lucide-react';
import React, { Fragment, useEffect, useImperativeHandle, useRef, useState } from 'react';
import Empty from '../empty';
import { ColumnFilter, IParams } from './column-filter';
import { ColumnHeader } from './column-header';
import { ColumnToggle } from './column-toggle';
import { Pagination } from './pagination';
export interface ProTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
request: (
pagination: {
page: number;
size: number;
},
filter: TValue,
) => Promise<{ list: TData[]; total: number }>;
params?: IParams[];
header?: {
title?: React.ReactNode;
toolbar?: React.ReactNode | React.ReactNode[];
};
actions?: {
render?: (row: TData) => React.ReactNode[];
batchRender?: (rows: TData[]) => React.ReactNode[];
};
action?: React.Ref<ProTableActions | undefined>;
texts?: Partial<{
actions: string;
asc: string;
desc: string;
hide: string;
textRowsPerPage: string;
textPageOf: (current: number, total: number) => string;
selectedRowsText: (total: number) => string;
}>;
}
export interface ProTableActions {
refresh: () => void;
reset: () => void;
}
export function ProTable<TData, TValue extends Record<string, unknown>>({
columns,
request,
params,
header,
actions,
action,
texts,
}: ProTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [data, setData] = useState<TData[]>([]);
const [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 50,
});
const [loading, setLoading] = useState(false);
const table = useReactTable({
data,
columns: [
...(actions?.batchRender ? [createSelectColumn<TData, TValue>()] : []),
...columns,
...(actions?.render
? ([
{
id: 'actions',
header: texts?.actions,
cell: ({ row }) => (
<div className='flex items-center justify-end gap-2'>
{actions
?.render?.(row.original)
.map((item, index) => <Fragment key={index}>{item}</Fragment>)}
</div>
),
enableSorting: false,
enableHiding: false,
},
] as ColumnDef<TData, TValue>[])
: []),
],
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
manualPagination: true,
manualFiltering: true,
rowCount: rowCount,
});
const fetchData = async () => {
setLoading(true);
try {
const response = await request(
{
page: pagination.pageIndex + 1,
size: pagination.pageSize,
},
Object.fromEntries(columnFilters.map((item) => [item.id, item.value])) as TValue,
);
setData(response.list);
setRowCount(response.total);
} catch (error) {
console.log('Fetch data error:', error);
} finally {
setLoading(false);
}
};
const reset = async () => {
table.resetSorting();
table.resetColumnFilters();
table.resetGlobalFilter(true);
table.resetColumnVisibility();
table.resetRowSelection();
table.resetPagination();
};
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
useImperativeHandle(action, () => ({
refresh: fetchData,
reset,
}));
useEffect(() => {
fetchData();
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original);
const selectedCount = selectedRows.length;
return (
<div className='flex flex-col gap-4' ref={ref}>
<div className='flex flex-wrap-reverse items-center justify-between gap-4'>
<div>
{params ? (
<ColumnFilter
table={table}
params={params}
filters={Object.fromEntries(columnFilters.map((item) => [item.id, item.value]))}
/>
) : (
header?.title
)}
</div>
<div className='flex flex-1 items-center justify-end gap-2'>
<Button variant='outline' size='icon' onClick={fetchData}>
<RefreshCcw />
</Button>
<ColumnToggle table={table} />
<Button variant='outline' size='icon' onClick={reset}>
<ListRestart />
</Button>
{header?.toolbar}
</div>
</div>
{selectedCount > 0 && actions?.batchRender && (
<Alert className='flex items-center justify-between'>
<AlertTitle className='m-0'>
{texts?.selectedRowsText?.(selectedCount) || `Selected ${selectedCount} rows`}
</AlertTitle>
<AlertDescription className='flex gap-2'>
{actions.batchRender(selectedRows)}
</AlertDescription>
</Alert>
)}
<div
className='relative w-auto overflow-x-auto rounded-md border'
style={{
width: size?.width,
}}
>
<Table className='w-full'>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className={getTableHeaderClass(header.column.id)}>
<ColumnHeader
header={header}
text={{
asc: texts?.asc,
desc: texts?.desc,
hide: texts?.hide,
}}
/>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={getTableCellClass(cell.column.id)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length + 2} className='py-24'>
<Empty />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{loading && (
<div className='bg-muted/80 absolute top-0 z-20 flex h-full w-full items-center justify-center'>
<Loader className='h-4 w-4 animate-spin' />
</div>
)}
</div>
{rowCount > 0 && (
<Pagination
table={table}
text={{
textRowsPerPage: texts?.textRowsPerPage,
textPageOf: texts?.textPageOf,
}}
/>
)}
</div>
);
}
function createSelectColumn<TData, TValue>(): ColumnDef<TData, TValue> {
return {
id: 'selected',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
};
}
function getTableHeaderClass(columnId: string) {
if (columnId === 'selected') {
return 'sticky left-0 z-10 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] [&:has([role=checkbox])]:pr-2';
} else if (columnId === 'actions') {
return 'sticky right-0 z-10 text-right bg-background shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]';
}
return 'truncate';
}
function getTableCellClass(columnId: string) {
if (columnId === 'selected') {
return 'sticky left-0 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]';
} else if (columnId === 'actions') {
return 'sticky right-0 bg-background shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]';
}
return 'truncate';
}