From 77e7de55a42c5e4421ae2ffff8d9df09f2dc521a Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Fri, 13 Feb 2026 16:55:22 +1000 Subject: [PATCH] feat(CardChain): add slide-in animation and loading states for pagination feat(CardChain): show badge with count of new blocks on next arrow refactor(HomePage): remove tx search, keep block-only search refactor(HomePage): pin view to anchor block to prevent auto-jump style(index.css): add keyframes for chain slide animations --- .../DataDisplay/CardChain/CardChain.tsx | 115 +++++++++-- .../DataDisplay/CardChain/CardChain.types.ts | 4 + src/index.css | 26 +++ .../execution/gas-profiler/HomePage.tsx | 183 +++++++++++------- .../gas-profiler/hooks/useRecentBlocks.ts | 34 +++- 5 files changed, 269 insertions(+), 93 deletions(-) diff --git a/src/components/DataDisplay/CardChain/CardChain.tsx b/src/components/DataDisplay/CardChain/CardChain.tsx index e495f6eca..cb5af79a5 100644 --- a/src/components/DataDisplay/CardChain/CardChain.tsx +++ b/src/components/DataDisplay/CardChain/CardChain.tsx @@ -1,5 +1,5 @@ -import type { JSX, ReactNode } from 'react'; -import { ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; +import { useEffect, useRef, useState, type JSX, type ReactNode } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import type { CardChainItem, CardChainProps } from './CardChain.types'; @@ -148,28 +148,40 @@ function NavArrow({ direction, onClick, disabled, + loading, + badgeCount, title, }: { direction: 'left' | 'right'; onClick?: () => void; disabled: boolean; + loading?: boolean; + badgeCount?: number; title: string; }): JSX.Element { const Icon = direction === 'left' ? ChevronLeftIcon : ChevronRightIcon; + const showBadge = badgeCount !== undefined && badgeCount > 0 && !loading; return ( ); } @@ -187,10 +199,49 @@ export function CardChain({ onLoadNext, hasPreviousItems = false, hasNextItems = false, + nextItemCount, isLoading = false, + isFetching = false, skeletonCount = 6, className, }: CardChainProps): JSX.Element { + // --- Slide animation state --- + const prevIdsRef = useRef<(string | number)[]>([]); + const [slideDirection, setSlideDirection] = useState<'left' | 'right' | null>(null); + const [animationKey, setAnimationKey] = useState(0); + const fetchDirectionRef = useRef<'left' | 'right' | null>(null); + + useEffect(() => { + if (isLoading || items.length === 0) return; + + const currentIds = items.map(i => i.id); + const prevIds = prevIdsRef.current; + + // Skip initial render or identical item sets + if (prevIds.length > 0 && JSON.stringify(currentIds) !== JSON.stringify(prevIds)) { + const newFirstId = currentIds[0]; + const prevFirstId = prevIds[0]; + + // Compare as numbers if both are numeric, otherwise use string comparison + const newFirst = Number(newFirstId); + const prevFirst = Number(prevFirstId); + const isNumeric = !Number.isNaN(newFirst) && !Number.isNaN(prevFirst); + + if (isNumeric ? newFirst > prevFirst : newFirstId > prevFirstId) { + setSlideDirection('left'); // newer blocks → cards enter from right + } else { + setSlideDirection('right'); // older blocks → cards enter from left + } + setAnimationKey(k => k + 1); + fetchDirectionRef.current = null; + } + + prevIdsRef.current = currentIds; + }, [items, isLoading]); + + const staggerMs = 50; + const durationMs = 350; + // Default wrapper just renders children const wrapItem = (item: CardChainItem, index: number, children: ReactNode): ReactNode => { if (renderItemWrapper) { @@ -231,8 +282,12 @@ export function CardChain({ {onLoadPrevious && ( { + fetchDirectionRef.current = 'left'; + onLoadPrevious(); + }} disabled={!hasPreviousItems || isLoading} + loading={isFetching && fetchDirectionRef.current === 'left'} title={hasPreviousItems ? 'Load previous items' : 'No previous items available'} /> )} @@ -246,13 +301,51 @@ export function CardChain({ items.map((item, index) => { const isLast = index === items.length - 1; const cardElement = ; - return wrapItem(item, index, cardElement); + + // Apply stagger animation when direction is set + const animStyle = + slideDirection != null + ? { + animation: `chain-slide-${slideDirection} ${durationMs}ms ease-out both`, + animationDelay: `${ + slideDirection === 'left' + ? (items.length - 1 - index) * staggerMs // rightmost leads + : index * staggerMs // leftmost leads + }ms`, + } + : undefined; + + return ( +
setSlideDirection(null) + : undefined + } + > + {wrapItem(item, index, cardElement)} +
+ ); })} {/* Right arrow - load next items (only show when there are next items) */} {onLoadNext && hasNextItems && ( - + { + fetchDirectionRef.current = 'right'; + onLoadNext(); + }} + disabled={isLoading} + loading={isFetching && fetchDirectionRef.current === 'right'} + badgeCount={nextItemCount} + title="Load next items" + /> )} diff --git a/src/components/DataDisplay/CardChain/CardChain.types.ts b/src/components/DataDisplay/CardChain/CardChain.types.ts index f092bd6c3..1bdf85930 100644 --- a/src/components/DataDisplay/CardChain/CardChain.types.ts +++ b/src/components/DataDisplay/CardChain/CardChain.types.ts @@ -36,8 +36,12 @@ export interface CardChainProps { hasPreviousItems?: boolean; /** Whether there are next items to load */ hasNextItems?: boolean; + /** Badge count shown on the "next" arrow (e.g., number of new items available) */ + nextItemCount?: number; /** Loading state - shows skeleton items */ isLoading?: boolean; + /** Background fetching state - shows spinner on nav arrows */ + isFetching?: boolean; /** Number of skeleton items to show when loading (default: 6) */ skeletonCount?: number; /** Additional CSS class for the container */ diff --git a/src/index.css b/src/index.css index b994c404a..831e3b3d2 100644 --- a/src/index.css +++ b/src/index.css @@ -269,6 +269,7 @@ opacity: 1; } } + } /* Semantic token mappings @@ -398,3 +399,28 @@ opacity: 1; } } + +/* CardChain slide animations for conveyor belt effect. + * Placed outside @theme inline so they're always emitted + * (keyframes inside @theme are only output when referenced by a token). */ +@keyframes chain-slide-left { + from { + opacity: 0; + transform: translateX(60px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes chain-slide-right { + from { + opacity: 0; + transform: translateX(-60px); + } + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/src/pages/ethereum/execution/gas-profiler/HomePage.tsx b/src/pages/ethereum/execution/gas-profiler/HomePage.tsx index 4248302fc..386b15cfc 100644 --- a/src/pages/ethereum/execution/gas-profiler/HomePage.tsx +++ b/src/pages/ethereum/execution/gas-profiler/HomePage.tsx @@ -8,10 +8,8 @@ import { Header } from '@/components/Layout/Header'; import { PopoutCard } from '@/components/Layout/PopoutCard'; import { Divider } from '@/components/Layout/Divider'; import { Alert } from '@/components/Feedback/Alert'; -import { Input } from '@/components/Forms/Input'; // TODO: Re-enable when fork annotation data is available // import { Toggle } from '@/components/Forms/Toggle'; -import { Button } from '@/components/Elements/Button'; import { CardChain, type CardChainItem } from '@/components/DataDisplay/CardChain'; import { useNetwork } from '@/hooks/useNetwork'; import { useForks } from '@/hooks/useForks'; @@ -163,13 +161,6 @@ function formatCompact(value: number): string { return value.toString(); } -/** - * Validate if string is a valid transaction hash - */ -function isValidTxHash(value: string): boolean { - return /^0x[a-fA-F0-9]{64}$/.test(value); -} - /** * Validate if string is a valid block number */ @@ -204,17 +195,30 @@ export function HomePage(): JSX.Element { const [searchError, setSearchError] = useState(null); const [blocksOffset, setBlocksOffset] = useState(0); + // Pinned max block — prevents view from jumping when new blocks are indexed. + // Initialized from bounds on first load, then only advances on explicit user action. + const [pinnedMax, setPinnedMax] = useState(null); + // Fetch bounds to validate block range - uses intersection of all gas profiler tables const { data: bounds, isLoading: boundsLoading, error } = useGasProfilerBounds(); - // Fetch recent blocks for visualization + // Initialize pinnedMax from bounds on first load + useEffect(() => { + if (bounds && pinnedMax === null) { + setPinnedMax(bounds.max); + } + }, [bounds, pinnedMax]); + + // Fetch recent blocks for visualization — pinned to anchorBlock const { blocks: recentBlocks, isLoading: recentBlocksLoading, - isFetching: _recentBlocksFetching, + isFetching: recentBlocksFetching, hasOlderBlocks, + hasNewerBlocks, isAtLatest, - } = useRecentBlocks({ count: 6, offset: blocksOffset }); + newerBlockCount, + } = useRecentBlocks({ count: 6, offset: blocksOffset, anchorBlock: pinnedMax ?? undefined }); // Calculate timestamp for start of selected time range const startTimestamp = useMemo(() => { @@ -401,8 +405,14 @@ export function HomePage(): JSX.Element { }, []); const handleLoadNewerBlocks = useCallback(() => { - setBlocksOffset(prev => Math.max(0, prev - 6)); - }, []); + if (blocksOffset > 0) { + // Navigate back toward the pinned anchor + setBlocksOffset(prev => Math.max(0, prev - 6)); + } else if (bounds && pinnedMax !== null && bounds.max > pinnedMax) { + // At anchor but newer blocks exist — advance anchor by one page + setPinnedMax(Math.min(bounds.max, pinnedMax + 6)); + } + }, [blocksOffset, bounds, pinnedMax]); // Transform recent blocks to CardChainItem format const recentBlockItems = useMemo(() => { @@ -431,48 +441,37 @@ export function HomePage(): JSX.Element { // Handle quick search from URL useEffect(() => { - if (search.tx && isValidTxHash(search.tx)) { - setSearchInput(search.tx); - } if (search.block) { navigate({ to: '/ethereum/execution/gas-profiler/block/$blockNumber', params: { blockNumber: String(search.block) }, }); } - }, [search.tx, search.block, navigate]); + }, [search.block, navigate]); - // Handle search submission + // Handle search submission - block numbers only const handleSearch = useCallback(() => { setSearchError(null); - const cleanedInput = searchInput.replace(/,/g, ''); + const cleanedInput = searchInput.replace(/,/g, '').trim(); - if (isValidBlockNumber(cleanedInput) && !cleanedInput.startsWith('0x')) { - const blockNum = parseInt(cleanedInput, 10); - if (bounds) { - if (blockNum < bounds.min || blockNum > bounds.max) { - setSearchError( - `Block ${formatGas(blockNum)} is outside indexed range (${formatGas(bounds.min)} - ${formatGas(bounds.max)})` - ); - return; - } - } - navigate({ - to: '/ethereum/execution/gas-profiler/block/$blockNumber', - params: { blockNumber: cleanedInput }, - }); + if (!isValidBlockNumber(cleanedInput) || cleanedInput.startsWith('0x')) { + setSearchError('Enter a valid block number'); return; } - if (isValidTxHash(cleanedInput)) { - navigate({ - to: '/ethereum/execution/gas-profiler/tx/$txHash', - params: { txHash: cleanedInput }, - }); - return; + const blockNum = parseInt(cleanedInput, 10); + if (bounds) { + if (blockNum < bounds.min || blockNum > bounds.max) { + setSearchError( + `Block ${formatGas(blockNum)} is outside indexed range (${formatGas(bounds.min)} - ${formatGas(bounds.max)})` + ); + return; + } } - - setSearchError('Enter a valid transaction hash (0x...) or block number'); + navigate({ + to: '/ethereum/execution/gas-profiler/block/$blockNumber', + params: { blockNumber: cleanedInput }, + }); }, [searchInput, navigate, bounds]); const handleKeyPress = useCallback( @@ -484,6 +483,27 @@ export function HomePage(): JSX.Element { [handleSearch] ); + // Handle simulate navigation - block numbers only + const handleSimulate = useCallback(() => { + const cleanedInput = searchInput.trim().replace(/,/g, ''); + + if (!cleanedInput) { + navigate({ to: '/ethereum/execution/gas-profiler/simulate' }); + return; + } + + if (isValidBlockNumber(cleanedInput) && !cleanedInput.startsWith('0x')) { + const blockNum = parseInt(cleanedInput, 10); + navigate({ + to: '/ethereum/execution/gas-profiler/simulate', + search: { block: blockNum }, + }); + return; + } + + setSearchError('Enter a valid block number'); + }, [searchInput, navigate]); + // ============================================================================ // CHART CONFIGS // ============================================================================ @@ -756,40 +776,51 @@ export function HomePage(): JSX.Element { Real-time block exploration and transaction/block search ============================================================================ */} - {/* Search Input + Simulate */} -
-
- +
+
+
+ setSearchInput(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Block number" + className="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-base/6 text-foreground placeholder:text-muted focus:ring-0 focus:outline-hidden" + /> + - - -
-
-
- - - + Search + +
+

+ {searchError ?? `Indexed range: ${formatGas(bounds.min)} - ${formatGas(bounds.max)}`} +

{/* Recent Blocks Chain Visualization */} @@ -797,11 +828,13 @@ export function HomePage(): JSX.Element { className="mb-8" items={recentBlockItems} isLoading={recentBlocksLoading} + isFetching={recentBlocksFetching} skeletonCount={6} onLoadPrevious={handleLoadOlderBlocks} onLoadNext={handleLoadNewerBlocks} hasPreviousItems={hasOlderBlocks} - hasNextItems={!isAtLatest} + hasNextItems={hasNewerBlocks} + nextItemCount={newerBlockCount} renderItemWrapper={(item, index, children) => { const fromEnd = recentBlockItems.length - 1 - index; return ( diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/useRecentBlocks.ts b/src/pages/ethereum/execution/gas-profiler/hooks/useRecentBlocks.ts index 8c290260c..4eda5f714 100644 --- a/src/pages/ethereum/execution/gas-profiler/hooks/useRecentBlocks.ts +++ b/src/pages/ethereum/execution/gas-profiler/hooks/useRecentBlocks.ts @@ -16,8 +16,10 @@ export interface BlockSummary { export interface UseRecentBlocksOptions { /** Number of recent blocks to fetch (default: 6) */ count?: number; - /** Offset from latest (0 = latest blocks, 6 = 6 blocks back, etc.) */ + /** Offset from the anchor block (0 = at anchor, 6 = 6 blocks back, etc.) */ offset?: number; + /** Pin view to this block number instead of always chasing bounds.max */ + anchorBlock?: number; } export interface UseRecentBlocksResult { @@ -29,8 +31,12 @@ export interface UseRecentBlocksResult { error: Error | null; /** Whether there are older blocks available to load */ hasOlderBlocks: boolean; - /** Whether we're viewing the latest blocks (offset = 0) */ + /** Whether there are newer blocks beyond the current view */ + hasNewerBlocks: boolean; + /** Whether the view includes the actual latest indexed block */ isAtLatest: boolean; + /** Number of new blocks available beyond the current view */ + newerBlockCount: number; /** The bounds of available data */ bounds: { min: number; max: number } | undefined; } @@ -38,15 +44,24 @@ export interface UseRecentBlocksResult { /** * Hook to fetch lightweight summary data for recent blocks. * Uses int_block_opcode_gas for efficient per-block gas totals. + * + * When `anchorBlock` is provided, the view is pinned to that block + * instead of always following bounds.max. This prevents the view + * from jumping when new blocks are indexed. */ -export function useRecentBlocks({ count = 6, offset = 0 }: UseRecentBlocksOptions = {}): UseRecentBlocksResult { +export function useRecentBlocks({ + count = 6, + offset = 0, + anchorBlock, +}: UseRecentBlocksOptions = {}): UseRecentBlocksResult { const { currentNetwork } = useNetwork(); // Get bounds (intersection of all gas profiler tables) const { data: bounds, isLoading: boundsLoading } = useGasProfilerBounds(); - // Calculate the block range we want (with offset from latest) - const maxBlock = bounds?.max ? bounds.max - offset : null; + // Use anchor block if provided, otherwise fall back to bounds.max + const effectiveMax = anchorBlock ?? bounds?.max ?? null; + const maxBlock = effectiveMax ? effectiveMax - offset : null; const minBlock = maxBlock ? Math.max(bounds?.min ?? 0, maxBlock - count + 1) : null; // Fetch opcode gas data for recent blocks @@ -94,9 +109,12 @@ export function useRecentBlocks({ count = 6, offset = 0 }: UseRecentBlocksOption .sort((a, b) => a.blockNumber - b.blockNumber); }, [opcodeGasData, maxBlock]); - // Calculate if there are older blocks available + // Calculate navigation availability const hasOlderBlocks = bounds?.min !== undefined && minBlock !== null && minBlock > bounds.min; - const isAtLatest = offset === 0; + const viewMax = maxBlock ?? 0; + const hasNewerBlocks = bounds?.max !== undefined && viewMax > 0 && viewMax < bounds.max; + const newerBlockCount = bounds?.max !== undefined && viewMax > 0 ? Math.max(0, bounds.max - viewMax) : 0; + const isAtLatest = !hasNewerBlocks; return { blocks, @@ -104,7 +122,9 @@ export function useRecentBlocks({ count = 6, offset = 0 }: UseRecentBlocksOption isFetching: dataFetching, error: error as Error | null, hasOlderBlocks, + hasNewerBlocks, isAtLatest, + newerBlockCount, bounds, }; }