diff --git a/apps/explorer/src/comps/DataGrid.tsx b/apps/explorer/src/comps/DataGrid.tsx index c60d00de3..f46551077 100644 --- a/apps/explorer/src/comps/DataGrid.tsx +++ b/apps/explorer/src/comps/DataGrid.tsx @@ -176,7 +176,9 @@ export function DataGrid(props: DataGrid.Props) { {/* Hide pagination when no items and not loading */} {(totalItems > 0 || loading) && (
- {pagination === 'simple' ? ( + {pagination !== 'default' && pagination !== 'simple' ? ( + pagination + ) : pagination === 'simple' ? (
({ - ...previous, - page: totalPages, - })} + search={(previous) => ({ ...previous, page: totalPages })} disabled={page >= totalPages || isPending} className={cx( 'rounded-full! border border-base-border hover:bg-alt flex items-center justify-center cursor-pointer press-down aria-disabled:cursor-default aria-disabled:opacity-50 size-[24px] text-primary', @@ -242,7 +239,7 @@ export namespace Pagination { ({ ...prev, page: 1, live: true })} + search={(prev) => ({ ...prev, page: 1 })} disabled={page <= 1} className={cx( 'rounded-full border border-base-border hover:bg-alt flex items-center justify-center cursor-pointer active:translate-y-[0.5px] aria-disabled:cursor-not-allowed aria-disabled:opacity-50 size-[24px] text-primary', @@ -254,10 +251,10 @@ export namespace Pagination { { - const newPage = (prev?.page ?? 1) - 1 - return { ...prev, page: newPage, live: newPage === 1 } - }} + search={(prev) => ({ + ...prev, + page: (prev?.page ?? 1) - 1, + })} disabled={page <= 1} className={cx( 'rounded-full border border-base-border hover:bg-alt flex items-center justify-center cursor-pointer active:translate-y-[0.5px] aria-disabled:cursor-not-allowed aria-disabled:opacity-50 size-[24px] text-primary', @@ -283,7 +280,6 @@ export namespace Pagination { search={(prev) => ({ ...prev, page: (prev?.page ?? 1) + 1, - live: false, })} disabled={page >= totalPages} className={cx( @@ -296,7 +292,7 @@ export namespace Pagination { ({ ...prev, page: totalPages, live: false })} + search={(prev) => ({ ...prev, page: totalPages })} disabled={page >= totalPages || disableLastPage} className={cx( 'rounded-full border border-base-border hover:bg-alt flex items-center justify-center cursor-pointer active:translate-y-[0.5px] aria-disabled:cursor-not-allowed aria-disabled:opacity-50 size-[24px] text-primary', diff --git a/apps/explorer/src/lib/queries/blocks.ts b/apps/explorer/src/lib/queries/blocks.ts index 412316441..dc7d543df 100644 --- a/apps/explorer/src/lib/queries/blocks.ts +++ b/apps/explorer/src/lib/queries/blocks.ts @@ -17,16 +17,15 @@ export type BlockIdentifier = export type BlockWithTransactions = Block export type BlockTransaction = BlockWithTransactions['transactions'][number] -export function blocksQueryOptions(page: number) { +export function blocksQueryOptions(start?: number) { return queryOptions({ - queryKey: ['blocks-loader', page], + queryKey: ['blocks-loader', start], queryFn: async () => { const config = getWagmiConfig() const latestBlock = await getBlock(config) const latestBlockNumber = latestBlock.number - const startBlock = - latestBlockNumber - BigInt((page - 1) * BLOCKS_PER_PAGE) + const startBlock = start != null ? BigInt(start) : latestBlockNumber const blockNumbers: bigint[] = [] for (let i = 0n; i < BigInt(BLOCKS_PER_PAGE); i++) { diff --git a/apps/explorer/src/routes/_layout.tsx b/apps/explorer/src/routes/_layout.tsx index e2f2c8562..bc7af6590 100644 --- a/apps/explorer/src/routes/_layout.tsx +++ b/apps/explorer/src/routes/_layout.tsx @@ -41,7 +41,8 @@ export function Layout(props: Layout.Props) { Testnet migration: Tempo launched a new testnet - (Moderato) on January 8th. The old testnet (Andantino) will be deprecated on{' '} + (Moderato) on January 8th. The old testnet (Andantino) will be + deprecated on{' '} diff --git a/apps/explorer/src/routes/_layout/blocks.tsx b/apps/explorer/src/routes/_layout/blocks.tsx index ed16aef8a..2309f844c 100644 --- a/apps/explorer/src/routes/_layout/blocks.tsx +++ b/apps/explorer/src/routes/_layout/blocks.tsx @@ -4,12 +4,20 @@ import * as React from 'react' import type { Block } from 'viem' import { useBlock, useWatchBlockNumber } from 'wagmi' import * as z from 'zod/mini' +import { DataGrid } from '#comps/DataGrid' import { Midcut } from '#comps/Midcut' -import { Pagination } from '#comps/Pagination' -import { FormattedTimestamp, useTimeFormat } from '#comps/TimeFormat' -import { cx } from '#cva.config.ts' -import { useIsMounted } from '#lib/hooks' +import { Sections } from '#comps/Sections' +import { + FormattedTimestamp, + TimeColumnHeader, + useTimeFormat, +} from '#comps/TimeFormat' +import { cx } from '#cva.config' import { BLOCKS_PER_PAGE, blocksQueryOptions } from '#lib/queries' +import ChevronFirst from '~icons/lucide/chevron-first' +import ChevronLast from '~icons/lucide/chevron-last' +import ChevronLeft from '~icons/lucide/chevron-left' +import ChevronRight from '~icons/lucide/chevron-right' import Play from '~icons/lucide/play' // Track which block numbers are "new" for animation purposes @@ -18,256 +26,319 @@ const recentlyAddedBlocks = new Set() export const Route = createFileRoute('/_layout/blocks')({ component: RouteComponent, validateSearch: z.object({ - page: z.optional(z.coerce.number()), - live: z.prefault(z.coerce.boolean(), true), + from: z.optional(z.coerce.number()), + live: z.optional(z.coerce.boolean()), }), - loaderDeps: ({ search: { page, live } }) => ({ - page: page ?? 1, - live: live ?? (page ?? 1) === 1, + loaderDeps: ({ search: { from, live } }) => ({ + from, + live: live ?? from == null, }), loader: async ({ deps, context }) => - context.queryClient.ensureQueryData(blocksQueryOptions(deps.page)), + context.queryClient.ensureQueryData(blocksQueryOptions(deps.from)), }) function RouteComponent() { const search = Route.useSearch() - const page = search.page ?? 1 - const live = search.live ?? page === 1 + const from = search.from + const isAtLatest = from == null + const live = search.live ?? isAtLatest const loaderData = Route.useLoaderData() const { data: queryData } = useQuery({ - ...blocksQueryOptions(page), + ...blocksQueryOptions(from), initialData: loaderData, }) const [latestBlockNumber, setLatestBlockNumber] = React.useState< bigint | undefined >() + const currentLatest = latestBlockNumber ?? queryData.latestBlockNumber + // Initialize with loader data to prevent layout shift const [liveBlocks, setLiveBlocks] = React.useState(() => queryData.blocks.slice(0, BLOCKS_PER_PAGE), ) - const isMounted = useIsMounted() const { timeFormat, cycleTimeFormat, formatLabel } = useTimeFormat() + const [paused, setPaused] = React.useState(false) - // Use loader data for initial render, then live updates - const currentLatest = latestBlockNumber ?? queryData.latestBlockNumber - - // Watch for new blocks (only on page 1 when live) + // Watch for new blocks useWatchBlockNumber({ - enabled: isMounted && live && page === 1, onBlockNumber: (blockNumber) => { - // Only update if this is actually a new block if (latestBlockNumber === undefined || blockNumber > latestBlockNumber) { setLatestBlockNumber(blockNumber) - // Only mark as recently added for animation on page 1 - if (page === 1) { - recentlyAddedBlocks.add(blockNumber.toString()) - // Clear the animation flag after animation completes - // TODO: is cleanup necessary? - setTimeout(() => { - recentlyAddedBlocks.delete(blockNumber.toString()) - }, 400) - } } }, + poll: true, }) - // Fetch the latest block when block number changes (for live updates on page 1) + // Fetch the latest block when block number changes (for live updates) const { data: latestBlock } = useBlock({ blockNumber: latestBlockNumber, query: { - enabled: live && page === 1 && latestBlockNumber !== undefined, + enabled: live && isAtLatest && latestBlockNumber !== undefined, staleTime: Number.POSITIVE_INFINITY, // Block data never changes }, }) // Add new blocks as they arrive React.useEffect(() => { - if (!live || page !== 1 || !latestBlock) return + if (!live || !isAtLatest || !latestBlock || paused) return setLiveBlocks((prev) => { - // Don't add if already exists if (prev.some((b) => b.number === latestBlock.number)) return prev - // Prepend new block and keep only BLOCKS_PER_PAGE + + // Mark as new for animation + const blockNum = latestBlock.number?.toString() + if (blockNum) { + recentlyAddedBlocks.add(blockNum) + setTimeout(() => recentlyAddedBlocks.delete(blockNum), 400) + } + return [latestBlock, ...prev].slice(0, BLOCKS_PER_PAGE) }) - }, [latestBlock, live, page]) + }, [latestBlock, live, isAtLatest, paused]) - // Re-initialize when navigating back to page 1 with live mode + // Re-initialize when navigating back to latest with live mode React.useEffect(() => { - if (page === 1 && live && queryData.blocks) { + if (isAtLatest && live && queryData.blocks) { setLiveBlocks((prev) => { - // Only reinitialize if we have no blocks or stale data if (prev.length === 0) { return queryData.blocks.slice(0, BLOCKS_PER_PAGE) } return prev }) } - }, [page, live, queryData.blocks]) + }, [isAtLatest, live, queryData.blocks]) - // Use live blocks on page 1 when live, otherwise use loader data + // Use live blocks when at latest and live, otherwise use loader data const blocks = React.useMemo(() => { - if (page === 1 && live && liveBlocks.length > 0) return liveBlocks + if (isAtLatest && live && liveBlocks.length > 0) return liveBlocks return queryData.blocks - }, [page, live, liveBlocks, queryData.blocks]) + }, [isAtLatest, live, liveBlocks, queryData.blocks]) const isLoading = !blocks || blocks.length === 0 - const totalBlocks = currentLatest ? Number(currentLatest) + 1 : 0 - const totalPages = Math.ceil(totalBlocks / BLOCKS_PER_PAGE) + const displayedFrom = blocks[0]?.number ?? undefined + const displayedEnd = blocks[blocks.length - 1]?.number ?? undefined + + const columns: DataGrid.Column[] = [ + { label: 'Block', width: '1fr', minWidth: 100 }, + { label: 'Hash', width: '8fr' }, + { + align: 'end', + label: ( + + ), + width: '1fr', + minWidth: 80, + }, + { align: 'end', label: 'Txns', width: '1fr' }, + ] return ( -
-
-
- {/* Header */} -
-
Block
-
Hash
-
- -
-
Count
-
+ + blocks.map((block) => { + const blockNumber = block.number?.toString() ?? '0' + const blockHash = block.hash ?? '0x' + const txCount = block.transactions?.length ?? 0 + const isNew = recentlyAddedBlocks.has(blockNumber) - {/* Blocks list */} -
- {isLoading ? ( -
- Loading blocks… -
- ) : blocks && blocks.length > 0 ? ( - blocks.map((block, index) => ( - + #{blockNumber} + , + , + + + , + + {txCount} + , + ], + link: { + href: `/block/${blockNumber}`, + title: `View block #${blockNumber}`, + }, + className: isNew ? 'bg-positive/5' : undefined, + } + }) + } + totalItems={totalBlocks} + page={1} + loading={isLoading} + itemsLabel="blocks" + itemsPerPage={BLOCKS_PER_PAGE} + emptyState="No blocks found." + pagination={ + + } /> - )) - ) : ( -
- No blocks found
- )} -
-
- -
- -
- ({ ...prev, live: !live })} - className={cx( - 'flex items-center gap-1.5 px-2.5 py-1.25 rounded-md text-[12px] font-medium font-sans transition-colors text-primary', - live - ? 'bg-positive/10 hover:bg-positive/20' - : 'bg-base-alt hover:bg-base-alt/80', - )} - title={live ? 'Pause live updates' : 'Resume live updates'} - > - {live ? ( - <> - - - - - Live - - ) : ( - <> - - Paused - - )} - - -
-
-
+ ), + }, + ]} + activeSection={0} + />
) } -function BlockRow({ - block, - isNew, - isLatest, - timeFormat, +function BlocksPagination({ + displayedFrom, + displayedEnd, + latestBlockNumber, + isAtLatest, }: { - block: Block - isNew?: boolean - isLatest?: boolean - timeFormat: 'relative' | 'local' | 'utc' | 'unix' + displayedFrom: bigint | undefined + displayedEnd: bigint | undefined + latestBlockNumber: bigint | undefined + isAtLatest: boolean }) { - const txCount = block.transactions?.length ?? 0 - const blockNumber = block.number?.toString() ?? '0' - const blockHash = block.hash ?? '0x' + const canGoNewer = !isAtLatest + const canGoOlder = displayedEnd != null && displayedEnd > 0n + + const newerFrom = + displayedFrom != null ? Number(displayedFrom) + BLOCKS_PER_PAGE : undefined + const olderFrom = displayedEnd != null ? Number(displayedEnd) - 1 : undefined return ( -
-
+
+
- {blockNumber} + -
-
- + + + + {displayedFrom != null ? `#${displayedFrom}-#${displayedEnd}` : '…'} + + + + + +
-
- {isLatest ? ( - 'now' - ) : ( - - - - )} -
-
- {txCount} -
+ + {latestBlockNumber != null + ? `${(Number(latestBlockNumber) + 1).toLocaleString()} blocks` + : '…'} +
) } diff --git a/apps/explorer/src/routes/_layout/index.tsx b/apps/explorer/src/routes/_layout/index.tsx index 480bb3e61..c13c39cde 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -7,13 +7,7 @@ import { } from '@tanstack/react-router' import { animate, stagger } from 'animejs' import type { Address, Hex } from 'ox' -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react' +import * as React from 'react' import { ExploreInput } from '#comps/ExploreInput' import { cx } from '#cva.config' import { springInstant, springBouncy, springSmooth } from '#lib/animation' @@ -75,24 +69,24 @@ function Component() { const router = useRouter() const navigate = useNavigate() const introSeen = useIntroSeen() - const introSeenOnMount = useRef(introSeen) - const [inputValue, setInputValue] = useState('') - const [isMounted, setIsMounted] = useState(false) - const inputWrapperRef = useRef(null) - const exploreInputRef = useRef(null) + const introSeenOnMount = React.useRef(introSeen) + const [inputValue, setInputValue] = React.useState('') + const [isMounted, setIsMounted] = React.useState(false) + const inputWrapperRef = React.useRef(null) + const exploreInputRef = React.useRef(null) const isNavigating = useRouterState({ select: (state) => state.status === 'pending', }) - useEffect(() => setIsMounted(true), []) + React.useEffect(() => setIsMounted(true), []) - useEffect(() => { + React.useEffect(() => { return router.subscribe('onResolved', ({ hrefChanged }) => { if (hrefChanged) setInputValue('') }) }, [router]) - const handlePhaseChange = useCallback((phase: IntroPhase) => { + const handlePhaseChange = React.useCallback((phase: IntroPhase) => { if (phase === 'start' && inputWrapperRef.current) { const seen = introSeenOnMount.current animate(inputWrapperRef.current, { @@ -156,16 +150,18 @@ function Component() { function SpotlightLinks() { const navigate = useNavigate() const introSeen = useIntroSeen() - const [actionOpen, setActionOpen] = useState(false) - const [menuMounted, setMenuMounted] = useState(false) - const dropdownRef = useRef(null) - const dropdownMenuRef = useRef(null) - const hoverTimeoutRef = useRef | null>(null) - const closingRef = useRef(false) - const pillsRef = useRef(null) - const introSeenOnMount = useRef(introSeen) + const [actionOpen, setActionOpen] = React.useState(false) + const [menuMounted, setMenuMounted] = React.useState(false) + const dropdownRef = React.useRef(null) + const dropdownMenuRef = React.useRef(null) + const hoverTimeoutRef = React.useRef | null>( + null, + ) + const closingRef = React.useRef(false) + const pillsRef = React.useRef(null) + const introSeenOnMount = React.useRef(introSeen) - const closeMenu = useCallback(() => { + const closeMenu = React.useCallback(() => { setActionOpen(false) if (dropdownMenuRef.current) { closingRef.current = true @@ -183,7 +179,7 @@ function SpotlightLinks() { } }, []) - useEffect(() => { + React.useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( dropdownRef.current && @@ -196,7 +192,7 @@ function SpotlightLinks() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [closeMenu]) - useEffect(() => { + React.useEffect(() => { if (!pillsRef.current) return const seen = introSeenOnMount.current const children = [...pillsRef.current.children] @@ -219,11 +215,11 @@ function SpotlightLinks() { } }, []) - useEffect(() => { + React.useEffect(() => { if (actionOpen) setMenuMounted(true) }, [actionOpen]) - useLayoutEffect(() => { + React.useLayoutEffect(() => { if (!dropdownMenuRef.current) return if (actionOpen && menuMounted) { animate(dropdownMenuRef.current, {