| import { useState } from 'react'; | |
| import { ListFilter } from 'lucide-react'; | |
| import { useSetRecoilState } from 'recoil'; | |
| import { | |
| flexRender, | |
| getCoreRowModel, | |
| getFilteredRowModel, | |
| getPaginationRowModel, | |
| getSortedRowModel, | |
| useReactTable, | |
| } from '@tanstack/react-table'; | |
| import type { | |
| ColumnDef, | |
| SortingState, | |
| VisibilityState, | |
| ColumnFiltersState, | |
| } from '@tanstack/react-table'; | |
| import { FileContext } from 'librechat-data-provider'; | |
| import { | |
| Button, | |
| Input, | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| DropdownMenu, | |
| DropdownMenuCheckboxItem, | |
| DropdownMenuContent, | |
| DropdownMenuTrigger, | |
| TrashIcon, | |
| Spinner, | |
| useMediaQuery, | |
| } from '@librechat/client'; | |
| import type { TFile } from 'librechat-data-provider'; | |
| import type { AugmentedColumnDef } from '~/common'; | |
| import { useDeleteFilesFromTable } from '~/hooks/Files'; | |
| import { useLocalize } from '~/hooks'; | |
| import { cn } from '~/utils'; | |
| import store from '~/store'; | |
| interface DataTableProps<TData, TValue> { | |
| columns: ColumnDef<TData, TValue>[]; | |
| data: TData[]; | |
| } | |
| const contextMap = { | |
| [FileContext.filename]: 'com_ui_name', | |
| [FileContext.updatedAt]: 'com_ui_date', | |
| [FileContext.filterSource]: 'com_ui_storage', | |
| [FileContext.context]: 'com_ui_context', | |
| [FileContext.bytes]: 'com_ui_size', | |
| }; | |
| type Style = { | |
| width?: number | string; | |
| maxWidth?: number | string; | |
| minWidth?: number | string; | |
| zIndex?: number; | |
| }; | |
| export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { | |
| const localize = useLocalize(); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const setFiles = useSetRecoilState(store.filesByIndex(0)); | |
| const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false)); | |
| const [rowSelection, setRowSelection] = useState({}); | |
| const [sorting, setSorting] = useState<SortingState>([]); | |
| const isSmallScreen = useMediaQuery('(max-width: 768px)'); | |
| const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); | |
| const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); | |
| const table = useReactTable({ | |
| data, | |
| columns, | |
| onSortingChange: setSorting, | |
| getCoreRowModel: getCoreRowModel(), | |
| getSortedRowModel: getSortedRowModel(), | |
| onColumnFiltersChange: setColumnFilters, | |
| getFilteredRowModel: getFilteredRowModel(), | |
| onColumnVisibilityChange: setColumnVisibility, | |
| getPaginationRowModel: getPaginationRowModel(), | |
| onRowSelectionChange: setRowSelection, | |
| state: { | |
| sorting, | |
| columnFilters, | |
| columnVisibility, | |
| rowSelection, | |
| }, | |
| }); | |
| return ( | |
| <div className="flex h-full flex-col gap-4"> | |
| <div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4"> | |
| <Button | |
| variant="outline" | |
| onClick={() => { | |
| setIsDeleting(true); | |
| const filesToDelete = table | |
| .getFilteredSelectedRowModel() | |
| .rows.map((row) => row.original); | |
| deleteFiles({ files: filesToDelete as TFile[], setFiles }); | |
| setRowSelection({}); | |
| }} | |
| disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting} | |
| className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')} | |
| > | |
| {isDeleting ? ( | |
| <Spinner className="size-3.5 sm:size-4" /> | |
| ) : ( | |
| <TrashIcon className="size-3.5 text-red-400 sm:size-4" /> | |
| )} | |
| {!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>} | |
| </Button> | |
| <Input | |
| placeholder={localize('com_files_filter')} | |
| value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''} | |
| onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)} | |
| className="flex-1 text-sm" | |
| /> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button | |
| variant="outline" | |
| aria-label={localize('com_files_filter_by')} | |
| className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')} | |
| > | |
| <ListFilter className="size-3.5 sm:size-4" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent | |
| align="end" | |
| className="max-h-[300px] overflow-y-auto dark:border-gray-700 dark:bg-gray-850" | |
| > | |
| {table | |
| .getAllColumns() | |
| .filter((column) => column.getCanHide()) | |
| .map((column) => ( | |
| <DropdownMenuCheckboxItem | |
| key={column.id} | |
| className="cursor-pointer text-sm capitalize dark:text-white dark:hover:bg-gray-800" | |
| checked={column.getIsVisible()} | |
| onCheckedChange={(value) => column.toggleVisibility(Boolean(value))} | |
| > | |
| {localize(contextMap[column.id])} | |
| </DropdownMenuCheckboxItem> | |
| ))} | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| <div className="relative grid h-full max-h-[calc(100vh-20rem)] w-full flex-1 overflow-hidden overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10"> | |
| <Table className="w-full min-w-[300px] border-separate border-spacing-0"> | |
| <TableHeader className="sticky top-0 z-50"> | |
| {table.getHeaderGroups().map((headerGroup) => ( | |
| <TableRow key={headerGroup.id} className="border-b border-border-light"> | |
| {headerGroup.headers.map((header, index) => { | |
| const style: Style = {}; | |
| if (index === 0 && header.id === 'select') { | |
| style.width = '35px'; | |
| style.minWidth = '35px'; | |
| } else if (header.id === 'filename') { | |
| style.width = isSmallScreen ? '60%' : '40%'; | |
| } else { | |
| style.width = isSmallScreen ? '20%' : '15%'; | |
| } | |
| return ( | |
| <TableHead | |
| key={header.id} | |
| className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4" | |
| style={{ ...style }} | |
| > | |
| {header.isPlaceholder | |
| ? null | |
| : flexRender(header.column.columnDef.header, header.getContext())} | |
| </TableHead> | |
| ); | |
| })} | |
| </TableRow> | |
| ))} | |
| </TableHeader> | |
| <TableBody className="w-full"> | |
| {table.getRowModel().rows.length ? ( | |
| table.getRowModel().rows.map((row) => ( | |
| <TableRow | |
| key={row.id} | |
| data-state={row.getIsSelected() && 'selected'} | |
| className="border-b border-border-light transition-colors hover:bg-surface-secondary [tr:last-child_&]:border-b-0" | |
| > | |
| {row.getVisibleCells().map((cell, index) => { | |
| const maxWidth = | |
| (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ?? | |
| 'auto'; | |
| const style: Style = {}; | |
| if (cell.column.id === 'filename') { | |
| style.maxWidth = maxWidth; | |
| } else if (index === 0) { | |
| style.maxWidth = '20px'; | |
| } | |
| return ( | |
| <TableCell | |
| key={cell.id} | |
| className="align-start overflow-x-auto px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm [tr[data-disabled=true]_&]:opacity-50" | |
| style={style} | |
| > | |
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | |
| </TableCell> | |
| ); | |
| })} | |
| </TableRow> | |
| )) | |
| ) : ( | |
| <TableRow> | |
| <TableCell colSpan={columns.length} className="h-24 text-center"> | |
| {localize('com_files_no_results')} | |
| </TableCell> | |
| </TableRow> | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| <div className="flex items-center justify-end gap-2 py-4"> | |
| <div className="ml-2 flex-1 truncate text-xs text-muted-foreground sm:ml-4 sm:text-sm"> | |
| <span className="hidden sm:inline"> | |
| {localize('com_files_number_selected', { | |
| 0: `${table.getFilteredSelectedRowModel().rows.length}`, | |
| 1: `${table.getFilteredRowModel().rows.length}`, | |
| })} | |
| </span> | |
| <span className="sm:hidden"> | |
| {`${table.getFilteredSelectedRowModel().rows.length}/${ | |
| table.getFilteredRowModel().rows.length | |
| }`} | |
| </span> | |
| </div> | |
| <div className="flex items-center space-x-1 pr-2 text-xs font-bold text-text-primary sm:text-sm"> | |
| <span className="hidden sm:inline">{localize('com_ui_page')}</span> | |
| <span>{table.getState().pagination.pageIndex + 1}</span> | |
| <span>/</span> | |
| <span>{table.getPageCount()}</span> | |
| </div> | |
| <Button | |
| className="select-none" | |
| variant="outline" | |
| size="sm" | |
| onClick={() => table.previousPage()} | |
| disabled={!table.getCanPreviousPage()} | |
| > | |
| {localize('com_ui_prev')} | |
| </Button> | |
| <Button | |
| className="select-none" | |
| variant="outline" | |
| size="sm" | |
| onClick={() => table.nextPage()} | |
| disabled={!table.getCanNextPage()} | |
| > | |
| {localize('com_ui_next')} | |
| </Button> | |
| </div> | |
| </div> | |
| ); | |
| } | |