diff --git a/inertia/components/common/icon_button.tsx b/inertia/components/common/icon_button.tsx new file mode 100644 index 0000000..6ca411e --- /dev/null +++ b/inertia/components/common/icon_button.tsx @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +const IconButton = styled.button(({ theme }) => ({ + cursor: 'pointer', + height: '2rem', + width: '2rem', + fontSize: '1rem', + color: theme.colors.font, + backgroundColor: theme.colors.grey, + borderRadius: '50%', + border: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + '&:disabled': { + cursor: 'not-allowed', + opacity: 0.15, + }, +})); + +export default IconButton; diff --git a/inertia/components/common/table.tsx b/inertia/components/common/table.tsx new file mode 100644 index 0000000..7f22809 --- /dev/null +++ b/inertia/components/common/table.tsx @@ -0,0 +1,218 @@ +import styled from '@emotion/styled'; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + useReactTable, +} from '@tanstack/react-table'; +import { useState } from 'react'; +import IconButton from '~/components/common/icon_button'; + +import { + MdKeyboardArrowLeft, + MdKeyboardArrowRight, + MdKeyboardDoubleArrowLeft, + MdKeyboardDoubleArrowRight, +} from 'react-icons/md'; +import Input from '~/components/common/form/_input'; + +const TablePageFooter = styled.div({ + display: 'flex', + gap: '1em', + alignItems: 'center', +}); + +const Box = styled(TablePageFooter)({ + gap: '0.35em', +}); + +const Resizer = styled.div<{ isResizing: boolean }>( + ({ theme, isResizing }) => ({ + cursor: 'col-resize', + userSelect: 'none', + touchAction: 'none', + position: 'absolute', + right: 0, + top: 0, + height: '100%', + width: '5px', + opacity: !isResizing ? 0 : 1, + background: !isResizing ? theme.colors.white : theme.colors.primary, + '&:hover': { + opacity: 0.5, + }, + }) +); + +type TableProps = { + columns: ColumnDef[]; + data: T[]; +}; + +export default function Table({ columns, data }: TableProps) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const table = useReactTable({ + data, + columns, + enableColumnResizing: true, + columnResizeMode: 'onChange', + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + debugTable: true, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table + .getRowModel() + .rows.slice(0, 10) + .map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
div > div': { + opacity: 0.5, + }, + }} + colSpan={header.colSpan} + > + {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: ' 🔼', + desc: ' 🔽', + }[header.column.getIsSorted() as string] ?? null} + {header.column.getCanResize() && ( + + )} +
+ )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ {table.getPageCount() > 1 && ( + + + table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + + + table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + + + table.nextPage()} + disabled={!table.getCanNextPage()} + > + + + table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + + + + + Page + + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + + + + Go to page + { + const page = e.target.value ? Number(e.target.value) - 1 : 0; + table.setPageIndex(page); + }} + /> + + + )} +
+ ); +} diff --git a/inertia/components/layouts/_theme_layout.tsx b/inertia/components/layouts/_theme_layout.tsx index d4900bb..e5d17ad 100644 --- a/inertia/components/layouts/_theme_layout.tsx +++ b/inertia/components/layouts/_theme_layout.tsx @@ -131,7 +131,9 @@ function GlobalStyles() { }, 'th, td': { - whiteSpace: 'nowrap', + whiteSspace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', }, 'tr:nth-of-type(even)': { diff --git a/inertia/pages/admin/dashboard.tsx b/inertia/pages/admin/dashboard.tsx index abc6c42..b0fab76 100644 --- a/inertia/pages/admin/dashboard.tsx +++ b/inertia/pages/admin/dashboard.tsx @@ -1,14 +1,13 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { ColumnDef } from '@tanstack/react-table'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { ReactNode } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import RoundedImage from '~/components/common/rounded_image'; -import TextEllipsis from '~/components/common/text_ellipsis'; +import Table from '~/components/common/table'; import ContentLayout from '~/components/layouts/content_layout'; import { DATE_FORMAT } from '~/constants'; -import { sortByCreationDate } from '~/lib/array'; import { UserWithRelationCount } from '~/types/app'; dayjs.extend(relativeTime); @@ -19,15 +18,18 @@ interface AdminDashboardProps { totalLinks: number; } -const Cell = styled.div<{ column?: boolean; fixed?: boolean }>( - ({ column, fixed }) => ({ - width: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: fixed ? 'unset' : 'center', - gap: column ? 0 : '0.35em', - flexDirection: column ? 'column' : 'row', - }) +const CenteredCell = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', +}); + +const RenderDateCell = (info: any) => ( + + {dayjs(info.getValue().toString()).fromNow()} + {dayjs(info.getValue().toString()).format(DATE_FORMAT)} + ); const ThemeProvider = (props: AdminDashboardProps) => ( @@ -44,95 +46,76 @@ function AdminDashboard({ }: AdminDashboardProps) { const { t } = useTranslation(); const theme = useTheme(); - return ( -
- - - - # - {t('common:name')} - {t('common:email')} - + const columns = useMemo( + () => + [ + { + accessorKey: 'id', + header: ( + <> + # ({users.length}) + + ), + cell: (info) => info.getValue(), + }, + { + accessorKey: 'fullname', + header: t('common:name'), + cell: (info) => info.getValue(), + }, + { + accessorKey: 'email', + header: t('common:email'), + cell: (info) => info.getValue(), + }, + { + accessorKey: 'count', + header: ( + <> {t('common:collection.collections', { count: totalCollections })}{' '} ({totalCollections}) - - + + ), + cell: (info) => (info.getValue() as any)?.collection, + }, + { + accessorKey: 'count', + header: ( + <> {t('common:link.links', { count: totalLinks })}{' '} ({totalLinks}) - - {t('admin:role')} - {t('admin:created_at')} - {t('admin:updated_at')} - - - - {users.length !== 0 && - sortByCreationDate(users).map((user) => ( - - ))} - -
-
+ + ), + cell: (info: any) => info.getValue()?.link, + }, + { + accessorKey: 'isAdmin', + header: t('admin:role'), + cell: (info) => + info.getValue() ? ( + + {t('admin:admin')} + + ) : ( + + {t('admin:user')} + + ), + }, + { + accessorKey: 'createdAt', + header: t('admin:created_at'), + cell: RenderDateCell, + }, + { + accessorKey: 'updatedAt', + header: t('admin:updated_at'), + cell: RenderDateCell, + }, + ] as ColumnDef[], + [] ); -} - -function TableUserRow({ user }: { user: UserWithRelationCount }) { - const { t } = useTranslation(); - const theme = useTheme(); - const { id, fullname, avatarUrl, email, isAdmin, createdAt, updatedAt } = - user; - return ( - - {id} - - {avatarUrl && ( - - )} - {fullname ?? '-'} - - - {email} - - {user.count.collection} - {user.count.link} - - {isAdmin ? ( - - {t('admin:admin')} - - ) : ( - {t('admin:user')} - )} - - - {dayjs(createdAt.toString()).fromNow()} - {dayjs(createdAt.toString()).format(DATE_FORMAT)} - - - {dayjs(updatedAt.toString()).fromNow()} - {dayjs(updatedAt.toString()).format(DATE_FORMAT)} - - - ); -} - -type TableItem = { - children: ReactNode; - type: 'td' | 'th'; - fixed?: boolean; - column?: boolean; -}; - -function TableCell({ children, type, ...props }: TableItem) { - const child = {children}; - return type === 'td' ? {child} : {child}; + return ; } diff --git a/package.json b/package.json index 163152f..4534e0c 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@emotion/styled": "^11.11.5", "@inertiajs/react": "^1.2.0", "@izzyjs/route": "^1.1.0-0", + "@tanstack/react-table": "^8.20.5", "@vinejs/vine": "^2.1.0", "bentocache": "^1.0.0-beta.9", "dayjs": "^1.11.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f536ba8..1321949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@izzyjs/route': specifier: ^1.1.0-0 version: 1.1.0-0(@adonisjs/core@6.12.1(@adonisjs/assembler@7.7.0(babel-plugin-macros@3.1.0)(typescript@5.4.5))(@vinejs/vine@2.1.0)(edge.js@6.0.2))(edge.js@6.0.2) + '@tanstack/react-table': + specifier: ^8.20.5 + version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@vinejs/vine': specifier: ^2.1.0 version: 2.1.0 @@ -1244,6 +1247,17 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@tanstack/react-table@8.20.5': + resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.20.5': + resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} + engines: {node: '>=12'} + '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -5877,6 +5891,14 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tanstack/react-table@8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/table-core': 8.20.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/table-core@8.20.5': {} + '@tokenizer/token@0.3.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {}