From df32c2d7d57d0391e6600b35261764b0c5739ffb Mon Sep 17 00:00:00 2001 From: max-digi Date: Mon, 19 Jan 2026 15:02:33 +0000 Subject: [PATCH 1/7] feat: recent transaction widget --- apps/explorer/src/comps/ContractSource.tsx | 8 +- apps/explorer/src/comps/Dashboard.tsx | 218 +++++++++++++++++++++ apps/explorer/src/lib/queries/dashboard.ts | 87 ++++++++ apps/explorer/src/lib/queries/index.ts | 1 + apps/explorer/src/routes/_layout/index.tsx | 8 +- 5 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 apps/explorer/src/comps/Dashboard.tsx create mode 100644 apps/explorer/src/lib/queries/dashboard.ts diff --git a/apps/explorer/src/comps/ContractSource.tsx b/apps/explorer/src/comps/ContractSource.tsx index 401d325ae..b55449d08 100644 --- a/apps/explorer/src/comps/ContractSource.tsx +++ b/apps/explorer/src/comps/ContractSource.tsx @@ -10,12 +10,10 @@ import SolidityIcon from '~icons/vscode-icons/file-type-solidity' import VyperIcon from '~icons/vscode-icons/file-type-vyper' function getCompilerVersionUrl(compiler: string, version: string) { - const isVyper = compiler.toLowerCase() === "vyper" - const repo = isVyper ? "vyperlang/vyper" : "argotorg/solidity" + const isVyper = compiler.toLowerCase() === 'vyper' + const repo = isVyper ? 'vyperlang/vyper' : 'argotorg/solidity' - const tag = isVyper - ? version.trim() - : version.trim().split("+commit.", 1)[0] + const tag = isVyper ? version.trim() : version.trim().split('+commit.', 1)[0] return `https://github.com/${repo}/releases/tag/v${tag}` } diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx new file mode 100644 index 000000000..b0e8a1047 --- /dev/null +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -0,0 +1,218 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { waapi, stagger } from 'animejs' +import type * as React from 'react' +import { useEffect, useRef } from 'react' +import { Address } from '#comps/Address' +import { Midcut } from '#comps/Midcut' +import { RelativeTime } from '#comps/RelativeTime' +import { cx } from '#lib/css' +import { springSmooth } from '#lib/animation' +import { + dashboardQueryOptions, + type DashboardBlock, + type DashboardTransaction, +} from '#lib/queries' +import BoxIcon from '~icons/lucide/box' +import ArrowRightIcon from '~icons/lucide/arrow-right' +import ZapIcon from '~icons/lucide/zap' + +export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { + const { visible } = props + const containerRef = useRef(null) + + const { data, isLoading } = useQuery({ + ...dashboardQueryOptions(), + enabled: visible, + }) + + useEffect(() => { + if (!visible || !containerRef.current) return + const children = [...containerRef.current.children] + waapi.animate(children as HTMLElement[], { + opacity: [0, 1], + translateY: [12, 0], + ease: springSmooth, + delay: stagger(40, { start: 100 }), + }) + }, [visible]) + + if (!visible) return null + + return ( +
+ } + viewAllLink="/blocks" + loading={isLoading} + > + {data?.blocks.map((block) => ( + + ))} + + } + loading={isLoading} + > + {data?.transactions.map((tx) => ( + + ))} + +
+ ) +} + +export declare namespace Dashboard { + type Props = { + visible: boolean + } +} + +function Skeleton(): React.JSX.Element { + const items = [1, 2, 3, 4, 5] + return ( + <> + {items.map((n) => ( +
+
+
+
+
+
+
+ ))} + + ) +} + +type CardProps = { + title: string + icon: React.ReactNode + viewAllLink?: string + loading?: boolean + children: React.ReactNode +} + +function Card(props: CardProps): React.JSX.Element { + const { title, icon, viewAllLink, loading, children } = props + return ( +
+
+
+ {icon} + {title} +
+ {viewAllLink && ( + + View all + + + )} +
+
+ {loading ? : children} +
+
+ ) +} + +function BlockRow(props: { block: DashboardBlock }): React.JSX.Element { + const { block } = props + const blockNumber = block.number?.toString() ?? '0' + const txCount = Array.isArray(block.transactions) + ? block.transactions.length + : 0 + + return ( + +
+
+ +
+
+ + #{blockNumber} + + + {txCount} transaction{txCount !== 1 ? 's' : ''} + +
+
+
+ {block.hash && ( + + + + )} + {block.timestamp && ( + + + + )} +
+ + ) +} + +function TransactionRow(props: { + transaction: DashboardTransaction +}): React.JSX.Element { + const { transaction } = props + + return ( + +
+
+ +
+
+ + + +
+ From +
+ {transaction.to && ( + <> + +
+ + )} +
+
+
+
+ + Block #{transaction.blockNumber.toString()} + + {transaction.timestamp && ( + + + + )} +
+ + ) +} diff --git a/apps/explorer/src/lib/queries/dashboard.ts b/apps/explorer/src/lib/queries/dashboard.ts new file mode 100644 index 000000000..87c9d45ef --- /dev/null +++ b/apps/explorer/src/lib/queries/dashboard.ts @@ -0,0 +1,87 @@ +import { queryOptions } from '@tanstack/react-query' +import type { Block } from 'viem' +import { getBlock } from 'wagmi/actions' +import { getWagmiConfig } from '#wagmi.config.ts' + +export const DASHBOARD_BLOCKS_COUNT = 5 +export const DASHBOARD_TRANSACTIONS_COUNT = 8 + +export type DashboardBlock = Pick< + Block, + 'number' | 'hash' | 'timestamp' | 'transactions' +> + +export type DashboardTransaction = { + hash: `0x${string}` + from: `0x${string}` + to: `0x${string}` | null + blockNumber: bigint + timestamp: bigint +} + +export function dashboardQueryOptions() { + return queryOptions({ + queryKey: ['dashboard'], + queryFn: async () => { + const config = getWagmiConfig() + const latestBlock = await getBlock(config, { includeTransactions: true }) + const latestBlockNumber = latestBlock.number + + const blockNumbers: bigint[] = [] + for (let i = 0n; i < BigInt(DASHBOARD_BLOCKS_COUNT); i++) { + const blockNum = latestBlockNumber - i + if (blockNum >= 0n) blockNumbers.push(blockNum) + } + + const blocks = await Promise.all( + blockNumbers.map((blockNumber) => + getBlock(config, { blockNumber, includeTransactions: true }).catch( + () => null, + ), + ), + ) + + const validBlocks = blocks.filter(Boolean) as Block[] + + const recentBlocks: DashboardBlock[] = validBlocks.map((block) => ({ + number: block.number, + hash: block.hash, + timestamp: block.timestamp, + transactions: block.transactions.map((tx) => + typeof tx === 'string' ? tx : tx.hash, + ), + })) + + const allTransactions: DashboardTransaction[] = [] + for (const block of validBlocks) { + if (block.number === null) continue + for (const tx of block.transactions) { + if (typeof tx === 'string') continue + allTransactions.push({ + hash: tx.hash, + from: tx.from, + to: tx.to, + blockNumber: block.number, + timestamp: block.timestamp, + }) + if (allTransactions.length >= DASHBOARD_TRANSACTIONS_COUNT) break + } + if (allTransactions.length >= DASHBOARD_TRANSACTIONS_COUNT) break + } + + return { + latestBlockNumber, + blocks: recentBlocks, + transactions: allTransactions, + } + }, + staleTime: 5_000, + refetchInterval: 10_000, + }) +} + +export type DashboardData = { + latestBlockNumber: bigint + blocks: DashboardBlock[] + transactions: DashboardTransaction[] +} diff --git a/apps/explorer/src/lib/queries/index.ts b/apps/explorer/src/lib/queries/index.ts index 64813ad66..e94c8c643 100644 --- a/apps/explorer/src/lib/queries/index.ts +++ b/apps/explorer/src/lib/queries/index.ts @@ -2,6 +2,7 @@ export * from './abi' export * from './account' export * from './balance-changes' export * from './blocks' +export * from './dashboard' export * from './tokens' export * from './trace' export * from './tx' diff --git a/apps/explorer/src/routes/_layout/index.tsx b/apps/explorer/src/routes/_layout/index.tsx index 2476568d4..df8b4f7bb 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -8,6 +8,7 @@ import { import { waapi, stagger } from 'animejs' import type { Address, Hex } from 'ox' import * as React from 'react' +import { Dashboard } from '#comps/Dashboard' import { ExploreInput } from '#comps/ExploreInput' import { cx } from '#lib/css' import { springInstant, springBouncy, springSmooth } from '#lib/animation' @@ -84,6 +85,7 @@ function Component() { const [inputValue, setInputValue] = React.useState('') const [isMounted, setIsMounted] = React.useState(false) const [inputReady, setInputReady] = React.useState(false) + const [dashboardVisible, setDashboardVisible] = React.useState(false) const exploreInputRef = React.useRef(null) const exploreWrapperRef = React.useRef(null) const isNavigating = useRouterState({ @@ -105,6 +107,7 @@ function Component() { setTimeout( () => { setInputReady(true) + setDashboardVisible(true) if (exploreWrapperRef.current) { exploreWrapperRef.current.style.pointerEvents = 'auto' waapi.animate(exploreWrapperRef.current, { @@ -120,8 +123,8 @@ function Component() { }, []) return ( -
-
+
+
+
) } From 29923ca9a7c68242771c7dd5b5a54cee8b7d4f6c Mon Sep 17 00:00:00 2001 From: max-digi Date: Mon, 19 Jan 2026 15:26:09 +0000 Subject: [PATCH 2/7] feat: add new tx and account counter to homepage dashboard --- apps/explorer/src/comps/Dashboard.tsx | 117 +++++++++++++++--- .../src/lib/queries/.dashboard.ts.swp | Bin 0 -> 12288 bytes apps/explorer/src/lib/queries/dashboard.ts | 31 +++++ apps/explorer/src/routeTree.gen.ts | 21 ++++ apps/explorer/src/routes/api/stats.ts | 106 ++++++++++++++++ 5 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 apps/explorer/src/lib/queries/.dashboard.ts.swp create mode 100644 apps/explorer/src/routes/api/stats.ts diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx index b0e8a1047..e8bd4ca4a 100644 --- a/apps/explorer/src/comps/Dashboard.tsx +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -10,12 +10,15 @@ import { cx } from '#lib/css' import { springSmooth } from '#lib/animation' import { dashboardQueryOptions, + networkStatsQueryOptions, type DashboardBlock, type DashboardTransaction, } from '#lib/queries' import BoxIcon from '~icons/lucide/box' import ArrowRightIcon from '~icons/lucide/arrow-right' import ZapIcon from '~icons/lucide/zap' +import UsersIcon from '~icons/lucide/users' +import ActivityIcon from '~icons/lucide/activity' export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { const { visible } = props @@ -26,6 +29,11 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { enabled: visible, }) + const { data: stats, isLoading: statsLoading } = useQuery({ + ...networkStatsQueryOptions(), + enabled: visible, + }) + useEffect(() => { if (!visible || !containerRef.current) return const children = [...containerRef.current.children] @@ -42,27 +50,47 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { return (
- } - viewAllLink="/blocks" - loading={isLoading} - > - {data?.blocks.map((block) => ( - - ))} - - } - loading={isLoading} - > - {data?.transactions.map((tx) => ( - - ))} - +
+ } + loading={statsLoading} + /> + } + loading={statsLoading} + /> +
+
+ } + viewAllLink="/blocks" + loading={isLoading} + > + {data?.blocks.map((block) => ( + + ))} + + } + loading={isLoading} + > + {data?.transactions.map((tx) => ( + + ))} + +
) } @@ -73,6 +101,55 @@ export declare namespace Dashboard { } } +type StatCardProps = { + title: string + value: number | undefined + subtitle: number | undefined + subtitleLabel: string + icon: React.ReactNode + loading?: boolean +} + +function formatNumber(num: number): string { + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M` + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K` + } + return num.toLocaleString() +} + +function StatCard(props: StatCardProps): React.JSX.Element { + const { title, value, subtitle, subtitleLabel, icon, loading } = props + + return ( +
+
+ {icon} + {title} +
+ {loading ? ( +
+
+
+
+ ) : ( + <> +
+ {value !== undefined ? formatNumber(value) : '—'} +
+ {subtitle !== undefined && ( +
+ +{formatNumber(subtitle)} {subtitleLabel} +
+ )} + + )} +
+ ) +} + function Skeleton(): React.JSX.Element { const items = [1, 2, 3, 4, 5] return ( diff --git a/apps/explorer/src/lib/queries/.dashboard.ts.swp b/apps/explorer/src/lib/queries/.dashboard.ts.swp new file mode 100644 index 0000000000000000000000000000000000000000..3c757ae42cdcfacaef504a12a76e41318d85d232 GIT binary patch literal 12288 zcmeI2?~5Bn7{{l!{)o0J^;M}arax#Bdbi2-tkigSHo4PYZF|?=4_hr~Zf`TmZ7Bld?CK_wM9_h)n6cp_??;E&1RDp1YcO$gHMy4 zdG?uSzR%1v8@fx`=Z>GEPiR8~e-9I~M4)+cQku5GxQX5LKYT%Y#RD%nu(L%8IUid-u>scFsqw;_Fy}SbMpr|NH;?Lxj8oHo+-y66^=RKS;=L;9Kx9cne$x zO|S|YU=j?0o#4A2gj@kl@B+w#5wHtv2RFCF7x)ys3*H8AfXm<#aKSk+2WG$-pn(J6 z*9Qps8QcV4fe*o(;1Y1bB6txjfMM_`*aCjIpODW$6HJ0*U@y3lAmk(P3fKe}!Si4k z{B$4U1Yd(M!8LFdY=9OR2D`vkaN}M=-UqLOP0$2uAP;tfU+y8~3-CI48Pvcza10y( z+rXc96Y>Fg5160?3~&}41qZ-Z@E7v-2lyUb2cLs$;3~KRUITUTED$-CdAYTY;UQRV?OU%j9CA@oN zaf0`NY==|6US}P@6FS%FC~XmfvD;~qNI0y_xUqcP;>=mqt2sK9EoQUX0d%=sud+G3 z&6J~u{kFE~NZ@Atb;qt(tD&e}X%__UFoRhtOs}UWaCw8nc*C(oe8DJYnWiGXwJNhJ ze3_1o(uw@+WMMi#Gf|wI$xqGZ$LEeuPt6v`r{|~U(sapT`ihKIMV$C|oGIm`1W*Su z)#{L4q(tL>gPMOrDG;Pa+E_ACz5%y$ls7eQ`weBsuE{3x#cy23%8-rXi`N)crQUFt zQ!P1NIZ=^%yA0=abaa#^UGA7xC7Gs%ZE@3TFlkw~9hyRmi>1<}F!$!sS%7XD`4E#8 z;;CLW1L@XkRIA-CLms8IaEg^>mI`ted*0NLkty722Vlu!v5O$uX|ZS%iL|Fd|2fj9 z#AG6RbuZW6X=*jSo=Wj%nvRaiF@jv)BDpFj%26jRN*n){P_EUC!#g1~U8&9a1xz#~ zm5N9XAcCSut_Vyy3fbWrNl86rjOr#gG&R-Y ztF9)erh|FuYr4rPUUsaRF4M4FQjyZ&I~Bzsiy8aZD21*jcZA=frW^8)fiXf)A);sMZWa)N^={Qqag&%l0t5<5~xJ+|O=q27AU4O>PVIi$s231Q#R0G*@ zg01K1`DE~7X>tK8#w+2{V7e|3HmqCkG%7bNdDhY3o-<+u-%dy;PNwkAQ>R&^j(Uxv zb}nX{`&+8EwHN6I6$_UnR1xj!BMG%(yBqr+lcA*yT@VurLQ*9mi6N50k^B+_s;)vs zGf5H!QSS!)sdpd5vJbvbo5sq>ZBFCI`<=$aL8Cg3J;^V)wxyMD!i(Ih1J675bi=S4 z7IzOlhC@W2?XciB&iB066KW1kqSrp84A!&fDmb;_RU8sB5au{IS~fQ5o**u7XQ-fL ze~zlm1$l!9F@qePVtmbZR%W@*-B7REFTx|lOR4JMFt?5ajA^1dm6laco>Eis%$zrjMx2Mev&T{T9_FefozY2|nLmLxyIl3V%75kv{_766N zcb}Yd-+j@QN53dU@)$Hn^B8o9ym#l(>k99M@Fdk9@OqV~odVeV+y(O^{;H2!hs!1G z4&#R!-e$*hYAaHHXq$>XOq2W8L`~A*2xkvoT~Bu1q4tq)D@`-r<8)vscV0^!gn`>t flhuTUKYF-c1x80Xp3b>$tYq*YY4Aa5lq7!xW>PDp literal 0 HcmV?d00001 diff --git a/apps/explorer/src/lib/queries/dashboard.ts b/apps/explorer/src/lib/queries/dashboard.ts index 87c9d45ef..7136f0ed5 100644 --- a/apps/explorer/src/lib/queries/dashboard.ts +++ b/apps/explorer/src/lib/queries/dashboard.ts @@ -6,6 +6,37 @@ import { getWagmiConfig } from '#wagmi.config.ts' export const DASHBOARD_BLOCKS_COUNT = 5 export const DASHBOARD_TRANSACTIONS_COUNT = 8 +export type NetworkStats = { + totalTransactions: number + transactions24h: number + totalAccounts: number + accounts24h: number +} + +export function networkStatsQueryOptions() { + return queryOptions({ + queryKey: ['network-stats'], + queryFn: async (): Promise => { + const response = await fetch(`${__BASE_URL__}/api/stats`) + const json = (await response.json()) as { + data: NetworkStats | null + error: string | null + } + if (json.error || !json.data) { + return { + totalTransactions: 0, + transactions24h: 0, + totalAccounts: 0, + accounts24h: 0, + } + } + return json.data + }, + staleTime: 30_000, + refetchInterval: 60_000, + }) +} + export type DashboardBlock = Pick< Block, 'number' | 'hash' | 'timestamp' | 'transactions' diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index 75c2a7453..0651131ed 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' +import { Route as ApiStatsRouteImport } from './routes/api/stats' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiCodeRouteImport } from './routes/api/code' @@ -46,6 +47,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) +const ApiStatsRoute = ApiStatsRouteImport.update({ + id: '/api/stats', + path: '/api/stats', + getParentRoute: () => rootRouteImport, +} as any) const ApiSearchRoute = ApiSearchRouteImport.update({ id: '/api/search', path: '/api/search', @@ -183,6 +189,7 @@ export interface FileRoutesByFullPath { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/stats': typeof ApiStatsRoute '/': typeof LayoutIndexRoute '/address/$address': typeof LayoutAddressAddressRoute '/block/$id': typeof LayoutBlockIdRoute @@ -211,6 +218,7 @@ export interface FileRoutesByTo { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/stats': typeof ApiStatsRoute '/': typeof LayoutIndexRoute '/address/$address': typeof LayoutAddressAddressRoute '/block/$id': typeof LayoutBlockIdRoute @@ -241,6 +249,7 @@ export interface FileRoutesById { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/stats': typeof ApiStatsRoute '/_layout/': typeof LayoutIndexRoute '/_layout/address/$address': typeof LayoutAddressAddressRoute '/_layout/block/$id': typeof LayoutBlockIdRoute @@ -271,6 +280,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/' | '/address/$address' | '/block/$id' @@ -299,6 +309,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/' | '/address/$address' | '/block/$id' @@ -328,6 +339,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/_layout/' | '/_layout/address/$address' | '/_layout/block/$id' @@ -355,6 +367,7 @@ export interface RootRouteChildren { ApiCodeRoute: typeof ApiCodeRoute ApiHealthRoute: typeof ApiHealthRoute ApiSearchRoute: typeof ApiSearchRoute + ApiStatsRoute: typeof ApiStatsRoute ApiAbiBatchRoute: typeof ApiAbiBatchRoute ApiAddressAddressRoute: typeof ApiAddressAddressRoute ApiTokensCountRoute: typeof ApiTokensCountRoute @@ -381,6 +394,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } + '/api/stats': { + id: '/api/stats' + path: '/api/stats' + fullPath: '/api/stats' + preLoaderRoute: typeof ApiStatsRouteImport + parentRoute: typeof rootRouteImport + } '/api/search': { id: '/api/search' path: '/api/search' @@ -603,6 +623,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCodeRoute: ApiCodeRoute, ApiHealthRoute: ApiHealthRoute, ApiSearchRoute: ApiSearchRoute, + ApiStatsRoute: ApiStatsRoute, ApiAbiBatchRoute: ApiAbiBatchRoute, ApiAddressAddressRoute: ApiAddressAddressRoute, ApiTokensCountRoute: ApiTokensCountRoute, diff --git a/apps/explorer/src/routes/api/stats.ts b/apps/explorer/src/routes/api/stats.ts new file mode 100644 index 000000000..e33db8309 --- /dev/null +++ b/apps/explorer/src/routes/api/stats.ts @@ -0,0 +1,106 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as IDX from 'idxs' +import { getBlock } from 'wagmi/actions' +import { getChainId } from 'wagmi/actions' +import { hasIndexSupply } from '#lib/env' +import { getWagmiConfig } from '#wagmi.config' + +const IS = IDX.IndexSupply.create({ + apiKey: process.env.INDEXER_API_KEY, +}) + +const QB = IDX.QueryBuilder.from(IS) + +const SECONDS_IN_24H = 24 * 60 * 60 +const BLOCKS_TO_CHECK_24H = 500 + +export const Route = createFileRoute('/api/stats')({ + server: { + handlers: { + GET: async () => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + let totalTransactions = 0 + let transactions24h = 0 + let totalAccounts = 0 + let accounts24h = 0 + + if (hasIndexSupply()) { + try { + const totalTxResult = await QB.selectFrom('txs') + .select((eb) => eb.fn.count('hash').as('cnt')) + .where('chain', '=', chainId) + .executeTakeFirst() + + totalTransactions = Number(totalTxResult?.cnt ?? 0) + } catch (error) { + console.error('Failed to fetch total transactions:', error) + } + + try { + const uniqueFromResult = await QB.selectFrom('txs') + .select('from') + .distinct() + .where('chain', '=', chainId) + .limit(100_000) + .execute() + + totalAccounts = uniqueFromResult.length + } catch (error) { + console.error('Failed to fetch total accounts:', error) + } + } + + try { + const latestBlock = await getBlock(config) + const now = Math.floor(Date.now() / 1000) + const timestamp24hAgo = now - SECONDS_IN_24H + + const blockNumbers: bigint[] = [] + for (let i = 0n; i < BigInt(BLOCKS_TO_CHECK_24H); i++) { + const blockNum = latestBlock.number - i + if (blockNum >= 0n) blockNumbers.push(blockNum) + } + + const blocks = await Promise.all( + blockNumbers.map((blockNumber) => + getBlock(config, { + blockNumber, + includeTransactions: true, + }).catch(() => null), + ), + ) + + const uniqueAccountsIn24h = new Set() + let txCount24h = 0 + + for (const block of blocks) { + if (!block || block.timestamp < timestamp24hAgo) continue + txCount24h += block.transactions.length + for (const tx of block.transactions) { + if (typeof tx !== 'string') { + uniqueAccountsIn24h.add(tx.from.toLowerCase()) + } + } + } + + transactions24h = txCount24h + accounts24h = uniqueAccountsIn24h.size + } catch (error) { + console.error('Failed to fetch 24h stats:', error) + } + + return Response.json({ + data: { + totalTransactions, + transactions24h, + totalAccounts, + accounts24h, + }, + error: null, + }) + }, + }, + }, +}) From d6f5f7028930ce57bfcb1b74d0c98a7334b22234 Mon Sep 17 00:00:00 2001 From: max-digi Date: Mon, 19 Jan 2026 19:30:39 +0000 Subject: [PATCH 3/7] feat: add top tokens, avg block time, TPS stats, and validators link to dashboard Amp-Thread-ID: https://ampcode.com/threads/T-019bd7ab-7893-70b9-8ae3-3a46ba77a2fb Co-authored-by: Amp --- apps/explorer/src/comps/Dashboard.tsx | 159 +++++++++- apps/explorer/src/lib/queries/dashboard.ts | 2 +- apps/explorer/src/routes/_layout/index.tsx | 327 +-------------------- apps/explorer/src/routes/api/stats.ts | 2 +- apps/og/src/index.tsx | 4 +- apps/tokenlist/env.d.ts | 1 - apps/tokenlist/src/docs.tsx | 4 +- 7 files changed, 159 insertions(+), 340 deletions(-) diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx index e8bd4ca4a..b42bb2cbd 100644 --- a/apps/explorer/src/comps/Dashboard.tsx +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -11,11 +11,16 @@ import { springSmooth } from '#lib/animation' import { dashboardQueryOptions, networkStatsQueryOptions, + tokensListQueryOptions, type DashboardBlock, type DashboardTransaction, } from '#lib/queries' +import type { Token } from '#lib/server/tokens.server' import BoxIcon from '~icons/lucide/box' import ArrowRightIcon from '~icons/lucide/arrow-right' +import ClockIcon from '~icons/lucide/clock' +import CoinsIcon from '~icons/lucide/coins' + import ZapIcon from '~icons/lucide/zap' import UsersIcon from '~icons/lucide/users' import ActivityIcon from '~icons/lucide/activity' @@ -34,6 +39,14 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { enabled: visible, }) + const { data: tokensData, isLoading: tokensLoading } = useQuery({ + ...tokensListQueryOptions({ page: 1, limit: 5 }), + enabled: visible, + }) + + const avgBlockTime = data?.blocks ? calculateAvgBlockTime(data.blocks) : null + const tps = data?.blocks ? calculateTPS(data.blocks) : null + useEffect(() => { if (!visible || !containerRef.current) return const children = [...containerRef.current.children] @@ -52,7 +65,7 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { ref={containerRef} className="w-full max-w-[1000px] mx-auto px-4 mt-8 flex flex-col gap-4" > -
+
} loading={statsLoading} /> + +
+ } + loading={isLoading} + > + {data?.transactions.map((tx) => ( + + ))} +
} - loading={isLoading} + title="Top Tokens" + icon={} + viewAllLink="/tokens" + loading={tokensLoading} > - {data?.transactions.map((tx) => ( - + {tokensData?.tokens.map((token) => ( + ))}
+
) } @@ -293,3 +319,124 @@ function TransactionRow(props: { ) } + +function calculateAvgBlockTime(blocks: DashboardBlock[]): number | null { + if (blocks.length < 2) return null + const timestamps = blocks.map((b) => Number(b.timestamp)) + let totalDiff = 0 + for (let i = 0; i < timestamps.length - 1; i++) { + totalDiff += timestamps[i] - timestamps[i + 1] + } + return totalDiff / (timestamps.length - 1) +} + +function calculateTPS(blocks: DashboardBlock[]): number | null { + if (blocks.length < 2) return null + const timestamps = blocks.map((b) => Number(b.timestamp)) + const timeSpan = timestamps[0] - timestamps[timestamps.length - 1] + if (timeSpan <= 0) return null + const totalTxs = blocks.reduce((sum, b) => { + const txCount = Array.isArray(b.transactions) ? b.transactions.length : 0 + return sum + txCount + }, 0) + return totalTxs / timeSpan +} + +function AvgBlockTimeCard(props: { + avgBlockTime: number | null + loading: boolean +}): React.JSX.Element { + const { avgBlockTime, loading } = props + + return ( +
+
+ + + + Avg Block Time +
+ {loading ? ( +
+
+
+ ) : ( +
+ {avgBlockTime !== null ? `${avgBlockTime.toFixed(1)}s` : '—'} +
+ )} +
+ ) +} + +function TPSCard(props: { + tps: number | null + loading: boolean +}): React.JSX.Element { + const { tps, loading } = props + + return ( +
+
+ + + + TPS +
+ {loading ? ( +
+
+
+ ) : ( +
+ {tps !== null ? tps.toFixed(2) : '—'} +
+ )} +
+ ) +} + +function TokenRow(props: { token: Token }): React.JSX.Element { + const { token } = props + + return ( + +
+
+ +
+
+ {token.symbol} + + {token.name} + +
+
+ + + ) +} + +function ValidatorsQuickLink(): React.JSX.Element { + return ( + +
+ Validators + + View network validators + +
+ + + ) +} diff --git a/apps/explorer/src/lib/queries/dashboard.ts b/apps/explorer/src/lib/queries/dashboard.ts index 7136f0ed5..05100ed3e 100644 --- a/apps/explorer/src/lib/queries/dashboard.ts +++ b/apps/explorer/src/lib/queries/dashboard.ts @@ -4,7 +4,7 @@ import { getBlock } from 'wagmi/actions' import { getWagmiConfig } from '#wagmi.config.ts' export const DASHBOARD_BLOCKS_COUNT = 5 -export const DASHBOARD_TRANSACTIONS_COUNT = 8 +export const DASHBOARD_TRANSACTIONS_COUNT = 5 export type NetworkStats = { totalTransactions: number diff --git a/apps/explorer/src/routes/_layout/index.tsx b/apps/explorer/src/routes/_layout/index.tsx index df8b4f7bb..45093b366 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -1,77 +1,15 @@ import { createFileRoute, - Link, useNavigate, useRouter, useRouterState, } from '@tanstack/react-router' -import { waapi, stagger } from 'animejs' -import type { Address, Hex } from 'ox' +import { waapi } from 'animejs' import * as React from 'react' import { Dashboard } from '#comps/Dashboard' import { ExploreInput } from '#comps/ExploreInput' -import { cx } from '#lib/css' -import { springInstant, springBouncy, springSmooth } from '#lib/animation' +import { springInstant, springBouncy } from '#lib/animation' import { Intro, type IntroPhase, useIntroSeen } from '#comps/Intro' -import BoxIcon from '~icons/lucide/box' -import ChevronDownIcon from '~icons/lucide/chevron-down' -import CoinsIcon from '~icons/lucide/coins' -import FileIcon from '~icons/lucide/file' -import ReceiptIcon from '~icons/lucide/receipt' -import ShieldCheckIcon from '~icons/lucide/shield-check' -import ShuffleIcon from '~icons/lucide/shuffle' -import UserIcon from '~icons/lucide/user' -import ZapIcon from '~icons/lucide/zap' - -const SPOTLIGHT_DATA: Record< - string, - { - accountAddress: Address.Address - contractAddress: Address.Address - receiptHash: Hex.Hex | null - paymentHash: Hex.Hex | null - swapHash: Hex.Hex | null - mintHash: Hex.Hex | null - } -> = { - testnet: { - accountAddress: '0x5bc1473610754a5ca10749552b119df90c1a1877', - contractAddress: '0x9b400b4c962463E840cCdbE2493Dc6Ab78768266', - receiptHash: - '0x6d6d8c102064e6dee44abad2024a8b1d37959230baab80e70efbf9b0c739c4fd', - paymentHash: - '0x33cdfc39dcda535aac88e7fe3a79954e0740ec26a2fe54eb5481a4cfc0cb8024', - swapHash: - '0x8b6cdb1f6193c17a3733aec315441ab92bca3078b462b27863509a279a5ea6e0', - mintHash: - '0xe5c909ef42674965a8b805118f08b58f215a98661838ae187737841531097b70', - }, - moderato: { - accountAddress: '0xa726a1CD723409074DF9108A2187cfA19899aCF8', - contractAddress: '0x52db6B29F1032b55F1C28354055539b1931CB26e', - receiptHash: - '0x429eb0d8a4565138aec97fe11c8f2f4e56f26725e3a428881bbeba6c4e8ecdc9', - paymentHash: - '0x429eb0d8a4565138aec97fe11c8f2f4e56f26725e3a428881bbeba6c4e8ecdc9', - swapHash: - '0xc61b40cfc6714a893e3d758f2db3e19cd54f175369e17c48591654b294332cf9', - mintHash: - '0x58fcdd78477f7ee402320984e990e7a1623d80b768afb03f9b27fd2eac395032', - }, - presto: { - accountAddress: '0x85269497F0b602a718b85DB5ce490A6c88d01c0E', - contractAddress: '0x4027a3f47d9a421c381bf5d88e22dad5afd4b1a2', - receiptHash: - '0x009293cd8195b5f088729e8839c3ccb7e9d10d98798a1cb11c06e62d77d1dbef', - paymentHash: - '0xc13dbcf74b99ddc7645b1752a031b4bd479e4514b77569e1a4e69ecbf88e7290', - swapHash: null, - mintHash: - '0x3dcdfda1c8689a0fab003174e7a0bc3c5df8c325e566df42cae8fe1a41ac48fb', - }, -} - -const spotlightData = SPOTLIGHT_DATA[import.meta.env.VITE_TEMPO_ENV] export const Route = createFileRoute('/_layout/')({ component: Component, @@ -160,269 +98,8 @@ function Component() { }} />
-
) } - -function SpotlightLinks() { - const introSeen = useIntroSeen() - 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 = React.useCallback(() => { - setActionOpen(false) - if (dropdownMenuRef.current) { - closingRef.current = true - waapi - .animate(dropdownMenuRef.current, { - opacity: [1, 0], - scale: [1, 0.97], - translateY: [0, -4], - ease: springInstant, - }) - .then(() => { - if (!closingRef.current) return - setMenuMounted(false) - }) - } else { - setMenuMounted(false) - } - }, []) - - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - closeMenu() - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [closeMenu]) - - React.useEffect(() => { - if (!pillsRef.current) return - const seen = introSeenOnMount.current - const children = [...pillsRef.current.children] - const delay = seen ? 0 : 320 - setTimeout(() => { - for (const child of children) { - ;(child as HTMLElement).style.pointerEvents = 'auto' - } - }, delay) - const anim = waapi.animate(children as HTMLElement[], { - opacity: [0, 1], - translateY: [seen ? 4 : 8, 0], - scale: [0.97, 1], - ease: seen ? springInstant : springSmooth, - delay: seen ? stagger(10) : stagger(20, { start: delay, from: 'first' }), - }) - anim.then(() => { - for (const child of children) { - ;(child as HTMLElement).style.transform = '' - } - }) - return () => { - try { - anim.cancel() - } catch {} - } - }, []) - - React.useEffect(() => { - if (actionOpen) setMenuMounted(true) - }, [actionOpen]) - - React.useLayoutEffect(() => { - if (!dropdownMenuRef.current) return - if (actionOpen && menuMounted) { - waapi.animate(dropdownMenuRef.current, { - opacity: [0, 1], - scale: [0.97, 1], - translateY: [-4, 0], - ease: springInstant, - }) - } - }, [actionOpen, menuMounted]) - - const handleMouseEnter = () => { - if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current) - if (closingRef.current && dropdownMenuRef.current) { - closingRef.current = false - waapi.animate(dropdownMenuRef.current, { - opacity: 1, - scale: 1, - ease: springInstant, - }) - } - setActionOpen(true) - } - - const handleMouseLeave = () => { - hoverTimeoutRef.current = setTimeout(() => closeMenu(), 150) - } - - const actionTypes = spotlightData - ? [ - { label: 'Payment', hash: spotlightData.paymentHash }, - { label: 'Swap', hash: spotlightData.swapHash }, - { label: 'Mint', hash: spotlightData.mintHash }, - ].filter((a): a is { label: string; hash: Hex.Hex } => a.hash !== null) - : [] - - return ( -
-
- {spotlightData && ( - <> - } - badge={} - > - Account - - } - badge={} - > - Contract - - {spotlightData.receiptHash && ( - } - > - Receipt - - )} - {/** biome-ignore lint/a11y/noStaticElementInteractions: _ */} -
- - {menuMounted && ( -
-
- {actionTypes.map((action, i) => ( - - {action.label} - - ))} -
-
- )} -
- - )} - } - > - Blocks - - } - > - Tokens - - } - > - Validators - -
-
- ) -} - -function SpotlightPill(props: { - className?: string - to: string - params?: Record - search?: Record - icon: React.ReactNode - badge?: React.ReactNode - children: React.ReactNode -}) { - const { className, to, params, search, icon, badge, children } = props - return ( - - {icon} - {children} - {badge && ( - - {badge} - - )} - - ) -} diff --git a/apps/explorer/src/routes/api/stats.ts b/apps/explorer/src/routes/api/stats.ts index e33db8309..a78a4ceb8 100644 --- a/apps/explorer/src/routes/api/stats.ts +++ b/apps/explorer/src/routes/api/stats.ts @@ -76,7 +76,7 @@ export const Route = createFileRoute('/api/stats')({ let txCount24h = 0 for (const block of blocks) { - if (!block || block.timestamp < timestamp24hAgo) continue + if (!block || Number(block.timestamp) < timestamp24hAgo) continue txCount24h += block.transactions.length for (const tx of block.transactions) { if (typeof tx !== 'string') { diff --git a/apps/og/src/index.tsx b/apps/og/src/index.tsx index 958811144..88188e329 100644 --- a/apps/og/src/index.tsx +++ b/apps/og/src/index.tsx @@ -170,9 +170,7 @@ app.get( const [fonts, images, tokenIcon] = await Promise.all([ loadFonts(), loadImages(context.env), - tokenParams.chainId - ? fetchTokenIcon(address, tokenParams.chainId) - : null, + tokenParams.chainId ? fetchTokenIcon(address, tokenParams.chainId) : null, ]) const imageResponse = new ImageResponse( diff --git a/apps/tokenlist/env.d.ts b/apps/tokenlist/env.d.ts index 700bd10e5..70d1a1587 100644 --- a/apps/tokenlist/env.d.ts +++ b/apps/tokenlist/env.d.ts @@ -30,5 +30,4 @@ interface ImportMeta { readonly env: ImportMetaEnv } - declare const __BUILD_VERSION__: string diff --git a/apps/tokenlist/src/docs.tsx b/apps/tokenlist/src/docs.tsx index 8571d7ae8..8047144fd 100644 --- a/apps/tokenlist/src/docs.tsx +++ b/apps/tokenlist/src/docs.tsx @@ -4,9 +4,7 @@ import { html, raw } from 'hono/html' const scalarConfig = { slug: 'tokenlist', hideModels: true, - sources: [ - { url: '/schema/openapi.json', default: true }, - ], + sources: [{ url: '/schema/openapi.json', default: true }], hideClientButton: true, url: '/schema/openapi.json', showDeveloperTools: 'never', From fd39e0743b91b104158944e711479c215eca796b Mon Sep 17 00:00:00 2001 From: max-digi Date: Mon, 19 Jan 2026 19:43:56 +0000 Subject: [PATCH 4/7] fix: simplify stats cards - show active addresses 24h instead of total accounts --- apps/explorer/src/comps/Dashboard.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx index b42bb2cbd..101f681f7 100644 --- a/apps/explorer/src/comps/Dashboard.tsx +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -75,10 +75,8 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { loading={statsLoading} /> } loading={statsLoading} /> @@ -130,8 +128,8 @@ export declare namespace Dashboard { type StatCardProps = { title: string value: number | undefined - subtitle: number | undefined - subtitleLabel: string + subtitle?: number | undefined + subtitleLabel?: string icon: React.ReactNode loading?: boolean } @@ -165,7 +163,7 @@ function StatCard(props: StatCardProps): React.JSX.Element {
{value !== undefined ? formatNumber(value) : '—'}
- {subtitle !== undefined && ( + {subtitle !== undefined && subtitle > 0 && (
+{formatNumber(subtitle)} {subtitleLabel}
From fecdfb8e3799dc302a973310ada9a2c04217fd74 Mon Sep 17 00:00:00 2001 From: max-digi Date: Mon, 19 Jan 2026 21:14:13 +0000 Subject: [PATCH 5/7] feat: replace active addresses with validators count, reorder stats, add link to validators page --- apps/explorer/src/comps/Dashboard.tsx | 73 +++++++++++++++------------ 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx index 101f681f7..d652d619e 100644 --- a/apps/explorer/src/comps/Dashboard.tsx +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -12,6 +12,7 @@ import { dashboardQueryOptions, networkStatsQueryOptions, tokensListQueryOptions, + validatorsQueryOptions, type DashboardBlock, type DashboardTransaction, } from '#lib/queries' @@ -20,9 +21,8 @@ import BoxIcon from '~icons/lucide/box' import ArrowRightIcon from '~icons/lucide/arrow-right' import ClockIcon from '~icons/lucide/clock' import CoinsIcon from '~icons/lucide/coins' - +import ShieldCheckIcon from '~icons/lucide/shield-check' import ZapIcon from '~icons/lucide/zap' -import UsersIcon from '~icons/lucide/users' import ActivityIcon from '~icons/lucide/activity' export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { @@ -44,6 +44,11 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { enabled: visible, }) + const { data: validators, isLoading: validatorsLoading } = useQuery({ + ...validatorsQueryOptions(), + enabled: visible, + }) + const avgBlockTime = data?.blocks ? calculateAvgBlockTime(data.blocks) : null const tps = data?.blocks ? calculateTPS(data.blocks) : null @@ -74,14 +79,15 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { icon={} loading={statsLoading} /> + + } - loading={statsLoading} + title="Validators" + value={validators?.filter((v) => v.active).length} + icon={} + loading={validatorsLoading} + href="/validators" /> - -
-
) } @@ -132,6 +137,7 @@ type StatCardProps = { subtitleLabel?: string icon: React.ReactNode loading?: boolean + href?: string } function formatNumber(num: number): string { @@ -145,13 +151,16 @@ function formatNumber(num: number): string { } function StatCard(props: StatCardProps): React.JSX.Element { - const { title, value, subtitle, subtitleLabel, icon, loading } = props + const { title, value, subtitle, subtitleLabel, icon, loading, href } = props - return ( -
-
- {icon} - {title} + const content = ( + <> +
+
+ {icon} + {title} +
+ {href && }
{loading ? (
@@ -170,6 +179,23 @@ function StatCard(props: StatCardProps): React.JSX.Element { )} )} + + ) + + if (href) { + return ( + + {content} + + ) + } + + return ( +
+ {content}
) } @@ -422,19 +448,4 @@ function TokenRow(props: { token: Token }): React.JSX.Element { ) } -function ValidatorsQuickLink(): React.JSX.Element { - return ( - -
- Validators - - View network validators - -
- - - ) -} + From 1c050a75795e25d96f467b334eaa1d779c068391 Mon Sep 17 00:00:00 2001 From: max-digi Date: Tue, 20 Jan 2026 00:08:25 +0000 Subject: [PATCH 6/7] feat: add styled tooltips, increase stats sample to 50 blocks, make stat cards more compact, rename to Latest Tokens --- apps/explorer/src/comps/Dashboard.tsx | 112 ++++++++++++++------- apps/explorer/src/lib/queries/dashboard.ts | 19 +++- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx index d652d619e..50603d7f0 100644 --- a/apps/explorer/src/comps/Dashboard.tsx +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -49,8 +49,10 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { enabled: visible, }) - const avgBlockTime = data?.blocks ? calculateAvgBlockTime(data.blocks) : null - const tps = data?.blocks ? calculateTPS(data.blocks) : null + const avgBlockTime = data?.statsBlocks + ? calculateAvgBlockTime(data.statsBlocks) + : null + const tps = data?.statsBlocks ? calculateTPS(data.statsBlocks) : null useEffect(() => { if (!visible || !containerRef.current) return @@ -78,6 +80,7 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { subtitleLabel="24h" icon={} loading={statsLoading} + tooltip="Total transactions on the network" /> @@ -87,6 +90,7 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { icon={} loading={validatorsLoading} href="/validators" + tooltip="Active network validators" />
} viewAllLink="/tokens" loading={tokensLoading} @@ -130,6 +134,20 @@ export declare namespace Dashboard { } } +function Tooltip(props: { + text: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+ {props.children} +
+ {props.text} +
+
+ ) +} + type StatCardProps = { title: string value: number | undefined @@ -138,6 +156,7 @@ type StatCardProps = { icon: React.ReactNode loading?: boolean href?: string + tooltip?: string } function formatNumber(num: number): string { @@ -151,29 +170,38 @@ function formatNumber(num: number): string { } function StatCard(props: StatCardProps): React.JSX.Element { - const { title, value, subtitle, subtitleLabel, icon, loading, href } = props + const { title, value, subtitle, subtitleLabel, icon, loading, href, tooltip } = + props + + const titleContent = ( +
+ {icon} + {title} +
+ ) const content = ( <> -
-
- {icon} - {title} -
+
+ {tooltip ? ( + {titleContent} + ) : ( + titleContent + )} {href && }
{loading ? ( -
-
-
+
+
+
) : ( <> -
+
{value !== undefined ? formatNumber(value) : '—'}
{subtitle !== undefined && subtitle > 0 && ( -
+
+{formatNumber(subtitle)} {subtitleLabel}
)} @@ -186,7 +214,7 @@ function StatCard(props: StatCardProps): React.JSX.Element { return ( {content} @@ -194,7 +222,7 @@ function StatCard(props: StatCardProps): React.JSX.Element { } return ( -
+
{content}
) @@ -372,20 +400,28 @@ function AvgBlockTimeCard(props: { }): React.JSX.Element { const { avgBlockTime, loading } = props + const titleContent = ( +
+ + + + Avg Block Time +
+ ) + return ( -
-
- - - - Avg Block Time +
+
+ + {titleContent} +
{loading ? ( -
-
+
+
) : ( -
+
{avgBlockTime !== null ? `${avgBlockTime.toFixed(1)}s` : '—'}
)} @@ -399,20 +435,28 @@ function TPSCard(props: { }): React.JSX.Element { const { tps, loading } = props + const titleContent = ( +
+ + + + TPS +
+ ) + return ( -
-
- - - - TPS +
+
+ + {titleContent} +
{loading ? ( -
-
+
+
) : ( -
+
{tps !== null ? tps.toFixed(2) : '—'}
)} diff --git a/apps/explorer/src/lib/queries/dashboard.ts b/apps/explorer/src/lib/queries/dashboard.ts index 05100ed3e..12ec4e678 100644 --- a/apps/explorer/src/lib/queries/dashboard.ts +++ b/apps/explorer/src/lib/queries/dashboard.ts @@ -3,7 +3,8 @@ import type { Block } from 'viem' import { getBlock } from 'wagmi/actions' import { getWagmiConfig } from '#wagmi.config.ts' -export const DASHBOARD_BLOCKS_COUNT = 5 +export const DASHBOARD_BLOCKS_DISPLAY = 5 +export const DASHBOARD_BLOCKS_STATS = 50 export const DASHBOARD_TRANSACTIONS_COUNT = 5 export type NetworkStats = { @@ -59,7 +60,7 @@ export function dashboardQueryOptions() { const latestBlockNumber = latestBlock.number const blockNumbers: bigint[] = [] - for (let i = 0n; i < BigInt(DASHBOARD_BLOCKS_COUNT); i++) { + for (let i = 0n; i < BigInt(DASHBOARD_BLOCKS_STATS); i++) { const blockNum = latestBlockNumber - i if (blockNum >= 0n) blockNumbers.push(blockNum) } @@ -74,7 +75,8 @@ export function dashboardQueryOptions() { const validBlocks = blocks.filter(Boolean) as Block[] - const recentBlocks: DashboardBlock[] = validBlocks.map((block) => ({ + const displayBlocks = validBlocks.slice(0, DASHBOARD_BLOCKS_DISPLAY) + const recentBlocks: DashboardBlock[] = displayBlocks.map((block) => ({ number: block.number, hash: block.hash, timestamp: block.timestamp, @@ -84,7 +86,7 @@ export function dashboardQueryOptions() { })) const allTransactions: DashboardTransaction[] = [] - for (const block of validBlocks) { + for (const block of displayBlocks) { if (block.number === null) continue for (const tx of block.transactions) { if (typeof tx === 'string') continue @@ -104,6 +106,14 @@ export function dashboardQueryOptions() { latestBlockNumber, blocks: recentBlocks, transactions: allTransactions, + statsBlocks: validBlocks.map((block) => ({ + number: block.number, + hash: block.hash, + timestamp: block.timestamp, + transactions: block.transactions.map((tx) => + typeof tx === 'string' ? tx : tx.hash, + ), + })), } }, staleTime: 5_000, @@ -115,4 +125,5 @@ export type DashboardData = { latestBlockNumber: bigint blocks: DashboardBlock[] transactions: DashboardTransaction[] + statsBlocks: DashboardBlock[] } From 0c6813dd6b51ce61d8568640e7d7a9227d725ce9 Mon Sep 17 00:00:00 2001 From: max-digi Date: Tue, 20 Jan 2026 00:25:44 +0000 Subject: [PATCH 7/7] refactor: convert stat cards to horizontal pills with narrower layout - Change stats from grid cards to horizontal pill-style buttons (rounded-full) - Center and wrap pills on mobile with flex-wrap - Narrow content area from max-w-[1000px] to max-w-[700px] - Move tooltip to wrap entire pill content instead of just title - Simplify StatCard component by removing subtitleLabel prop --- apps/explorer/src/comps/Dashboard.tsx | 120 ++++++++++---------------- 1 file changed, 47 insertions(+), 73 deletions(-) diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx index 50603d7f0..f7b46bf18 100644 --- a/apps/explorer/src/comps/Dashboard.tsx +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -70,9 +70,9 @@ export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { return (
-
+
{icon} - {title} -
- ) - - const content = ( - <> -
- {tooltip ? ( - {titleContent} - ) : ( - titleContent - )} - {href && } -
+ {title} {loading ? ( -
-
-
-
+
) : ( - <> -
- {value !== undefined ? formatNumber(value) : '—'} -
+ + {value !== undefined ? formatNumber(value) : '—'} {subtitle !== undefined && subtitle > 0 && ( -
- +{formatNumber(subtitle)} {subtitleLabel} -
+ + +{formatNumber(subtitle)} + )} - +
)} - + {href && } +
) + const pillClass = + 'bg-surface border border-base-border rounded-full px-4 py-2 hover:border-accent transition-colors' + if (href) { return ( - - {content} + + {tooltip ? {content} : content} ) } return ( -
- {content} +
+ {tooltip ? {content} : content}
) } @@ -400,33 +382,29 @@ function AvgBlockTimeCard(props: { }): React.JSX.Element { const { avgBlockTime, loading } = props - const titleContent = ( + const content = (
- Avg Block Time -
- ) - - return ( -
-
- - {titleContent} - -
+ Block Time {loading ? ( -
-
-
+
) : ( -
+ {avgBlockTime !== null ? `${avgBlockTime.toFixed(1)}s` : '—'} -
+ )}
) + + return ( +
+ + {content} + +
+ ) } function TPSCard(props: { @@ -435,33 +413,29 @@ function TPSCard(props: { }): React.JSX.Element { const { tps, loading } = props - const titleContent = ( + const content = (
- TPS -
- ) - - return ( -
-
- - {titleContent} - -
+ TPS {loading ? ( -
-
-
+
) : ( -
+ {tps !== null ? tps.toFixed(2) : '—'} -
+ )}
) + + return ( +
+ + {content} + +
+ ) } function TokenRow(props: { token: Token }): React.JSX.Element {