diff --git a/packages/app-explorer/src/app/blocks/layout.tsx b/packages/app-explorer/src/app/blocks/layout.tsx new file mode 100644 index 00000000..1291bdbf --- /dev/null +++ b/packages/app-explorer/src/app/blocks/layout.tsx @@ -0,0 +1,17 @@ +import { OverlayDialog, Providers } from 'app-portal'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'All Blocks', +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +export const dynamic = 'force-static'; diff --git a/packages/app-explorer/src/app/blocks/page.tsx b/packages/app-explorer/src/app/blocks/page.tsx new file mode 100644 index 00000000..90838da6 --- /dev/null +++ b/packages/app-explorer/src/app/blocks/page.tsx @@ -0,0 +1,23 @@ +'use client'; +import { Box, Flex } from '@fuels/ui'; +import { tv } from 'tailwind-variants'; +import { BlocksScreen } from '~/systems/Block/screens/BlockScreen'; + +const Blocks = () => { + const classes = styles(); + return ( + + + + + + ); +}; +const styles = tv({ + slots: { + content: 'w-full max-w-[100%]', + }, +}); +export default Blocks; + +export const dynamic = 'force-static'; diff --git a/packages/app-explorer/src/systems/Block/components/BlockEfficiencyItem.tsx b/packages/app-explorer/src/systems/Block/components/BlockEfficiencyItem.tsx new file mode 100644 index 00000000..48a82374 --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/BlockEfficiencyItem.tsx @@ -0,0 +1,49 @@ +import { Box, HStack, Text, VStack } from '@fuels/ui'; + +type BlockEfficiencyItemProps = { + current: number; + total: number; +}; + +export default function BlockEfficiencyItem({ + current, + total, +}: BlockEfficiencyItemProps) { + // Convert current and total to millions + const currentInMillions = current / 1_000_000; + const totalInMillions = total / 1_000_000; + + // Calculate progress percentage + const progress = (current / total) * 100; + + return ( + + + + {/* Format current and total as M (millions) */} + + {currentInMillions % 1 === 0 + ? currentInMillions.toFixed(0) + : currentInMillions.toFixed(1)} + M / + {totalInMillions % 1 === 0 + ? totalInMillions.toFixed(0) + : totalInMillions.toFixed(1)} + M + + + ({progress.toFixed(2)}%) + + +
+
+
+
+
+ + + ); +} diff --git a/packages/app-explorer/src/systems/Block/components/BlockHashItem.tsx b/packages/app-explorer/src/systems/Block/components/BlockHashItem.tsx new file mode 100644 index 00000000..d36eeffd --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/BlockHashItem.tsx @@ -0,0 +1,28 @@ +import { Box, Copyable, HStack, VStack } from '@fuels/ui'; + +type BlockHashItemProps = { + hashAddress: string; + width: string; +}; + +export default function BlockHashItem({ + hashAddress, + width, +}: BlockHashItemProps) { + return ( + + + + +

+ {hashAddress} +

+
+
+
+
+ ); +} diff --git a/packages/app-explorer/src/systems/Block/components/BlockItem.tsx b/packages/app-explorer/src/systems/Block/components/BlockItem.tsx new file mode 100644 index 00000000..f566d2ac --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/BlockItem.tsx @@ -0,0 +1,26 @@ +import { Box, Copyable, HStack, Text, VStack } from '@fuels/ui'; + +export interface BlockItemProps { + blockId: string; + ethValue: string; +} + +export default function BlockItem({ blockId, ethValue }: BlockItemProps) { + return ( + + + + + #{blockId} + + + + + {ethValue} ETH + + + ); +} diff --git a/packages/app-explorer/src/systems/Block/components/BlockTimeItem.tsx b/packages/app-explorer/src/systems/Block/components/BlockTimeItem.tsx new file mode 100644 index 00000000..74107776 --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/BlockTimeItem.tsx @@ -0,0 +1,28 @@ +import { Text, VStack } from '@fuels/ui'; + +type BlockTimeItemProps = { + time: Date; + timeAgo: string; +}; + +export default function BlockTimeItem({ time, timeAgo }: BlockTimeItemProps) { + const timeDate = new Date(time); + + const formattedTime = timeDate.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + }); + + return ( + + {timeAgo} + + {formattedTime} + + + ); +} diff --git a/packages/app-explorer/src/systems/Block/components/BlockValidatorItem.tsx b/packages/app-explorer/src/systems/Block/components/BlockValidatorItem.tsx new file mode 100644 index 00000000..29f61663 --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/BlockValidatorItem.tsx @@ -0,0 +1,16 @@ +import { HStack } from '@fuels/ui'; +import BlockHashItem from './BlockHashItem'; + +type BlockValidatorItemProps = { + hashAddress: string; +}; + +export default function BlockValidatorItem({ + hashAddress, +}: BlockValidatorItemProps) { + return ( + + + + ); +} diff --git a/packages/app-explorer/src/systems/Block/components/BlocksTable.tsx b/packages/app-explorer/src/systems/Block/components/BlocksTable.tsx new file mode 100644 index 00000000..a146eb71 --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/BlocksTable.tsx @@ -0,0 +1,132 @@ +import { GQLBlocksQuery } from '@fuel-explorer/graphql'; +import { GridTable } from '@fuels/ui'; +import { Link } from '@fuels/ui'; +import NextLink from 'next/link'; +import BlockEfficiencyItem from './BlockEfficiencyItem'; +import BlockHashItem from './BlockHashItem'; +import BlockItem from './BlockItem'; +import BlockTimeItem from './BlockTimeItem'; +import BlockValidatorItem from './BlockValidatorItem'; + +const columns = [ + { + name: 'Block', + cell: (row: any) => { + const totalGasUsed = ( + parseFloat(row.node.totalGasUsed) / + 10 ** 9 + ).toString(); + return ( + + ); + }, + sortable: false, + }, + { + name: 'Blockhash', + cell: (row: any) => ( + + ), + sortable: false, + }, + { + name: 'Transactions', + cell: (row: any) => ( +
+ {row.node.header.transactionsCount} +
+ ), + sortable: false, + }, + { + name: 'Rewards', + cell: (row: any) => { + const mintTransaction = row.node.transactions.find( + (trans: any) => trans.mintAmount != null, + ); + return ( +
+ {mintTransaction ? mintTransaction.mintAmount : 'No mint amount'}{' '} +
+ ); + }, + sortable: false, + }, + { + name: 'Validator', + cell: (row: any) => ( +
+ +
+ ), + sortable: false, + }, + { + name: 'Efficiency', + cell: (row: any) => ( +
+ +
+ ), + sortable: false, + }, + { + name: 'Time', + cell: (row: any) => { + const unixTimestamp = row.node.time.rawUnix; + const date = new Date(unixTimestamp * 1000); + + return ; + }, + sortable: false, + }, + { + name: '', + cell: (row: any) => ( + + View + + ), + sortable: false, + }, +]; + +type BlocksTableProps = { + blocks: GQLBlocksQuery['blocks']; + onPageChanged: (pageNumber: number) => void; + pageCount: number; + currentPage: number; + setCurrentPage: (currentPage: number) => void; +}; + +function BlocksTable({ + blocks, + onPageChanged, + pageCount, + currentPage, + setCurrentPage, +}: BlocksTableProps) { + const handlePageChanged = (pageNumber: number) => { + onPageChanged(pageNumber); + }; + + return ( +
+ +
+ ); +} + +export default BlocksTable; diff --git a/packages/app-explorer/src/systems/Block/components/Hero.tsx b/packages/app-explorer/src/systems/Block/components/Hero.tsx new file mode 100644 index 00000000..0ed3fa4b --- /dev/null +++ b/packages/app-explorer/src/systems/Block/components/Hero.tsx @@ -0,0 +1,21 @@ +import { HStack, Heading, Theme, VStack } from '@fuels/ui'; +import { IconChevronRight } from '@tabler/icons-react'; + +export function Hero() { + return ( + + + + Blocks + + + +

Home

+
+ +

View All Blocks

+
+
+
+ ); +} diff --git a/packages/app-explorer/src/systems/Block/screens/BlockScreen.tsx b/packages/app-explorer/src/systems/Block/screens/BlockScreen.tsx new file mode 100644 index 00000000..b0a4a81d --- /dev/null +++ b/packages/app-explorer/src/systems/Block/screens/BlockScreen.tsx @@ -0,0 +1,130 @@ +import { VStack } from '@fuels/ui'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { GQLBlocksQuery } from '@fuel-explorer/graphql'; +import { getBlocks } from '../actions/get-blocks'; +import BlocksTable from '../components/BlocksTable'; +import { Hero } from '../components/Hero'; + +export const BlocksScreen = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [data, setData] = useState( + undefined, + ); + const [dir, setDir] = useState<'after' | 'before'>('after'); + const [totalPages, setTotalPages] = useState(1); + const [currentPage, setCurrentPage] = useState(1); + const [currentCursor, setCurrentCursor] = useState(null); + const [loading, setLoading] = useState(false); + const limit = 10; + + const calculateTotalPages = () => { + if (data?.pageInfo.endCursor) { + const endCursor = Number(data.pageInfo.endCursor); + return Math.ceil(endCursor / limit); + } + return 1; + }; + + useEffect(() => { + if (data) { + const totalPageCount = calculateTotalPages(); + setTotalPages(totalPageCount); + } + }, [data]); + + const fetchBlockData = async ( + cursor: string | null = null, + dir: 'after' | 'before' = 'after', + ) => { + setLoading(true); + try { + const result = await getBlocks({ cursor, dir }); + const blockData = result.blocks; + setData(blockData); + } finally { + setLoading(false); + } + }; + + const handlePageChanged = (newPageNumber: number) => { + if (data) { + const newDir = newPageNumber > currentPage ? 'before' : 'after'; + let newCursor: string | null = null; + setDir(newDir); + + if ( + newPageNumber === currentPage + 1 || + newPageNumber === currentPage - 1 + ) { + if (newDir === 'before' && data.pageInfo.endCursor) { + newCursor = data.pageInfo.endCursor; + } else if (newDir === 'after' && data.pageInfo.startCursor) { + newCursor = data.pageInfo.startCursor; + } + } else { + if (newDir === 'before' && data.pageInfo.endCursor) { + newCursor = ( + +data.pageInfo.endCursor - + (newPageNumber - currentPage) * limit + ).toString(); + } else if (newDir === 'after' && data.pageInfo.startCursor) { + newCursor = ( + +data.pageInfo.startCursor + + (currentPage - newPageNumber) * limit + ).toString(); + } + } + + setCurrentPage(newPageNumber); + setCurrentCursor(newCursor); + if (newPageNumber === 1) { + router.push('/blocks'); + return; + } + router.push(`/blocks?page=${newPageNumber}&cursor=${newCursor}`); + } + }; + + useEffect(() => { + const page = parseInt(searchParams.get('page') || '1'); + const cursor = searchParams.get('cursor') || null; + + setCurrentPage(page); + setCurrentCursor(cursor); + setDir(page > currentPage ? 'after' : 'before'); + }, [searchParams]); + + useEffect(() => { + fetchBlockData(currentCursor, dir); + }, [currentCursor, dir]); + + useEffect(() => { + if (data) { + const totalPageCount = calculateTotalPages(); + setTotalPages(totalPageCount); + } + }, [data]); + + return ( + + + {loading ? ( +

Loading blocks...

+ ) : ( + data && ( + + ) + )} +
+ ); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index f1419af1..f48c0edd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -67,6 +67,8 @@ "@react-aria/focus": "3.16.2", "@tabler/icons-react": "2.47.0", "@tailwindcss/typography": "0.5.10", + "react-paginate": "8.2.0", + "react-data-table-component": "7.6.2", "clsx": "2.1.0", "csstype": "3.1.3", "framer-motion": "11.0.5", diff --git a/packages/ui/src/components/GridTable/GridTable.tsx b/packages/ui/src/components/GridTable/GridTable.tsx new file mode 100644 index 00000000..4e103133 --- /dev/null +++ b/packages/ui/src/components/GridTable/GridTable.tsx @@ -0,0 +1,180 @@ +'use client'; +import React from 'react'; +import DataTable, { TableProps, TableColumn } from 'react-data-table-component'; +import ReactPaginate from 'react-paginate'; + +export interface GridTableProps extends TableProps { + columns: TableColumn[]; + data: T[]; + pageCount: number; + onPageChanged: (selectedItem: number) => void; + currentPage: number; + setCurrentPage: (currentPage: number) => void; +} +export type GridTableColumn = TableColumn; + +export const GridTable = ({ + columns, + data, + pageCount, + onPageChanged, + setCurrentPage, + currentPage, + ...props +}: GridTableProps): React.JSX.Element => { + const customStyles = { + tableWrapper: { + style: { + borderRadius: '7px', + }, + }, + table: { + style: { + backgroundColor: 'transparent', + }, + }, + headRow: { + style: { + backgroundColor: 'transparent', + color: '#9f9f9f', + fontWeight: '600', + }, + }, + headCells: { + style: { + backgroundColor: 'transparent', + color: '#9f9f9f', + fontWeight: '600', + }, + }, + rows: { + style: { + cursor: 'pointer', + backgroundColor: 'transparent', + fontWeight: '400', + '&:hover': { + backgroundColor: 'var(--gray-2)', // Change background color on hover + }, + }, + }, + cells: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: '0.5rem', + paddingRight: '0.5rem', + color: 'var(--gray-table-text)', + paddingTop: '0.9rem', + paddingBottom: '0.9rem', + backgroundColor: 'transparent', + fontWeight: '400', + }, + }, + pagination: { + style: { + backgroundColor: 'var(--gray-2)', + color: '#f0f0f0', + }, + pageButtonsStyle: { + padding: '8px 16px', + margin: '0 4px', + color: '#f0f0f0', + borderRadius: '4px', + backgroundColor: 'var(--gray-2)', + '&.selected': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + fontWeight: 'bold', + }, + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + }, + }, + }; + + const Pagination: React.FC = () => { + return ( + ← Previous} // Left Arrow + nextLabel={Next →} // Right Arrow + breakLabel={'...'} + pageCount={pageCount} + marginPagesDisplayed={2} + pageRangeDisplayed={5} + onPageChange={(page) => handlePagination(page)} + containerClassName={'pagination'} + activeClassName={'selected'} + disabledClassName={'disabled'} + pageLinkClassName={'page-link'} + forcePage={currentPage !== 0 ? currentPage - 1 : 0} + /> + ); + }; + + const handlePagination = (page: any) => { + setCurrentPage(page.selected + 1); + onPageChanged(page.selected + 1); + }; + + return ( +
+ + +
+ ); +}; diff --git a/packages/ui/src/components/GridTable/index.tsx b/packages/ui/src/components/GridTable/index.tsx new file mode 100644 index 00000000..42edf4b3 --- /dev/null +++ b/packages/ui/src/components/GridTable/index.tsx @@ -0,0 +1 @@ +export { GridTable } from './GridTable'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 85be5c02..836e5a47 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -68,4 +68,5 @@ export * from './components/ToggleGroup'; export * from './components/Tooltip'; export * from './components/Portal'; export * from './utils/component'; +export * from './components/GridTable'; export * from './utils/radixUiThemesTailwindPlugin'; diff --git a/packages/ui/src/theme/theme.css b/packages/ui/src/theme/theme.css index bfd4d716..63941341 100644 --- a/packages/ui/src/theme/theme.css +++ b/packages/ui/src/theme/theme.css @@ -832,8 +832,8 @@ --gray-a10: #ffffff72; --gray-a11: #ffffffaf; --gray-a12: #ffffffed; - --gray-contrast: #FFFFFF; + --gray-table-text: #FFFFFF; --gray-surface: rgba(0, 0, 0, 0.05); --gray-indicator: var(--gray-9); } @@ -871,6 +871,7 @@ --gray-a12: #000000df; --gray-contrast: #FFFFFF; + --gray-table-text: #000000; --gray-surface: #ffffffcc; --gray-indicator: var(--gray-9); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75a7ae6e..b69ad50d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,9 +869,15 @@ importers: react-aria: specifier: 3.32.1 version: 3.32.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-data-table-component: + specifier: 7.6.2 + version: 7.6.2(react@18.2.0)(styled-components@5.3.11(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react-is@18.2.0)(react@18.2.0)) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-paginate: + specifier: 8.2.0 + version: 8.2.0(react@18.2.0) react-stately: specifier: 3.29.1 version: 3.29.1(react@18.2.0) @@ -13028,6 +13034,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + react-data-table-component@7.6.2: + resolution: {integrity: sha512-nHe7040fmtrJyQr/ieGrTfV0jBflYGK4sLokC6/AFOv3ThjmA9WzKz8Z8/2wMxzRqLU+Rn0CVFg+8+frKLepWQ==} + peerDependencies: + react: '>= 16.8.3' + styled-components: '>= 5.0.0' + react-docgen-typescript@2.2.2: resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} peerDependencies: @@ -13078,6 +13090,11 @@ packages: react: '*' react-native: '*' + react-paginate@8.2.0: + resolution: {integrity: sha512-sJCz1PW+9PNIjUSn919nlcRVuleN2YPoFBOvL+6TPgrH/3lwphqiSOgdrLafLdyLDxsgK+oSgviqacF4hxsDIw==} + peerDependencies: + react: ^16 || ^17 || ^18 + react-refresh@0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'} @@ -32308,6 +32325,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-data-table-component@7.6.2(react@18.2.0)(styled-components@5.3.11(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react-is@18.2.0)(react@18.2.0)): + dependencies: + deepmerge: 4.3.1 + react: 18.2.0 + styled-components: 5.3.11(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react-is@18.2.0)(react@18.2.0) + react-docgen-typescript@2.2.2(typescript@5.4.5): dependencies: typescript: 5.4.5 @@ -32363,6 +32386,11 @@ snapshots: invariant: 2.2.4 react: 18.2.0 + react-paginate@8.2.0(react@18.2.0): + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-refresh@0.14.0: {} react-remove-scroll-bar@2.3.4(@types/react@18.2.54)(react@18.2.0):