diff --git a/apps/explorer/.github/screenshots/bento/bento-hero.webp b/apps/explorer/.github/screenshots/bento/bento-hero.webp new file mode 100644 index 000000000..82a1bacf0 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/bento-hero.webp differ diff --git a/apps/explorer/.github/screenshots/bento/bento-row-2.webp b/apps/explorer/.github/screenshots/bento/bento-row-2.webp new file mode 100644 index 000000000..ee75de740 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/bento-row-2.webp differ diff --git a/apps/explorer/.github/screenshots/bento/bento-row-3.webp b/apps/explorer/.github/screenshots/bento/bento-row-3.webp new file mode 100644 index 000000000..7ae7702e7 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/bento-row-3.webp differ diff --git a/apps/explorer/.github/screenshots/bento/bento-row-4.webp b/apps/explorer/.github/screenshots/bento/bento-row-4.webp new file mode 100644 index 000000000..71e079293 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/bento-row-4.webp differ diff --git a/apps/explorer/.github/screenshots/bento/bento-row-5.webp b/apps/explorer/.github/screenshots/bento/bento-row-5.webp new file mode 100644 index 000000000..ee6c1ac38 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/bento-row-5.webp differ diff --git a/apps/explorer/.github/screenshots/bento/tile-activity-heatmap.webp b/apps/explorer/.github/screenshots/bento/tile-activity-heatmap.webp new file mode 100644 index 000000000..1bf9df294 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/tile-activity-heatmap.webp differ diff --git a/apps/explorer/.github/screenshots/bento/tile-notable-txs.webp b/apps/explorer/.github/screenshots/bento/tile-notable-txs.webp new file mode 100644 index 000000000..10aebb0a7 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/tile-notable-txs.webp differ diff --git a/apps/explorer/.github/screenshots/bento/tile-top-tokens.webp b/apps/explorer/.github/screenshots/bento/tile-top-tokens.webp new file mode 100644 index 000000000..ba1f42f1e Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/tile-top-tokens.webp differ diff --git a/apps/explorer/.github/screenshots/bento/tile-tvl.webp b/apps/explorer/.github/screenshots/bento/tile-tvl.webp new file mode 100644 index 000000000..7da9da8f8 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/tile-tvl.webp differ diff --git a/apps/explorer/.github/screenshots/bento/tile-validators.webp b/apps/explorer/.github/screenshots/bento/tile-validators.webp new file mode 100644 index 000000000..96cbe69c4 Binary files /dev/null and b/apps/explorer/.github/screenshots/bento/tile-validators.webp differ diff --git a/apps/explorer/public/landing-hero/animation.mp4 b/apps/explorer/public/landing-hero/animation.mp4 new file mode 100644 index 000000000..b35051849 Binary files /dev/null and b/apps/explorer/public/landing-hero/animation.mp4 differ diff --git a/apps/explorer/src/comps/HeroSection.tsx b/apps/explorer/src/comps/HeroSection.tsx new file mode 100644 index 000000000..9c95849e1 --- /dev/null +++ b/apps/explorer/src/comps/HeroSection.tsx @@ -0,0 +1,300 @@ +import { useNavigate } from '@tanstack/react-router' +import * as React from 'react' +import { ExploreInput } from '#comps/ExploreInput' +import { cx } from '#lib/css' +import ArrowUpRightIcon from '~icons/lucide/arrow-up-right' + +/** + * Cycling action lines. Each entry pairs a typewriter headline with a + * matching example "event description" rendered below the search input. + * Phrasing mirrors the dynamic OG image templates so the same rough + * shape (action / asset / connector / address) renders consistently. + */ +const ACTIONS: ReadonlyArray = [ + { + headline: 'Interact with code', + event: 'Send 1,000.00 TEST to 0xAb1c…Cc3e', + when: '3s', + }, + { + headline: 'Trace token flows', + event: 'Mint 500 USDC.e to 0xdEad…F00d', + when: '12s', + }, + { + headline: 'Inspect every block', + event: 'Block 15,242,460 sealed by 0xfeec…0000', + when: '1m', + }, + { + headline: 'Audit every transfer', + event: 'Burn 100 EURC.e from 0xC0fe…Ba5e', + when: '4m', + }, + { + headline: 'Search the network', + event: 'Approve 1.5M pathUSD for 0x20FC…0000', + when: '11m', + }, +] as const + +type HeroAction = { + headline: string + event: string + when: string +} + +export function HeroSection(props: HeroSection.Props): React.JSX.Element { + const { searchValue, onSearchChange } = props + const navigate = useNavigate() + const { typed, currentIndex, isPaused } = useTypewriter( + ACTIONS.map((a) => a.headline), + ) + + const current = ACTIONS[currentIndex] ?? ACTIONS[0] + + return ( +
+ +
+
+

+ {typed} + +

+

+ Dive into Tempo's blocks, transactions, assets, and contracts. +

+
+ { + if (data.type === 'block') { + navigate({ to: '/block/$id', params: { id: data.value } }) + return + } + if (data.type === 'hash') { + navigate({ to: '/tx/$hash', params: { hash: data.value } }) + return + } + if (data.type === 'token') { + navigate({ + to: '/token/$address', + params: { address: data.value }, + }) + return + } + if (data.type === 'address') { + navigate({ + to: '/address/$address', + params: { address: data.value }, + }) + } + }} + /> +
+ +
+
+ ) +} + +export declare namespace HeroSection { + type Props = { + searchValue: string + onSearchChange: (value: string) => void + } +} + +/** + * Background looping video. Source asset is dark-mode-friendly (white + * wireframe on black). The page renders dark mode using the source + * untouched; in light mode we invert + hue-rotate 180° so the wireframe + * reads as black on white without shipping a second video file. + */ +function HeroVideoBackground(): React.JSX.Element { + return ( +
+ + {/* Soft radial fade so the video edges blend into the page bg in + both themes (no harsh rectangular cut). */} +
+
+ ) +} + +/** + * Small mock event pill that cross-fades whenever the typewriter advances + * to a new action (the parent passes a fresh `key` so we get an unmount/ + * mount-driven enter animation). + */ +function HeroExamplePill(props: { action: HeroAction }): React.JSX.Element { + const { action } = props + return ( +
+
+ + {action.when} + +
+
+ ) +} + +/** + * Lightweight clone of [apps/og/src/ui.tsx](apps/og/src/ui.tsx)'s + * `parseEventDetails`: splits an event description into action / asset / + * connector / address segments for differentiated styling. + */ +function EventDescription(props: { text: string }): React.JSX.Element { + const groups = React.useMemo( + () => parseEventDetails(props.text), + [props.text], + ) + return ( + + {groups.map((g, i) => ( + + {g.text} + + ))} + + ) +} + +type Group = { text: string; type: 'normal' | 'asset' | 'address' } + +function parseEventDetails(details: string): Group[] { + const groups: Group[] = [] + const words = details.split(' ') + let i = 0 + while (i < words.length) { + const word = words[i] ?? '' + const next = words[i + 1] ?? '' + if ( + word.startsWith('0x') || + (word.includes('…') && /[0-9a-fA-F]/.test(word)) + ) { + groups.push({ text: word, type: 'address' }) + i++ + continue + } + if ( + /^[\d.,]+$/.test(word) && + next && + !['for', 'to', 'from', 'on', 'by'].includes(next) + ) { + groups.push({ text: `${word} ${next}`, type: 'asset' }) + i += 2 + continue + } + groups.push({ text: word, type: 'normal' }) + i++ + } + return groups +} + +/** + * Typewriter loop: + * - type each char ~50ms + * - pause ~1.8s when full string shown + * - delete ~30ms/char + * - advance to next phrase, repeat + */ +function useTypewriter(phrases: ReadonlyArray): { + typed: string + currentIndex: number + isPaused: boolean +} { + const [index, setIndex] = React.useState(0) + const [typed, setTyped] = React.useState('') + const [isPaused, setIsPaused] = React.useState(false) + + React.useEffect(() => { + if (phrases.length === 0) return + let cancelled = false + let timer: ReturnType | null = null + + const target = phrases[index] ?? '' + + const typeNext = (cursor: number) => { + if (cancelled) return + if (cursor < target.length) { + setTyped(target.slice(0, cursor + 1)) + timer = setTimeout(() => typeNext(cursor + 1), 55) + } else { + setIsPaused(true) + timer = setTimeout(() => { + setIsPaused(false) + backspaceNext(target.length) + }, 1800) + } + } + + const backspaceNext = (cursor: number) => { + if (cancelled) return + if (cursor > 0) { + setTyped(target.slice(0, cursor - 1)) + timer = setTimeout(() => backspaceNext(cursor - 1), 32) + } else { + setIndex((prev) => (prev + 1) % phrases.length) + } + } + + typeNext(typed.length) + + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [index, phrases, typed.length]) + + return { typed, currentIndex: index, isPaused } +} diff --git a/apps/explorer/src/comps/Layout.tsx b/apps/explorer/src/comps/Layout.tsx index 57791b892..b60c84b72 100644 --- a/apps/explorer/src/comps/Layout.tsx +++ b/apps/explorer/src/comps/Layout.tsx @@ -1,18 +1,13 @@ import { BreadcrumbsPortal } from '#comps/Breadcrumbs' import { Footer } from '#comps/Footer' import { Header } from '#comps/Header' -import { Sphere } from '#comps/Sphere' import { BlockNumberProvider } from '#lib/block-number' -import { useMatchRoute, useRouterState } from '@tanstack/react-router' +import { useMatchRoute } from '@tanstack/react-router' export function Layout(props: Layout.Props) { const { children } = props const matchRoute = useMatchRoute() const isReceipt = Boolean(matchRoute({ to: '/receipt/$hash', fuzzy: true })) - const isLanding = useRouterState({ - select: (state) => - (state.resolvedLocation?.pathname ?? state.location.pathname) === '/', - }) return (
@@ -26,7 +21,6 @@ export function Layout(props: Layout.Props) {
- {isLanding && }
) diff --git a/apps/explorer/src/comps/Sphere.tsx b/apps/explorer/src/comps/Sphere.tsx index 7a61c31d2..81c6a3beb 100644 --- a/apps/explorer/src/comps/Sphere.tsx +++ b/apps/explorer/src/comps/Sphere.tsx @@ -6,6 +6,7 @@ import { useTheme } from '#lib/theme' export function Sphere(props: Sphere.Props) { const { animate } = props const containerRef = useRef(null) + const rotatorRef = useRef(null) const animateOnMount = useRef(animate) useEffect(() => { @@ -23,14 +24,70 @@ export function Sphere(props: Sphere.Props) { } }, []) + // Looping parallax: as the user scrolls, the orb translates upward at a + // fraction of scroll speed and wraps modulo `wrap` so once it has fully + // exited the top of the viewport it re-enters from the bottom. The + // rotation is layered on top in the same transform to avoid a second + // composited layer. + useEffect(() => { + const node = rotatorRef.current + if (!node) return + + const SCROLL_SPEED = 0.6 + const DEGREES_PER_PIXEL = 0.05 + + let frame: number | null = null + let wrap = window.innerHeight + node.offsetHeight + let lastY = -1 + + const apply = () => { + frame = null + const y = window.scrollY || 0 + if (y === lastY) return + lastY = y + const traveled = y * SCROLL_SPEED + // Start the orb cropped at the top: initial offset = 0 means the + // element sits at its natural `top: 0` position, then the modulo + // belt scrolls it upward and re-enters from the bottom. + const offset = -((traveled % wrap) | 0) + const angle = traveled * DEGREES_PER_PIXEL + node.style.transform = `translate3d(0, ${offset}px, 0) rotate(${angle.toFixed(2)}deg)` + } + + const onScroll = () => { + if (frame != null) return + frame = window.requestAnimationFrame(apply) + } + + const onResize = () => { + wrap = window.innerHeight + node.offsetHeight + apply() + } + + apply() + window.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('resize', onResize) + return () => { + if (frame != null) window.cancelAnimationFrame(frame) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onResize) + } + }, []) + return ( -
+
- +
+ +
) diff --git a/apps/explorer/src/comps/bento/BentoGrid.tsx b/apps/explorer/src/comps/bento/BentoGrid.tsx new file mode 100644 index 000000000..78276d607 --- /dev/null +++ b/apps/explorer/src/comps/bento/BentoGrid.tsx @@ -0,0 +1,30 @@ +import type * as React from 'react' +import { cx } from '#lib/css' + +/** + * Compact masonry grid for the landing bento. At every breakpoint the grid is + * wide enough (2 / 4 / 6 cols) for tiles of mixed sizes (1×1 / 2×1 / 1×2 / 2×2) + * to pack side-by-side via `grid-auto-flow: dense`. + */ +export function BentoGrid(props: BentoGrid.Props): React.JSX.Element { + const { children, className } = props + return ( +
+ {children} +
+ ) +} + +export declare namespace BentoGrid { + type Props = { + children: React.ReactNode + className?: string + } +} diff --git a/apps/explorer/src/comps/bento/BentoTile.tsx b/apps/explorer/src/comps/bento/BentoTile.tsx new file mode 100644 index 000000000..2cb443dd6 --- /dev/null +++ b/apps/explorer/src/comps/bento/BentoTile.tsx @@ -0,0 +1,350 @@ +import { Link, type LinkProps } from '@tanstack/react-router' +import * as React from 'react' +import { cx } from '#lib/css' +import AlertTriangleIcon from '~icons/lucide/triangle-alert' +import ChevronDownIcon from '~icons/lucide/chevron-down' +import RefreshIcon from '~icons/lucide/refresh-cw' + +// Enumerate classes explicitly so Tailwind JIT sees them statically. +const COL_BASE: Record<1 | 2, string> = { + 1: 'col-span-1', + 2: 'col-span-2', +} +const COL_SM: Record<1 | 2 | 3 | 4, string> = { + 1: 'sm:col-span-1', + 2: 'sm:col-span-2', + 3: 'sm:col-span-3', + 4: 'sm:col-span-4', +} +const COL_LG: Record<1 | 2 | 3 | 4 | 6, string> = { + 1: 'lg:col-span-1', + 2: 'lg:col-span-2', + 3: 'lg:col-span-3', + 4: 'lg:col-span-4', + 6: 'lg:col-span-6', +} +const ROW_BASE: Record<1 | 2, string> = { + 1: 'row-span-1', + 2: 'row-span-2', +} +const ROW_SM: Record<1 | 2 | 3, string> = { + 1: 'sm:row-span-1', + 2: 'sm:row-span-2', + 3: 'sm:row-span-3', +} +const ROW_LG: Record<1 | 2 | 3, string> = { + 1: 'lg:row-span-1', + 2: 'lg:row-span-2', + 3: 'lg:row-span-3', +} + +export function BentoTile(props: BentoTile.Props): React.JSX.Element { + const { + children, + title, + titleAside, + action, + className, + span = { base: 1, sm: 1, lg: 1 }, + rowSpan = { base: 1, lg: 1 }, + status, + empty, + onRetry, + contentClassName, + } = props + + const spanClasses = cx( + COL_BASE[span.base ?? 1], + COL_SM[span.sm ?? span.base ?? 1], + COL_LG[span.lg ?? span.sm ?? span.base ?? 1], + ROW_BASE[rowSpan.base ?? 1], + ROW_SM[rowSpan.sm ?? rowSpan.base ?? 1], + ROW_LG[rowSpan.lg ?? rowSpan.sm ?? rowSpan.base ?? 1], + ) + + return ( +
+ {(title || action || titleAside) && ( +
+ {title} + + {titleAside ? ( + + {titleAside} + + ) : null} + {action} + +
+ )} +
+ {status === 'loading' ? ( + + ) : status === 'error' ? ( + + ) : status === 'empty' ? ( + + ) : ( + children + )} +
+
+ ) +} + +BentoTile.Skeleton = function BentoTileSkeleton(): React.JSX.Element { + return ( +
+
+
+
+
+ ) +} + +BentoTile.Error = function BentoTileError(props: { + onRetry?: () => void +}): React.JSX.Element { + const { onRetry } = props + return ( +
+ + Data unavailable + {onRetry ? ( + + ) : null} +
+ ) +} + +BentoTile.Empty = function BentoTileEmpty( + props: BentoTile.EmptyProps, +): React.JSX.Element { + const { icon, label = 'No data yet' } = props + return ( +
+ {icon ? ( + {icon} + ) : null} + {label} +
+ ) +} + +BentoTile.PillAction = function BentoTilePillAction( + props: BentoTile.PillActionProps, +): React.JSX.Element { + const { children, className, ...linkProps } = props + return ( + + {children} + + ) +} + +BentoTile.PrimaryValue = function BentoTilePrimaryValue( + props: BentoTile.PrimaryValueProps, +): React.JSX.Element { + const { value, suffix, className } = props + return ( + + {value} + {suffix ? ( + + {suffix} + + ) : null} + + ) +} + +BentoTile.SelectAction = function BentoTileSelectAction( + props: BentoTile.SelectActionProps, +): React.JSX.Element { + const { value, options, onChange, ariaLabel } = props + const [open, setOpen] = React.useState(false) + const ref = React.useRef(null) + const buttonId = React.useId() + const listId = React.useId() + + React.useEffect(() => { + if (!open) return + const onPointerDown = (evt: PointerEvent) => { + if (!ref.current) return + if (ref.current.contains(evt.target as Node)) return + setOpen(false) + } + const onKey = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') setOpen(false) + } + window.addEventListener('pointerdown', onPointerDown) + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('keydown', onKey) + } + }, [open]) + + const current = options.find((o) => o.value === value) ?? options[0] + + return ( +
+ + {open ? ( +
    + {options.map((opt) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ) +} + +export declare namespace BentoTile { + type Span = { + base?: 1 | 2 + sm?: 1 | 2 | 3 | 4 + lg?: 1 | 2 | 3 | 4 | 6 + } + + type RowSpanProp = { + base?: 1 | 2 + sm?: 1 | 2 | 3 + lg?: 1 | 2 | 3 + } + + type Status = 'loading' | 'error' | 'ready' | 'empty' + + type EmptyProps = { + icon?: React.ReactNode + label?: React.ReactNode + } + + type PillActionProps = React.ComponentProps & { + className?: string + } + + type PrimaryValueProps = { + value: React.ReactNode + suffix?: React.ReactNode + className?: string + } + + type SelectOption = { + value: T + label: React.ReactNode + } + + type SelectActionProps = { + value: T + options: ReadonlyArray> + onChange: (next: T) => void + ariaLabel?: string + } + + type Props = { + children?: React.ReactNode + title?: React.ReactNode + titleAside?: React.ReactNode + action?: React.ReactNode + className?: string + contentClassName?: string + span?: Span + rowSpan?: RowSpanProp + status?: Status + empty?: EmptyProps + /** When provided, renders a retry button in the error state. */ + onRetry?: () => void + } +} diff --git a/apps/explorer/src/comps/bento/LandingBento.tsx b/apps/explorer/src/comps/bento/LandingBento.tsx new file mode 100644 index 000000000..7578dee22 --- /dev/null +++ b/apps/explorer/src/comps/bento/LandingBento.tsx @@ -0,0 +1,35 @@ +import type * as React from 'react' +import { BentoGrid } from '#comps/bento/BentoGrid' +import { ActivityHeatmapTile } from '#comps/bento/tiles/ActivityHeatmapTile' +import { BlockTimeTile } from '#comps/bento/tiles/BlockTimeTile' +import { ChainIdTile } from '#comps/bento/tiles/ChainIdTile' +import { LatestBlockTile } from '#comps/bento/tiles/LatestBlockTile' +import { NewAssetsTile } from '#comps/bento/tiles/NewAssetsTile' +import { NotableTxsTile } from '#comps/bento/tiles/NotableTxsTile' +import { PopularCallsTile } from '#comps/bento/tiles/PopularCallsTile' +import { TopTokensTile } from '#comps/bento/tiles/TopTokensTile' +import { TpsTile } from '#comps/bento/tiles/TpsTile' +import { TvlOverTimeTile } from '#comps/bento/tiles/TvlOverTimeTile' +import { UptimeTile } from '#comps/bento/tiles/UptimeTile' +import { ValidatorsTile } from '#comps/bento/tiles/ValidatorsTile' + +// Source order chosen so `grid-auto-flow: dense` packs every row fully at +// every breakpoint (base 2 cols / sm 4 cols / lg 6 cols). See plan v5. +export function LandingBento(): React.JSX.Element { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/apps/explorer/src/comps/bento/LivePulseDot.tsx b/apps/explorer/src/comps/bento/LivePulseDot.tsx new file mode 100644 index 000000000..7bab8596d --- /dev/null +++ b/apps/explorer/src/comps/bento/LivePulseDot.tsx @@ -0,0 +1,22 @@ +import type * as React from 'react' +import { cx } from '#lib/css' + +/** + * Tiny animated green dot used inline next to tile labels to signal + * that the displayed value is a live (continuously updating) metric. + * Uses the `liveDot` keyframe defined in routes/styles.css. + */ +export function LivePulseDot( + props: { className?: string } = {}, +): React.JSX.Element { + return ( + + ) +} diff --git a/apps/explorer/src/comps/bento/LiveStatus.tsx b/apps/explorer/src/comps/bento/LiveStatus.tsx new file mode 100644 index 000000000..f78bbf616 --- /dev/null +++ b/apps/explorer/src/comps/bento/LiveStatus.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import { useLiveBlockNumber } from '#lib/block-number' +import { cx } from '#lib/css' + +const HEALTHY_THRESHOLD_MS = 10_000 + +type Status = 'live' | 'stale' | 'offline' + +function useOnlineStatus(): boolean { + const [online, setOnline] = React.useState(() => + typeof navigator === 'undefined' ? true : navigator.onLine, + ) + React.useEffect(() => { + const onOnline = () => setOnline(true) + const onOffline = () => setOnline(false) + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) + return () => { + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) + } + }, []) + return online +} + +export function LiveStatus(): React.JSX.Element { + const block = useLiveBlockNumber() + const online = useOnlineStatus() + const lastTickRef = React.useRef(Date.now()) + const lastBlockRef = React.useRef(undefined) + const [, force] = React.useReducer((x: number) => x + 1, 0) + + // Whenever the live block tick changes, stamp the time so we can compute + // how long ago we last heard from the chain. + React.useEffect(() => { + if (block != null && block !== lastBlockRef.current) { + lastBlockRef.current = block + lastTickRef.current = Date.now() + force() + } + }, [block]) + + // Poll for staleness once a second so the dot transitions away from + // "live" without needing a fresh block tick to re-render. + React.useEffect(() => { + const id = setInterval(() => force(), 1000) + return () => clearInterval(id) + }, []) + + const sinceMs = Date.now() - lastTickRef.current + const status: Status = !online + ? 'offline' + : sinceMs <= HEALTHY_THRESHOLD_MS && lastBlockRef.current != null + ? 'live' + : 'stale' + + const dotClass: Record = { + live: 'bg-positive', + stale: 'bg-warning', + offline: 'bg-negative', + } + const haloKeyframe: Record = { + live: 'liveHalo', + stale: 'liveHaloWarning', + offline: 'liveHaloOffline', + } + + return ( + + + {status === 'live' ? ( + Live + ) : status === 'offline' ? ( + Offline + ) : ( + Last seen {formatRelative(sinceMs)} ago + )} + + ) +} + +function formatRelative(ms: number): string { + if (ms < 60_000) return `${Math.max(1, Math.floor(ms / 1000))}s` + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h` + return `${Math.floor(ms / 86_400_000)}d` +} diff --git a/apps/explorer/src/comps/bento/Stat.tsx b/apps/explorer/src/comps/bento/Stat.tsx new file mode 100644 index 000000000..c6a48a932 --- /dev/null +++ b/apps/explorer/src/comps/bento/Stat.tsx @@ -0,0 +1,36 @@ +import type * as React from 'react' +import { cx } from '#lib/css' + +export function Stat(props: Stat.Props): React.JSX.Element { + const { label, value, sub, tone = 'default', className } = props + return ( +
+ {label ? ( + + {label} + + ) : null} + + {value} + + {sub ? {sub} : null} +
+ ) +} + +export declare namespace Stat { + type Props = { + label?: React.ReactNode + value: React.ReactNode + sub?: React.ReactNode + tone?: 'default' | 'positive' | 'negative' + className?: string + } +} diff --git a/apps/explorer/src/comps/bento/ToggleGroup.tsx b/apps/explorer/src/comps/bento/ToggleGroup.tsx new file mode 100644 index 000000000..b800d90c9 --- /dev/null +++ b/apps/explorer/src/comps/bento/ToggleGroup.tsx @@ -0,0 +1,34 @@ +import type * as React from 'react' +import { cx } from '#lib/css' + +export function ToggleGroup( + props: ToggleGroup.Props, +): React.JSX.Element { + return ( +
+ {props.options.map((opt) => ( + + ))} +
+ ) +} + +export declare namespace ToggleGroup { + type Props = { + options: Array<{ value: T; label: string }> + value: T + onChange: (next: T) => void + } +} diff --git a/apps/explorer/src/comps/bento/charts/AreaChart.tsx b/apps/explorer/src/comps/bento/charts/AreaChart.tsx new file mode 100644 index 000000000..c075f5eb0 --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/AreaChart.tsx @@ -0,0 +1,162 @@ +import * as React from 'react' +import { cx } from '#lib/css' +import { buildAreaPath, buildSmoothPath, scaleLinear } from './chart-utils' + +export function AreaChart(props: AreaChart.Props): React.JSX.Element { + const { + values, + height = 120, + width = 320, + className, + ariaLabel, + renderTooltip, + baselines = [], + yDomain, + } = props + + const [hoverIdx, setHoverIdx] = React.useState(null) + const svgRef = React.useRef(null) + const gradientId = React.useId() + + const { linePath, areaPath, points, min, max } = React.useMemo(() => { + if (values.length === 0) + return { + linePath: '', + areaPath: '', + points: [] as Array<{ x: number; y: number }>, + min: 0, + max: 0, + } + const minV = yDomain?.[0] ?? Math.min(...values) + const maxV = yDomain?.[1] ?? Math.max(...values) + const pad = 4 + const pts = values.map((v, i) => ({ + x: scaleLinear(i, 0, values.length - 1, pad, width - pad), + y: scaleLinear(v, minV, maxV, height - pad, pad), + })) + return { + linePath: buildSmoothPath(pts), + areaPath: buildAreaPath(pts, height - pad), + points: pts, + min: minV, + max: maxV, + } + }, [values, height, width, yDomain]) + + function handleMove(evt: React.PointerEvent) { + if (values.length === 0 || !svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const x = ((evt.clientX - rect.left) / rect.width) * width + const idx = Math.min( + values.length - 1, + Math.max(0, Math.round((x / width) * (values.length - 1))), + ) + setHoverIdx(idx) + } + + return ( +
+ setHoverIdx(null)} + > + + + + + + + {baselines.map((b) => { + const y = scaleLinear(b.value, min, max, height - 4, 4) + return ( + + + + ) + })} + {areaPath ? : null} + {linePath ? ( + + ) : null} + {hoverIdx != null && points[hoverIdx] ? ( + <> + + + + ) : null} + + {hoverIdx != null && renderTooltip ? ( +
+ {renderTooltip(hoverIdx)} +
+ ) : null} +
+ ) +} + +export declare namespace AreaChart { + type Props = { + values: number[] + height?: number + width?: number + className?: string + ariaLabel?: string + renderTooltip?: (index: number) => React.ReactNode + baselines?: Array<{ label: string; value: number }> + yDomain?: [number, number] + } +} diff --git a/apps/explorer/src/comps/bento/charts/BarChart.tsx b/apps/explorer/src/comps/bento/charts/BarChart.tsx new file mode 100644 index 000000000..ed032a5a9 --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/BarChart.tsx @@ -0,0 +1,209 @@ +import * as React from 'react' +import { cx } from '#lib/css' + +export function BarChart(props: BarChart.Props): React.JSX.Element { + const { + values, + height = 80, + width = 280, + className, + ariaLabel, + renderTooltip, + max: maxOverride, + showBaseline = false, + gradient = false, + } = props + + const [hoverIdx, setHoverIdx] = React.useState(null) + const svgRef = React.useRef(null) + + const max = maxOverride ?? (values.length ? Math.max(...values, 1) : 1) + const gap = values.length > 40 ? 1 : values.length > 20 ? 1.5 : 2 + const barWidth = Math.max( + 1, + (width - gap * (values.length - 1)) / Math.max(1, values.length), + ) + + function handleMove(evt: React.PointerEvent) { + if (values.length === 0 || !svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const x = ((evt.clientX - rect.left) / rect.width) * width + const idx = Math.min( + values.length - 1, + Math.max(0, Math.floor(x / (barWidth + gap))), + ) + setHoverIdx(idx) + } + + return ( +
+ setHoverIdx(null)} + > + {showBaseline ? ( + + ) : null} + {values.map((v, i) => { + const h = max > 0 ? (v / max) * (height - 2) : 0 + const x = i * (barWidth + gap) + const y = height - h + const active = hoverIdx === i + // Bar opacity ramps from `0.2` at the leftmost (oldest) sample + // up to `1.0` at the rightmost (newest) sample when gradient + // mode is enabled. Hover always pops to full saturation. + const baseOpacity = + gradient && values.length > 1 + ? 0.2 + (i / (values.length - 1)) * 0.8 + : 0.85 + return ( + 0 ? 0.5 : 0)} + rx={Math.min(1.5, barWidth / 3)} + fill="var(--color-accent)" + fillOpacity={active ? 1 : baseOpacity} + /> + ) + })} + + {hoverIdx != null && renderTooltip ? ( +
+ {renderTooltip(hoverIdx)} +
+ ) : null} +
+ ) +} + +export declare namespace BarChart { + type Props = { + values: number[] + height?: number + width?: number + className?: string + ariaLabel?: string + renderTooltip?: (index: number) => React.ReactNode + max?: number + showBaseline?: boolean + gradient?: boolean + } +} + +export function StackedBarChart( + props: StackedBarChart.Props, +): React.JSX.Element { + const { + series, + totals, + height = 80, + width = 280, + className, + ariaLabel, + renderTooltip, + } = props + + const [hoverIdx, setHoverIdx] = React.useState(null) + const svgRef = React.useRef(null) + + const length = series[0]?.length ?? 0 + const max = totals ? Math.max(...totals, 1) : 1 + const gap = length > 40 ? 1 : 1.5 + const barWidth = Math.max( + 1, + (width - gap * (length - 1)) / Math.max(1, length), + ) + + function handleMove(evt: React.PointerEvent) { + if (length === 0 || !svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const x = ((evt.clientX - rect.left) / rect.width) * width + const idx = Math.min( + length - 1, + Math.max(0, Math.floor(x / (barWidth + gap))), + ) + setHoverIdx(idx) + } + + return ( +
+ setHoverIdx(null)} + > + {Array.from({ length }).map((_, i) => { + const x = i * (barWidth + gap) + let offset = 0 + return series.map((serie, sIdx) => { + const v = serie[i] ?? 0 + const h = max > 0 ? (v / max) * (height - 2) : 0 + const y = height - offset - h + offset += h + const active = hoverIdx === i + return ( + 0 ? 0.5 : 0)} + fill={ + sIdx === 0 + ? 'var(--color-accent)' + : 'var(--color-border-primary)' + } + fillOpacity={ + sIdx === 0 ? (active ? 1 : 0.85) : active ? 0.7 : 0.5 + } + /> + ) + }) + })} + + {hoverIdx != null && renderTooltip ? ( +
+ {renderTooltip(hoverIdx)} +
+ ) : null} +
+ ) +} + +export declare namespace StackedBarChart { + type Props = { + /** Series from bottom to top. */ + series: number[][] + /** Pre-computed per-bar totals for scaling; overrides sum(series). */ + totals?: number[] + height?: number + width?: number + className?: string + ariaLabel?: string + renderTooltip?: (index: number) => React.ReactNode + } +} diff --git a/apps/explorer/src/comps/bento/charts/Donut.tsx b/apps/explorer/src/comps/bento/charts/Donut.tsx new file mode 100644 index 000000000..bdc4a61bb --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/Donut.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { cx } from '#lib/css' + +export function Donut(props: Donut.Props): React.JSX.Element { + const { + segments, + size = 120, + thickness = 12, + gap = 2, + className, + ariaLabel, + children, + } = props + + const radius = size / 2 - thickness / 2 - 2 + const circumference = 2 * Math.PI * radius + const total = segments.reduce((acc, s) => acc + s.value, 0) || 1 + + const [hoverIdx, setHoverIdx] = React.useState(null) + + let offset = 0 + + return ( +
+ + + {segments.map((s, i) => { + const length = (s.value / total) * circumference + const dash = `${Math.max(0, length - gap)} ${circumference}` + const rotation = ((offset / circumference) * 360 - 90).toFixed(2) + offset += length + const active = hoverIdx === i + return ( + setHoverIdx(i)} + onPointerLeave={() => setHoverIdx(null)} + > + + {s.label}: {s.value} + + + ) + })} + +
+ {children} +
+
+ ) +} + +export declare namespace Donut { + type Segment = { + label: string + value: number + color?: string + } + + type Props = { + segments: Segment[] + size?: number + thickness?: number + gap?: number + className?: string + ariaLabel?: string + children?: React.ReactNode + } +} diff --git a/apps/explorer/src/comps/bento/charts/Heatmap.tsx b/apps/explorer/src/comps/bento/charts/Heatmap.tsx new file mode 100644 index 000000000..4fccb1987 --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/Heatmap.tsx @@ -0,0 +1,166 @@ +import * as React from 'react' +import { cx } from '#lib/css' + +/** + * GitHub-style contribution heatmap. Values are laid out column-major — each + * column index `c` is one bucket in the X axis, each row index `r` is one + * bucket in the Y axis. Cell size is derived from the container's available + * width/height so the grid fills the tile without the cells stretching to a + * fixed pixel size. + */ +export function Heatmap(props: Heatmap.Props): React.JSX.Element { + const { + columns, + rows, + getValue, + getLabel, + max, + className, + ariaLabel, + minCellSize = 4, + maxCellSize = Number.POSITIVE_INFINITY, + } = props + + const containerRef = React.useRef(null) + const [box, setBox] = React.useState<{ w: number; h: number } | null>(null) + const [hover, setHover] = React.useState<{ + col: number + row: number + x: number + y: number + } | null>(null) + + React.useEffect(() => { + const node = containerRef.current + if (!node) return + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const rect = entry.contentRect + setBox({ w: rect.width, h: rect.height }) + } + }) + ro.observe(node) + return () => ro.disconnect() + }, []) + + const computedMax = React.useMemo(() => { + if (max != null) return max + let m = 0 + for (let c = 0; c < columns; c++) { + for (let r = 0; r < rows; r++) { + const v = getValue(c, r) + if (v > m) m = v + } + } + return Math.max(1, m) + }, [columns, rows, getValue, max]) + + const layout = React.useMemo(() => { + if (!box || box.w <= 0 || box.h <= 0) return null + // Tighter cell-to-gap ratio (~6:1) for a denser GitHub-graph feel. + const ratio = 0.16 + const cellByWidth = box.w / (columns + (columns - 1) * ratio) + const cellByHeight = box.h / (rows + (rows - 1) * ratio) + const raw = Math.min(cellByWidth, cellByHeight) + const cellSize = Math.max(minCellSize, Math.min(maxCellSize, raw)) + const cellGap = Math.max(1, cellSize * ratio) + const innerW = columns * cellSize + (columns - 1) * cellGap + const innerH = rows * cellSize + (rows - 1) * cellGap + return { cellSize, cellGap, innerW, innerH } + }, [box, columns, rows, minCellSize, maxCellSize]) + + function handlePointer( + evt: React.PointerEvent, + c: number, + r: number, + ) { + const rect = ( + containerRef.current as HTMLDivElement + ).getBoundingClientRect() + setHover({ + col: c, + row: r, + x: evt.clientX - rect.left, + y: evt.clientY - rect.top, + }) + } + + return ( +
+ {layout ? ( + + {Array.from({ length: columns }).map((_, c) => + Array.from({ length: rows }).map((_, r) => { + const v = getValue(c, r) + const opacity = + v <= 0 ? 0 : Math.min(1, 0.12 + (v / computedMax) * 0.88) + const x = c * (layout.cellSize + layout.cellGap) + const y = r * (layout.cellSize + layout.cellGap) + const active = hover?.col === c && hover?.row === r + return ( + handlePointer(e, c, r)} + onPointerLeave={() => setHover(null)} + /> + ) + }), + )} + + ) : null} + {hover && getLabel ? ( +
+ {getLabel(hover.col, hover.row, getValue(hover.col, hover.row))} +
+ ) : null} +
+ ) +} + +export declare namespace Heatmap { + type Props = { + columns: number + rows: number + getValue: (col: number, row: number) => number + getLabel?: (col: number, row: number, value: number) => string + max?: number + className?: string + ariaLabel?: string + minCellSize?: number + maxCellSize?: number + } +} diff --git a/apps/explorer/src/comps/bento/charts/Sparkline.tsx b/apps/explorer/src/comps/bento/charts/Sparkline.tsx new file mode 100644 index 000000000..7f46a02ea --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/Sparkline.tsx @@ -0,0 +1,139 @@ +import * as React from 'react' +import { cx } from '#lib/css' +import { buildAreaPath, buildSmoothPath, scaleLinear } from './chart-utils' + +export function Sparkline(props: Sparkline.Props): React.JSX.Element { + const { + values, + height = 48, + width = 160, + className, + fill = true, + strokeWidth = 1.25, + ariaLabel, + onHoverIndex, + } = props + + const [hoverIdx, setHoverIdx] = React.useState(null) + const svgRef = React.useRef(null) + + const { linePath, areaPath, points, min, max } = React.useMemo(() => { + if (values.length === 0) + return { + linePath: '', + areaPath: '', + points: [] as Array<{ x: number; y: number }>, + min: 0, + max: 0, + } + const minV = Math.min(...values) + const maxV = Math.max(...values) + const pad = 2 + const pts = values.map((v, i) => ({ + x: scaleLinear(i, 0, values.length - 1, pad, width - pad), + y: scaleLinear(v, minV, maxV, height - pad, pad), + })) + return { + linePath: buildSmoothPath(pts), + areaPath: buildAreaPath(pts, height - pad), + points: pts, + min: minV, + max: maxV, + } + }, [values, height, width]) + + const gradientId = React.useId() + + function handleMove(evt: React.PointerEvent) { + if (values.length === 0 || !svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const x = ((evt.clientX - rect.left) / rect.width) * width + const idx = Math.min( + values.length - 1, + Math.max(0, Math.round((x / width) * (values.length - 1))), + ) + setHoverIdx(idx) + onHoverIndex?.(idx) + } + + function handleLeave() { + setHoverIdx(null) + onHoverIndex?.(null) + } + + return ( + + + + + + + + {fill && areaPath ? ( + + ) : null} + {linePath ? ( + + ) : null} + {hoverIdx != null && points[hoverIdx] ? ( + <> + + + + ) : null} + + ) +} + +export declare namespace Sparkline { + type Props = { + values: number[] + height?: number + width?: number + className?: string + fill?: boolean + strokeWidth?: number + ariaLabel?: string + onHoverIndex?: (idx: number | null) => void + } +} diff --git a/apps/explorer/src/comps/bento/charts/StackedBarTimeChart.tsx b/apps/explorer/src/comps/bento/charts/StackedBarTimeChart.tsx new file mode 100644 index 000000000..455d95fd2 --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/StackedBarTimeChart.tsx @@ -0,0 +1,142 @@ +import * as React from 'react' +import { cx } from '#lib/css' + +export type StackedSeries = { + key: string + label: string + /** Numeric totals per day (same length as `dates`). */ + values: number[] + color: string +} + +export function StackedBarTimeChart( + props: StackedBarTimeChart.Props, +): React.JSX.Element { + const { + dates, + series, + height = 140, + width = 560, + className, + ariaLabel, + formatValue, + formatDate = (d) => new Date(d * 1000).toLocaleDateString(), + } = props + + const [hoverIdx, setHoverIdx] = React.useState(null) + const svgRef = React.useRef(null) + + const totals = React.useMemo(() => { + const len = dates.length + const out = new Array(len).fill(0) + for (const s of series) { + for (let i = 0; i < len; i++) out[i] += s.values[i] ?? 0 + } + return out + }, [dates.length, series]) + + const max = Math.max(1, ...totals) + + const gap = dates.length > 14 ? 1.5 : 3 + const barWidth = Math.max( + 1, + (width - gap * (dates.length - 1)) / Math.max(1, dates.length), + ) + + function handleMove(evt: React.PointerEvent) { + if (!dates.length || !svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const x = ((evt.clientX - rect.left) / rect.width) * width + const idx = Math.min( + dates.length - 1, + Math.max(0, Math.floor(x / (barWidth + gap))), + ) + setHoverIdx(idx) + } + + return ( +
+ setHoverIdx(null)} + > + {dates.map((_, i) => { + const x = i * (barWidth + gap) + let offset = 0 + return series.map((s) => { + const v = s.values[i] ?? 0 + const h = max > 0 ? (v / max) * (height - 2) : 0 + const y = height - offset - h + offset += h + const active = hoverIdx === i + return ( + 0 ? 0.5 : 0)} + fill={s.color} + fillOpacity={active ? 1 : 0.88} + /> + ) + }) + })} + + {hoverIdx != null ? ( +
+
+ {formatDate(dates[hoverIdx])} +
+
    + {[...series] + .map((s) => ({ + key: s.key, + label: s.label, + color: s.color, + v: s.values[hoverIdx] ?? 0, + })) + .sort((a, b) => b.v - a.v) + .slice(0, 6) + .map((s) => ( +
  • + + + {s.label} + + + {formatValue ? formatValue(s.v) : s.v.toFixed(0)} + +
  • + ))} +
+
+ ) : null} +
+ ) +} + +export declare namespace StackedBarTimeChart { + type Props = { + dates: number[] + series: StackedSeries[] + height?: number + width?: number + className?: string + ariaLabel?: string + formatValue?: (v: number) => string + formatDate?: (unixSeconds: number) => string + } +} diff --git a/apps/explorer/src/comps/bento/charts/StatusBar.tsx b/apps/explorer/src/comps/bento/charts/StatusBar.tsx new file mode 100644 index 000000000..0531dbe63 --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/StatusBar.tsx @@ -0,0 +1,52 @@ +import type * as React from 'react' +import { cx } from '#lib/css' + +export type StatusSegment = { + status: 'healthy' | 'slow' | 'stalled' + label?: string +} + +const SEGMENT_CLASS: Record = { + healthy: 'bg-positive/75', + slow: 'bg-warning/80', + stalled: 'bg-negative/70', +} + +/** + * Status-page style thin bars. Each segment is a fixed-width colored stripe + * with a native tooltip; hovering any segment in the row slightly brightens + * all of them to suggest the row is one continuous indicator. + */ +export function StatusBar(props: StatusBar.Props): React.JSX.Element { + const { segments, className, ariaLabel } = props + return ( +
+ {segments.map((segment, i) => ( + + ))} +
+ ) +} + +export declare namespace StatusBar { + type Props = { + segments: StatusSegment[] + className?: string + ariaLabel?: string + } +} diff --git a/apps/explorer/src/comps/bento/charts/chart-utils.ts b/apps/explorer/src/comps/bento/charts/chart-utils.ts new file mode 100644 index 000000000..9e223771c --- /dev/null +++ b/apps/explorer/src/comps/bento/charts/chart-utils.ts @@ -0,0 +1,62 @@ +export type Point = { x: number; y: number } + +export function buildLinePath(points: Point[]): string { + if (points.length === 0) return '' + const [first, ...rest] = points + let d = `M ${first.x.toFixed(2)} ${first.y.toFixed(2)}` + for (const p of rest) { + d += ` L ${p.x.toFixed(2)} ${p.y.toFixed(2)}` + } + return d +} + +/** + * Build a smooth catmull-rom-ish path. Keeps a natural curve without needing + * an extra dependency. + */ +export function buildSmoothPath(points: Point[]): string { + if (points.length < 2) return buildLinePath(points) + const tension = 0.5 + let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}` + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i - 1] ?? points[i] + const p1 = points[i] + const p2 = points[i + 1] + const p3 = points[i + 2] ?? p2 + const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension + const cp1y = p1.y + ((p2.y - p0.y) / 6) * tension + const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension + const cp2y = p2.y - ((p3.y - p1.y) / 6) * tension + d += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}` + } + return d +} + +export function buildAreaPath(points: Point[], baseY: number): string { + if (points.length === 0) return '' + const line = buildSmoothPath(points) + const first = points[0] + const last = points[points.length - 1] + return `${line} L ${last.x.toFixed(2)} ${baseY} L ${first.x.toFixed(2)} ${baseY} Z` +} + +export function scaleLinear( + value: number, + domainMin: number, + domainMax: number, + rangeMin: number, + rangeMax: number, +): number { + if (domainMax === domainMin) return (rangeMin + rangeMax) / 2 + const t = (value - domainMin) / (domainMax - domainMin) + return rangeMin + t * (rangeMax - rangeMin) +} + +export function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0 + const idx = Math.min( + sorted.length - 1, + Math.max(0, Math.floor((p / 100) * sorted.length)), + ) + return sorted[idx] +} diff --git a/apps/explorer/src/comps/bento/tiles/ActivityHeatmapTile.tsx b/apps/explorer/src/comps/bento/tiles/ActivityHeatmapTile.tsx new file mode 100644 index 000000000..e5946ec43 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/ActivityHeatmapTile.tsx @@ -0,0 +1,180 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { Heatmap } from '#comps/bento/charts/Heatmap' +import { ToggleGroup } from '#comps/bento/ToggleGroup' +import { + landingHeatmapGasQueryOptions, + landingHeatmapQueryOptions, +} from '#lib/queries' +import type { HeatmapWindow } from '#lib/server/landing-stats' +import GridIcon from '~icons/lucide/grid-2x2' + +const HOURS = 24 + +const WINDOW_OPTIONS: ReadonlyArray<{ + value: HeatmapWindow + label: string + days: number +}> = [ + { value: '7d', label: '7d', days: 7 }, + { value: '30d', label: '30d', days: 30 }, + { value: '90d', label: '90d', days: 90 }, +] + +/** + * Lay out the heatmap as a wide grid: more columns than rows, GitHub-graph + * style. Hourly buckets are flattened then reshaped so the visible matrix + * is `cols` wide × `rows` tall, with `cols >= rows` and `cols * rows` + * approximating the bucket count. + */ +function gridShape(totalBuckets: number): { cols: number; rows: number } { + // Wide GitHub-graph layout: cols ≈ 5 × rows so the heatmap fills the + // wide tile horizontally with plenty of cells. + const targetRatio = 5 + const rows = Math.max( + 4, + Math.round(Math.sqrt(totalBuckets / targetRatio)), + ) + const cols = Math.ceil(totalBuckets / rows) + return { cols, rows } +} + +const DATE_FMT = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', +}) + +function hourLabel(h: number) { + if (h === 0) return '12a' + if (h === 12) return '12p' + return h < 12 ? `${h}a` : `${h - 12}p` +} + +const compact = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, +}) + +type Mode = 'txs' | 'gas' + +export function ActivityHeatmapTile(): React.JSX.Element { + const [mode, setMode] = React.useState('txs') + const [window, setWindow] = React.useState('7d') + + const txsQuery = useQuery(landingHeatmapQueryOptions(window)) + const gasQuery = useQuery(landingHeatmapGasQueryOptions(window)) + const query = mode === 'txs' ? txsQuery : gasQuery + + const days = WINDOW_OPTIONS.find((o) => o.value === window)?.days ?? 7 + + const { flat, total, startHourSec, cols, rows } = React.useMemo(() => { + const totalBuckets = days * HOURS + const shape = gridShape(totalBuckets) + const empty = { + flat: [] as number[], + total: 0, + startHourSec: 0, + cols: shape.cols, + rows: shape.rows, + } + if (!query.data) return empty + + const now = Math.floor(Date.now() / 1000) + const nowHourSec = Math.floor(now / 3600) * 3600 + const startHourSec = nowHourSec - (totalBuckets - 1) * 3600 + + const bySecond = new Map() + for (const b of query.data.buckets) bySecond.set(b.hour, b.count) + + const flat: number[] = new Array(totalBuckets).fill(0) + for (let h = 0; h < totalBuckets; h++) { + const epochSec = startHourSec + h * 3600 + flat[h] = bySecond.get(epochSec) ?? 0 + } + + let totalCount = 0 + for (const v of flat) totalCount += v + return { + flat, + total: totalCount, + startHourSec, + cols: shape.cols, + rows: shape.rows, + } + }, [query.data, days]) + + const isLoading = query.isPending + const isError = query.isError + const isEmpty = !isLoading && !isError && total === 0 + + const totalLabel = + mode === 'txs' ? total.toLocaleString() : compact.format(total) + + return ( + + Transactions ({window}) + + } + span={{ base: 2, sm: 4, lg: 4 }} + rowSpan={{ base: 2, lg: 2 }} + status={ + isLoading ? 'loading' : isError ? 'error' : isEmpty ? 'empty' : 'ready' + } + empty={{ icon: , label: 'No activity in selected window' }} + onRetry={() => query.refetch()} + action={ +
+ + options={[ + { value: 'txs', label: 'Transactions' }, + { value: 'gas', label: 'Gas' }, + ]} + value={mode} + onChange={setMode} + /> + + value={window} + options={WINDOW_OPTIONS.map(({ value, label }) => ({ + value, + label, + }))} + onChange={setWindow} + ariaLabel="heatmap window" + /> +
+ } + contentClassName="gap-2" + > + +
+ { + // Column-major: each column is a vertical strip of hours + // running from oldest (top) to newest (bottom). + const idx = col * rows + row + return flat[idx] ?? 0 + }} + getLabel={(col, row, v) => { + const idx = col * rows + row + if (idx >= flat.length) return '' + const epoch = startHourSec + idx * 3600 + const date = new Date(epoch * 1000) + const dateLabel = DATE_FMT.format(date) + const hour = hourLabel(date.getHours()) + const label = + mode === 'txs' + ? `${v.toLocaleString()} txs` + : `${compact.format(v)} gas` + return `${dateLabel} · ${hour} — ${label}` + }} + ariaLabel={`activity heatmap (${mode}) over ${window}`} + /> +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/BlockTimeTile.tsx b/apps/explorer/src/comps/bento/tiles/BlockTimeTile.tsx new file mode 100644 index 000000000..0f56c5efc --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/BlockTimeTile.tsx @@ -0,0 +1,94 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { BarChart } from '#comps/bento/charts/BarChart' +import { percentile } from '#comps/bento/charts/chart-utils' +import { LivePulseDot } from '#comps/bento/LivePulseDot' +import { landingRecentBlocksQueryOptions } from '#lib/queries' +import TimerIcon from '~icons/lucide/timer' + +const BINS = 30 + +export function BlockTimeTile(): React.JSX.Element { + const { data, isPending, isError, refetch } = useQuery( + landingRecentBlocksQueryOptions(), + ) + + const { bins, p50, p95, avg } = React.useMemo(() => { + if (!data || data.blocks.length < 2) + return { bins: [], p50: 0, p95: 0, avg: 0 } + + // Per-block time differences (integer seconds from testnet). + const diffs: number[] = [] + for (let i = 1; i < data.blocks.length; i++) { + const dt = data.blocks[i].timestamp - data.blocks[i - 1].timestamp + if (dt >= 0) diffs.push(dt) + } + + // Bucket diffs into BINS bins and average each — smooths out the + // 0/1 quantization from second-resolution timestamps into readable + // floating-point values. + const bucket = new Array(BINS).fill(0) + const counts = new Array(BINS).fill(0) + for (let i = 0; i < diffs.length; i++) { + const bIdx = Math.min(BINS - 1, Math.floor((i / diffs.length) * BINS)) + bucket[bIdx] += diffs[i] + counts[bIdx] += 1 + } + const binAverages = bucket.map((sum, i) => + counts[i] > 0 ? sum / counts[i] : 0, + ) + + const sorted = [...diffs].sort((a, b) => a - b) + const sum = diffs.reduce((a, b) => a + b, 0) + return { + bins: binAverages, + p50: percentile(sorted, 50), + p95: percentile(sorted, 95), + avg: diffs.length ? sum / diffs.length : 0, + } + }, [data]) + + const isEmpty = !isPending && !isError && bins.length === 0 + + return ( + + + Block time + + } + titleAside={ + bins.length ? `p50 ${p50.toFixed(1)}s · p95 ${p95.toFixed(1)}s` : null + } + span={{ base: 2, sm: 2, lg: 2 }} + rowSpan={{ base: 1, lg: 1 }} + status={ + isPending ? 'loading' : isError ? 'error' : isEmpty ? 'empty' : 'ready' + } + empty={{ icon: , label: 'Waiting for blocks' }} + onRetry={() => refetch()} + contentClassName="justify-end gap-1.5" + > +
+ +
+
+ {bins.length ? ( + `${bins[i].toFixed(2)}s avg`} + /> + ) : null} +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/ChainIdTile.tsx b/apps/explorer/src/comps/bento/tiles/ChainIdTile.tsx new file mode 100644 index 000000000..24f02a235 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/ChainIdTile.tsx @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query' +import type * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { getTempoEnv, type TempoEnv } from '#lib/env' +import { landingChainVitalsQueryOptions } from '#lib/queries' + +const ENV_OPTIONS: ReadonlyArray<{ + value: TempoEnv + label: string + host: string +}> = [ + { + value: 'testnet', + label: 'Testnet', + host: 'https://explore.testnet.tempo.xyz', + }, + { + value: 'mainnet', + label: 'Mainnet', + host: 'https://explore.mainnet.tempo.xyz', + }, + { + value: 'devnet', + label: 'Devnet', + host: 'https://explore.devnet.tempo.xyz', + }, +] + +function navigateToEnv(next: TempoEnv) { + if (typeof window === 'undefined') return + const target = ENV_OPTIONS.find((o) => o.value === next) + if (!target) return + if (next === getTempoEnv()) return + window.location.assign( + `${target.host}${window.location.pathname}${window.location.search}`, + ) +} + +export function ChainIdTile(): React.JSX.Element { + const { data, isPending } = useQuery(landingChainVitalsQueryOptions()) + const env = getTempoEnv() + + return ( + + value={env} + options={ENV_OPTIONS.map(({ value, label }) => ({ value, label }))} + onChange={navigateToEnv} + ariaLabel="switch network" + /> + } + span={{ base: 1, sm: 1, lg: 1 }} + rowSpan={{ base: 1, lg: 1 }} + status={isPending ? 'loading' : 'ready'} + contentClassName="justify-end" + > + + + ) +} diff --git a/apps/explorer/src/comps/bento/tiles/LatestBlockTile.tsx b/apps/explorer/src/comps/bento/tiles/LatestBlockTile.tsx new file mode 100644 index 000000000..bc6474fbc --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/LatestBlockTile.tsx @@ -0,0 +1,103 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { LivePulseDot } from '#comps/bento/LivePulseDot' +import { useLiveBlockNumber } from '#lib/block-number' +import { landingRecentBlocksQueryOptions } from '#lib/queries' +import ActivityIcon from '~icons/lucide/activity' + +const PADDED_LEN = 15 + +export function LatestBlockTile(): React.JSX.Element { + const live = useLiveBlockNumber() + + const { data } = useQuery(landingRecentBlocksQueryOptions()) + + const avgBlockTime = React.useMemo(() => { + if (!data || data.blocks.length < 2) return 0 + const diffs: number[] = [] + for (let i = 1; i < data.blocks.length; i++) { + const dt = data.blocks[i].timestamp - data.blocks[i - 1].timestamp + if (dt > 0) diffs.push(dt) + } + if (diffs.length === 0) return 0 + return diffs.reduce((a, b) => a + b, 0) / diffs.length + }, [data]) + + if (live == null) { + return ( + + + Latest block + + } + span={{ base: 2, sm: 2, lg: 2 }} + rowSpan={{ base: 1, lg: 1 }} + status="empty" + empty={{ icon: , label: 'Waiting for blocks' }} + /> + ) + } + + return ( + + + Latest block + + } + span={{ base: 2, sm: 2, lg: 2 }} + rowSpan={{ base: 1, lg: 1 }} + contentClassName="justify-end gap-1" + > + + + {avgBlockTime ? `${avgBlockTime.toFixed(2)}s / block` : '\u00a0'} + + + ) +} + +/** + * Renders the block number padded to {PADDED_LEN} chars in a lotto-style + * row of digits. Per-digit, we animate ONLY the digits that changed + * since the previous render — leaving unchanged glyphs static — using + * the `digitFlash` keyframe defined in routes/styles.css. + */ +function DiffingBlockNumber(props: { value: bigint }): React.JSX.Element { + const str = String(props.value).padStart(PADDED_LEN, '0') + const zerosEnd = str.match(/^0*/)?.[0].length ?? 0 + + const prevRef = React.useRef(str) + const prev = prevRef.current + React.useEffect(() => { + prevRef.current = str + }, [str]) + + return ( +
+ + {str.split('').map((char, i) => { + const isPad = i < zerosEnd + const changed = prev[i] !== char + return ( + + {char} + + ) + })} + +
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/NewAssetsTile.tsx b/apps/explorer/src/comps/bento/tiles/NewAssetsTile.tsx new file mode 100644 index 000000000..4bdfdfbd6 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/NewAssetsTile.tsx @@ -0,0 +1,56 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import type * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { RelativeTime } from '#comps/RelativeTime' +import { TokenIcon } from '#comps/TokenIcon' +import { landingTokenLaunchesQueryOptions } from '#lib/queries' +import SparklesIcon from '~icons/lucide/sparkles' + +const LIST_LIMIT = 8 + +export function NewAssetsTile(): React.JSX.Element { + const { data, isPending, isError, refetch } = useQuery( + landingTokenLaunchesQueryOptions(), + ) + + const rows = data?.latest ?? [] + const isEmpty = !isPending && !isError && rows.length === 0 + + return ( + , label: 'No launches in 30d' }} + onRetry={() => refetch()} + action={View} + contentClassName="gap-0" + > +
    + {rows.slice(0, LIST_LIMIT).map((t) => ( +
  • + + + + {t.symbol} + + {t.name} + + +
  • + ))} +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/NotableTxsTile.tsx b/apps/explorer/src/comps/bento/tiles/NotableTxsTile.tsx new file mode 100644 index 000000000..28851d0e0 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/NotableTxsTile.tsx @@ -0,0 +1,118 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { RelativeTime } from '#comps/RelativeTime' +import { cx } from '#lib/css' +import { HexFormatter } from '#lib/formatting' +import { landingNotableTxsQueryOptions } from '#lib/queries' +import type { TxRateWindow } from '#lib/server/landing-stats' +import ReceiptIcon from '~icons/lucide/receipt' + +const WINDOW_OPTIONS: ReadonlyArray<{ value: TxRateWindow; label: string }> = [ + { value: '1h', label: '1h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, +] + +// Single grid template shared by header + rows so columns line up exactly. +// Index | Description | Hash | Gwei | Block share | Age +const GRID = + 'grid-cols-[20px_1fr_minmax(72px,auto)_64px_56px_88px] sm:grid-cols-[24px_1fr_120px_72px_72px_104px]' + +export function NotableTxsTile(): React.JSX.Element { + const [window, setWindow] = React.useState('24h') + const { data, isPending, isError, refetch } = useQuery( + landingNotableTxsQueryOptions(window), + ) + + const rows = data?.rows ?? [] + const isEmpty = !isPending && !isError && rows.length === 0 + + return ( + , label: 'No transactions in window' }} + onRetry={() => refetch()} + action={ + + value={window} + options={WINDOW_OPTIONS} + onChange={setWindow} + ariaLabel="notable transactions window" + /> + } + contentClassName="gap-0" + > +
    +
  • + # + Description + Hash + Gwei + Block + Age +
  • + {rows.map((row, i) => ( +
  • + + + {i + 1} + + + {row.description} + + + {HexFormatter.shortenHex(row.hash, 4)} + + + {row.gwei} + + + + +
  • + ))} +
+
+ ) +} + +function BlockShareGauge(props: { value: number }): React.JSX.Element { + const pct = Math.max(0, Math.min(1, props.value)) * 100 + return ( +
+
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/PopularCallsTile.tsx b/apps/explorer/src/comps/bento/tiles/PopularCallsTile.tsx new file mode 100644 index 000000000..bbdead651 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/PopularCallsTile.tsx @@ -0,0 +1,94 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { TokenIcon } from '#comps/TokenIcon' +import { getAccountTag } from '#lib/account' +import { landingPopularCallsQueryOptions } from '#lib/queries' +import type { TxRateWindow } from '#lib/server/landing-stats' +import ZapIcon from '~icons/lucide/zap' + +const WINDOW_OPTIONS: ReadonlyArray<{ value: TxRateWindow; label: string }> = [ + { value: '1h', label: '1h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, +] + +const KNOWN_SELECTORS: Record = { + '0xa9059cbb': 'transfer', + '0x23b872dd': 'transferFrom', + '0x095ea7b3': 'approve', + '0x40c10f19': 'mint', + '0x42966c68': 'burn', + '0x79cc6790': 'burnFrom', + '0x9dc29fac': 'burn', + '0xd505accf': 'permit', + '0x8456cb59': 'pause', + '0x3f4ba83a': 'unpause', +} + +function selectorLabel(selector: string): string { + return KNOWN_SELECTORS[selector.toLowerCase()] ?? selector +} + +function addressLabel(address: string): string { + const tag = getAccountTag(address as `0x${string}`) + if (tag?.label) return tag.label + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +export function PopularCallsTile(): React.JSX.Element { + const [window, setWindow] = React.useState('24h') + const { data, isPending, isError, refetch } = useQuery( + landingPopularCallsQueryOptions(window), + ) + + const rows = data ?? [] + const isEmpty = !isPending && !isError && rows.length === 0 + + return ( + + value={window} + options={WINDOW_OPTIONS} + onChange={setWindow} + ariaLabel="popular calls window" + /> + } + span={{ base: 2, sm: 2, lg: 2 }} + rowSpan={{ base: 2, lg: 2 }} + status={ + isPending ? 'loading' : isError ? 'error' : isEmpty ? 'empty' : 'ready' + } + empty={{ icon: , label: 'No activity in window' }} + onRetry={() => refetch()} + contentClassName="gap-0" + > +
    + {rows.slice(0, 8).map((r, i) => ( +
  • + + + {addressLabel(r.to)} + + + {selectorLabel(r.selector)} + + + {r.count.toLocaleString()} + +
  • + ))} +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/TopTokensTile.tsx b/apps/explorer/src/comps/bento/tiles/TopTokensTile.tsx new file mode 100644 index 000000000..b3eea4d9e --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/TopTokensTile.tsx @@ -0,0 +1,84 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { TokenIcon } from '#comps/TokenIcon' +import { cx } from '#lib/css' +import { landingTopTokensQueryOptions } from '#lib/queries' +import CoinsIcon from '~icons/lucide/coins' + +const compactCount = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 0, +}) + +export function TopTokensTile(): React.JSX.Element { + const { data, isPending, isError, refetch } = useQuery( + landingTopTokensQueryOptions(), + ) + + const max = React.useMemo(() => { + if (!data || data.length === 0) return 1 + return Math.max(1, ...data.map((t) => t.count)) + }, [data]) + + const isEmpty = !isPending && !isError && (!data || data.length === 0) + + return ( + , label: 'No tokens indexed' }} + onRetry={() => refetch()} + action={View} + contentClassName="gap-0" + > +
    + {(data ?? []).map((t, i) => { + const pct = (t.count / max) * 100 + return ( +
  • + + + {i + 1} + + + + + {t.symbol || + `${t.address.slice(0, 6)}…${t.address.slice(-4)}`} + + +
    +
    +
    +
    +
    + + {t.capped + ? `>${compactCount.format(t.count)}` + : t.count.toLocaleString()} + + +
  • + ) + })} +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/TpsTile.tsx b/apps/explorer/src/comps/bento/tiles/TpsTile.tsx new file mode 100644 index 000000000..c0cbbda9f --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/TpsTile.tsx @@ -0,0 +1,62 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { LivePulseDot } from '#comps/bento/LivePulseDot' +import { landingTxRateQueryOptions } from '#lib/queries' +import type { TxRateWindow } from '#lib/server/landing-stats' +import ActivityIcon from '~icons/lucide/activity' + +const WINDOW_OPTIONS: ReadonlyArray<{ value: TxRateWindow; label: string }> = [ + { value: '1h', label: '1h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, +] + +function formatTps(rate: number): string { + if (rate >= 100) return rate.toFixed(0) + if (rate >= 10) return rate.toFixed(1) + return rate.toFixed(2) +} + +export function TpsTile(): React.JSX.Element { + const [window, setWindow] = React.useState('24h') + const { data, isPending, isError, refetch } = useQuery( + landingTxRateQueryOptions(window), + ) + + const rate = data ? data.count / data.windowSecs : 0 + const isEmpty = !isPending && !isError && data?.count === 0 + + return ( + + + TPS + + } + action={ + + value={window} + options={WINDOW_OPTIONS} + onChange={setWindow} + ariaLabel="TPS window" + /> + } + span={{ base: 1, sm: 1, lg: 1 }} + rowSpan={{ base: 1, lg: 1 }} + status={ + isPending ? 'loading' : isError ? 'error' : isEmpty ? 'empty' : 'ready' + } + empty={{ icon: , label: 'No activity' }} + onRetry={() => refetch()} + contentClassName="justify-end" + > + + + ) +} diff --git a/apps/explorer/src/comps/bento/tiles/TvlOverTimeTile.tsx b/apps/explorer/src/comps/bento/tiles/TvlOverTimeTile.tsx new file mode 100644 index 000000000..a689e4b33 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/TvlOverTimeTile.tsx @@ -0,0 +1,140 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { landingTvlSeriesQueryOptions } from '#lib/queries' +import BarChartIcon from '~icons/lucide/bar-chart-3' + +const PALETTE = [ + 'var(--color-accent)', + 'color-mix(in srgb, var(--color-accent) 70%, var(--color-positive) 30%)', + 'color-mix(in srgb, var(--color-accent) 40%, var(--color-positive) 60%)', + 'color-mix(in srgb, var(--color-positive) 70%, var(--color-accent) 30%)', + 'var(--color-positive)', + 'var(--color-content-dimmed)', +] + +const compactUsd = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 2, + style: 'currency', + currency: 'USD', +}) + +export function TvlOverTimeTile(): React.JSX.Element { + const { data, isPending, isError, refetch } = useQuery( + landingTvlSeriesQueryOptions(), + ) + + const { rows, total } = React.useMemo(() => { + if (!data) + return { + rows: [] as Array<{ + key: string + label: string + value: number + color: string + }>, + total: 0, + } + + const entries: Array<{ + key: string + label: string + value: number + color: string + }> = data.tokens.map((t, i) => ({ + key: t.address, + label: t.symbol || t.name, + value: t.usdValue, + color: PALETTE[i] ?? PALETTE[PALETTE.length - 2], + })) + if (data.other.usdValue > 0) { + entries.push({ + key: 'other', + label: `Other · ${data.other.count}`, + value: data.other.usdValue, + color: PALETTE[PALETTE.length - 1], + }) + } + return { rows: entries, total: data.totalUsd } + }, [data]) + + const isEmpty = !isPending && !isError && rows.length === 0 + + return ( + , label: 'No token supplies available' }} + onRetry={() => refetch()} + contentClassName="gap-1.5 justify-end" + > + + +
    + {rows.map((row) => { + const pct = total > 0 ? (row.value / total) * 100 : 0 + return ( +
  • + + + + {row.label} + + + + {pct.toFixed(1)}% + +
  • + ) + })} +
+
+ ) +} + +function StackBar(props: { + rows: Array<{ key: string; label: string; value: number; color: string }> + total: number +}): React.JSX.Element { + const { rows, total } = props + + const [hoverKey, setHoverKey] = React.useState(null) + + return ( +
+
+ {rows.map((row) => { + const pct = total > 0 ? (row.value / total) * 100 : 0 + return ( +
setHoverKey(row.key)} + onPointerLeave={() => setHoverKey(null)} + /> + ) + })} +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/UptimeTile.tsx b/apps/explorer/src/comps/bento/tiles/UptimeTile.tsx new file mode 100644 index 000000000..61b160467 --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/UptimeTile.tsx @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { StatusBar, type StatusSegment } from '#comps/bento/charts/StatusBar' +import { + landingChainVitalsQueryOptions, + landingRecentBlocksQueryOptions, +} from '#lib/queries' +import ActivityIcon from '~icons/lucide/activity' + +const SEGMENTS = 80 + +export function UptimeTile(): React.JSX.Element { + const recent = useQuery(landingRecentBlocksQueryOptions()) + const vitals = useQuery(landingChainVitalsQueryOptions()) + + const { segments, percent } = React.useMemo(() => { + const blocks = recent.data?.blocks ?? [] + if (blocks.length < 2) + return { segments: [] as StatusSegment[], percent: null as number | null } + + const first = blocks[0].timestamp + const last = blocks[blocks.length - 1].timestamp + const windowSecs = Math.max(1, last - first) + const sliceSecs = windowSecs / SEGMENTS + + const diffs: number[] = [] + for (let i = 1; i < blocks.length; i++) { + const dt = blocks[i].timestamp - blocks[i - 1].timestamp + if (dt > 0) diffs.push(dt) + } + const median = + diffs.length === 0 + ? 1 + : [...diffs].sort((a, b) => a - b)[Math.floor(diffs.length / 2)] + const expectedPerSlice = Math.max(1, Math.round(sliceSecs / median)) + + const counts = new Array(SEGMENTS).fill(0) + for (const b of blocks) { + const idx = Math.min( + SEGMENTS - 1, + Math.max(0, Math.floor((b.timestamp - first) / sliceSecs)), + ) + counts[idx] += 1 + } + + const result: StatusSegment[] = counts.map((c, i) => { + const sliceStart = new Date((first + i * sliceSecs) * 1000) + const status: StatusSegment['status'] = + c === 0 ? 'stalled' : c < expectedPerSlice / 2 ? 'slow' : 'healthy' + return { + status, + label: `${sliceStart.toLocaleTimeString()} · ${c} blocks`, + } + }) + + const healthyCount = result.filter((s) => s.status === 'healthy').length + const pct = (healthyCount / result.length) * 100 + return { segments: result, percent: pct } + }, [recent.data]) + + const genesis = vitals.data?.genesisTimestamp ?? null + const daysLive = React.useMemo(() => { + if (!genesis) return null + return Math.max(0, Math.floor((Date.now() / 1000 - genesis) / 86400)) + }, [genesis]) + + const isLoading = recent.isPending || vitals.isPending + const isEmpty = !isLoading && segments.length === 0 + + return ( + , label: 'Waiting for blocks' }} + contentClassName="justify-end gap-1.5" + > +
+ +
+
+ {segments.length ? ( + + ) : null} +
+
+ ) +} diff --git a/apps/explorer/src/comps/bento/tiles/ValidatorsTile.tsx b/apps/explorer/src/comps/bento/tiles/ValidatorsTile.tsx new file mode 100644 index 000000000..f3313dd2b --- /dev/null +++ b/apps/explorer/src/comps/bento/tiles/ValidatorsTile.tsx @@ -0,0 +1,152 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { BentoTile } from '#comps/bento/BentoTile' +import { cx } from '#lib/css' +import { + landingRecentBlocksQueryOptions, + validatorsQueryOptions, +} from '#lib/queries' +import ShieldIcon from '~icons/lucide/shield' + +const LIST_LIMIT = 6 + +export function ValidatorsTile(): React.JSX.Element { + const validators = useQuery(validatorsQueryOptions()) + const recent = useQuery(landingRecentBlocksQueryOptions()) + + const { rows, active, total, windowBlocks } = React.useMemo(() => { + const directory = validators.data ?? [] + const blocks = recent.data?.blocks ?? [] + + // Map validator directory by lowercased address for fast lookup. + const dirByAddress = new Map() + for (const v of directory) { + dirByAddress.set(v.validatorAddress.toLowerCase(), v) + } + + // Tally miner occurrences from the recent window; these are the + // actual block producers observed on-chain. + const minerCounts = new Map() + for (const b of blocks) { + const key = b.miner.toLowerCase() + minerCounts.set(key, (minerCounts.get(key) ?? 0) + 1) + } + + // Union: start with miners we observed, plus any active validator in + // the directory that hasn't produced yet (so the list isn't empty on + // a fresh window). + const minerSet = new Set(minerCounts.keys()) + for (const v of directory) { + if (v.active) minerSet.add(v.validatorAddress.toLowerCase()) + } + + const combined = Array.from(minerSet).map((addr) => { + const dir = dirByAddress.get(addr) + return { + address: addr as `0x${string}`, + name: dir?.name, + active: dir?.active ?? minerCounts.has(addr), + count: minerCounts.get(addr) ?? 0, + } + }) + + combined.sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (!!b.active !== !!a.active) return Number(b.active) - Number(a.active) + return (a.name ?? a.address).localeCompare(b.name ?? b.address) + }) + + const maxCount = Math.max(1, ...combined.map((v) => v.count)) + const visible = combined.slice(0, LIST_LIMIT).map((v) => ({ + ...v, + share: v.count / maxCount, + })) + + const activeCount = directory.filter((v) => v.active).length + return { + rows: visible, + active: activeCount, + total: directory.length, + windowBlocks: blocks.length, + } + }, [validators.data, recent.data]) + + const isLoading = validators.isPending + const isError = validators.isError + const isEmpty = !isLoading && !isError && rows.length === 0 + + return ( + , label: 'No validators registered' }} + onRetry={() => { + validators.refetch() + recent.refetch() + }} + action={ + View + } + contentClassName="gap-1 justify-between" + > +
+
+ + Validator + Recent share + Blocks +
+
    + {rows.map((v) => { + const label = + v.name ?? `${v.address.slice(0, 6)}…${v.address.slice(-4)}` + return ( +
  • + + + {label} + +
    +
    +
    + + {v.count} + +
  • + ) + })} +
+
+ {total > 0 ? ( +
+ + + {active} + {' '} + active · {total} registered + + Last {windowBlocks} blocks +
+ ) : null} +
+ ) +} diff --git a/apps/explorer/src/lib/block-number.tsx b/apps/explorer/src/lib/block-number.tsx index 5f17445ed..2bdb5a8ce 100644 --- a/apps/explorer/src/lib/block-number.tsx +++ b/apps/explorer/src/lib/block-number.tsx @@ -4,7 +4,7 @@ import { getWagmiConfig } from '#wagmi.config' type Listener = () => void -const BLOCK_NUMBER_POLL_INTERVAL_MS = 2_000 +const BLOCK_NUMBER_POLL_INTERVAL_MS = 1_000 const BLOCK_NUMBER_ANIMATION_INTERVAL_MS = 500 const BLOCK_NUMBER_SKEW_SNAP_THRESHOLD = 12n diff --git a/apps/explorer/src/lib/queries/index.ts b/apps/explorer/src/lib/queries/index.ts index 73a332188..d58eb4723 100644 --- a/apps/explorer/src/lib/queries/index.ts +++ b/apps/explorer/src/lib/queries/index.ts @@ -3,6 +3,8 @@ export * from './account' export * from './balance-changes' export * from './blocks' export * from './fee-amm' +export * from './landing' export * from './tokens' export * from './trace' export * from './tx' +export * from './validators' diff --git a/apps/explorer/src/lib/queries/landing.ts b/apps/explorer/src/lib/queries/landing.ts new file mode 100644 index 000000000..b63d8d690 --- /dev/null +++ b/apps/explorer/src/lib/queries/landing.ts @@ -0,0 +1,115 @@ +import { queryOptions } from '@tanstack/react-query' +import { + fetchLandingChainVitals, + fetchLandingHeatmap, + fetchLandingHeatmapGas, + fetchLandingNotableTxs, + fetchLandingPopularCalls, + fetchLandingRecentBlocks, + fetchLandingTokenLaunches, + fetchLandingTopTokens, + fetchLandingTvlSeries, + fetchLandingTxRate, + type HeatmapWindow, + type TxRateWindow, +} from '#lib/server/landing-stats' + +/** Default: fail fast (one retry) so tiles don't sit in loading for 15s. */ +const LANDING_RETRY = 1 + +export function landingRecentBlocksQueryOptions() { + return queryOptions({ + queryKey: ['landing', 'recent-blocks'], + queryFn: () => fetchLandingRecentBlocks(), + staleTime: 5_000, + refetchInterval: 5_000, + retry: LANDING_RETRY, + }) +} + +export function landingHeatmapQueryOptions(window: HeatmapWindow = '7d') { + return queryOptions({ + queryKey: ['landing', 'heatmap', 'txs', window], + queryFn: () => fetchLandingHeatmap({ data: { window } }), + staleTime: 60_000, + refetchInterval: 60_000, + retry: LANDING_RETRY, + }) +} + +export function landingHeatmapGasQueryOptions(window: HeatmapWindow = '7d') { + return queryOptions({ + queryKey: ['landing', 'heatmap', 'gas', window], + queryFn: () => fetchLandingHeatmapGas({ data: { window } }), + staleTime: 60_000, + refetchInterval: 60_000, + retry: LANDING_RETRY, + }) +} + +export function landingChainVitalsQueryOptions() { + return queryOptions({ + queryKey: ['landing', 'chain-vitals'], + queryFn: () => fetchLandingChainVitals(), + staleTime: 30_000, + refetchInterval: 30_000, + retry: LANDING_RETRY, + }) +} + +export function landingTokenLaunchesQueryOptions() { + return queryOptions({ + queryKey: ['landing', 'token-launches'], + queryFn: () => fetchLandingTokenLaunches(), + staleTime: 60_000, + retry: LANDING_RETRY, + }) +} + +export function landingTopTokensQueryOptions() { + return queryOptions({ + queryKey: ['landing', 'top-tokens'], + queryFn: () => fetchLandingTopTokens(), + staleTime: 5 * 60_000, + retry: LANDING_RETRY, + }) +} + +export function landingNotableTxsQueryOptions(window: TxRateWindow = '24h') { + return queryOptions({ + queryKey: ['landing', 'notable-txs', window], + queryFn: () => fetchLandingNotableTxs({ data: { window } }), + staleTime: 30_000, + refetchInterval: 30_000, + retry: LANDING_RETRY, + }) +} + +export function landingPopularCallsQueryOptions(window: TxRateWindow = '24h') { + return queryOptions({ + queryKey: ['landing', 'popular-calls', window], + queryFn: () => fetchLandingPopularCalls({ data: { window } }), + staleTime: 60_000, + refetchInterval: 60_000, + retry: LANDING_RETRY, + }) +} + +export function landingTvlSeriesQueryOptions() { + return queryOptions({ + queryKey: ['landing', 'tvl-series'], + queryFn: () => fetchLandingTvlSeries(), + staleTime: 5 * 60_000, + retry: LANDING_RETRY, + }) +} + +export function landingTxRateQueryOptions(window: TxRateWindow) { + return queryOptions({ + queryKey: ['landing', 'tx-rate', window], + queryFn: () => fetchLandingTxRate({ data: { window } }), + staleTime: window === '1h' ? 30_000 : 60_000, + refetchInterval: window === '1h' ? 30_000 : 60_000, + retry: LANDING_RETRY, + }) +} diff --git a/apps/explorer/src/lib/queries/validators.ts b/apps/explorer/src/lib/queries/validators.ts new file mode 100644 index 000000000..9632a603b --- /dev/null +++ b/apps/explorer/src/lib/queries/validators.ts @@ -0,0 +1,39 @@ +import { queryOptions } from '@tanstack/react-query' +import { isTestnet } from '#lib/env' + +const VALIDATOR_DIRECTORY_URL = + 'https://tempo-validator-directory.porto.workers.dev' + +export type Validator = { + validatorAddress: `0x${string}` + name?: string + publicKey?: `0x${string}` + active?: boolean +} + +type ValidatorDirectoryResponse = { + network: string + validators: Validator[] + updatedAt: string | null +} + +const getValidatorNetwork = () => (isTestnet() ? 'testnet' : 'mainnet') + +export function validatorsQueryOptions() { + const network = getValidatorNetwork() + return queryOptions({ + queryKey: ['validators', network], + queryFn: async () => { + const url = `${VALIDATOR_DIRECTORY_URL}/validators?network=${network}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch validators: ${response.status}`) + } + + const data = (await response.json()) as ValidatorDirectoryResponse + return data.validators + }, + staleTime: 60_000, + }) +} diff --git a/apps/explorer/src/lib/server/landing-stats.ts b/apps/explorer/src/lib/server/landing-stats.ts new file mode 100644 index 000000000..439f95019 --- /dev/null +++ b/apps/explorer/src/lib/server/landing-stats.ts @@ -0,0 +1,905 @@ +import { createServerFn } from '@tanstack/react-start' +import type { Address, Hex } from 'ox' +import { sql } from 'tidx.ts' +import { getChainId, readContracts } from 'wagmi/actions' +import * as ABIS from '#lib/abis' +import { TOKEN_COUNT_MAX } from '#lib/constants' +import { + fetchGenesisBlockTimestamp, + fetchLatestBlockNumber, + fetchTokenHoldersCount, +} from '#lib/server/tempo-queries' +import { + tempoFastLookupQueryBuilder, + tempoQueryBuilder, +} from '#lib/server/tempo-queries-provider' +import { getTokenListEntries } from '#lib/server/tokens' +import { parseTimestamp } from '#lib/timestamp' +import { getWagmiConfig } from '#wagmi.config' + +const QB = tempoQueryBuilder +const FAST = tempoFastLookupQueryBuilder + +/** Default window for recent block tiles (covers ~5 min at 1s blocks). */ +const RECENT_BLOCKS_LIMIT = 300 +const TOKEN_LAUNCH_WINDOW_DAYS = 30 + +export type HeatmapWindow = '7d' | '30d' | '90d' + +const HEATMAP_WINDOW_HOURS: Record = { + '7d': 7 * 24, + '30d': 30 * 24, + '90d': 90 * 24, +} +const NOTABLE_TX_LIMIT = 8 +const TOP_TOKENS_LIMIT = 6 +/** Consider at most this many tokens from the tokenlist for ranking (keeps cost bounded). */ +const TOP_TOKENS_CANDIDATE_CAP = 10 + +// ---------- Recent blocks (Network Pulse / Block Time / Gas Usage) --------- // + +export type RecentBlockRow = { + num: number + timestamp: number + gas_used: number + gas_limit: number + miner: Address.Address +} + +export type LandingRecentBlocks = { + blocks: RecentBlockRow[] + latestBlockNumber: number +} + +export const fetchLandingRecentBlocks = createServerFn({ + method: 'GET', +}).handler(async (): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const rows = await QB(chainId) + .selectFrom('blocks') + .select(['num', 'timestamp', 'gas_used', 'gas_limit', 'miner']) + .orderBy('num', 'desc') + .limit(RECENT_BLOCKS_LIMIT) + .execute() + + const blocks: RecentBlockRow[] = rows + .map((row) => ({ + num: Number(row.num), + timestamp: parseTimestamp(row.timestamp) ?? 0, + gas_used: Number(row.gas_used ?? 0), + gas_limit: Number(row.gas_limit ?? 0), + miner: row.miner as Address.Address, + })) + // present oldest-first so charts scan left to right + .sort((a, b) => a.num - b.num) + + const latestBlockNumber = blocks.length ? blocks[blocks.length - 1].num : 0 + + return { blocks, latestBlockNumber } +}) + +// ---------- Activity heatmap (tx count per hour for last 7 days) --------- // + +export type HeatmapBucket = { + /** Hour bucket (unix seconds at the top of the hour). */ + hour: number + count: number +} + +export type LandingHeatmap = { + buckets: HeatmapBucket[] + windowStart: number + windowEnd: number +} + +function parseHeatmapWindow(input: unknown): { window: HeatmapWindow } { + const value = (input as { window?: string } | undefined)?.window + if (value === '7d' || value === '30d' || value === '90d') { + return { window: value } + } + return { window: '7d' } +} + +export const fetchLandingHeatmap = createServerFn({ method: 'GET' }) + .inputValidator(parseHeatmapWindow) + .handler(async ({ data }): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const nowSec = Math.floor(Date.now() / 1000) + const windowStart = nowSec - HEATMAP_WINDOW_HOURS[data.window] * 3600 + const windowEnd = nowSec + + try { + // ClickHouse stores block_timestamp as DateTime64, so we can't divide + // it by 3600; use toStartOfHour + toUnixTimestamp for bucketing. + const rows = (await FAST(chainId) + .selectFrom('txs') + .select((eb) => [ + sql`toUnixTimestamp(toStartOfHour(${eb.ref('block_timestamp')}))`.as( + 'hour', + ), + eb.fn.count('hash').as('count'), + ]) + .where( + 'block_timestamp', + '>=', + sql`toDateTime(${windowStart})` as never, + ) + .groupBy(sql`toStartOfHour(block_timestamp)`) + .orderBy(sql`toStartOfHour(block_timestamp)`, 'asc') + .execute()) as Array<{ hour: number | string; count: number | string }> + + const buckets: HeatmapBucket[] = rows.map((row) => ({ + hour: Number(row.hour), + count: Number(row.count), + })) + + return { buckets, windowStart, windowEnd } + } catch (error) { + console.error('[landing] heatmap query failed:', error) + return { buckets: [], windowStart, windowEnd } + } + }) + +// ---------- Activity heatmap — gas summed per hour ----------------------- // + +export const fetchLandingHeatmapGas = createServerFn({ method: 'GET' }) + .inputValidator(parseHeatmapWindow) + .handler(async ({ data }): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const nowSec = Math.floor(Date.now() / 1000) + const windowStart = nowSec - HEATMAP_WINDOW_HOURS[data.window] * 3600 + const windowEnd = nowSec + + try { + const rows = (await FAST(chainId) + .selectFrom('blocks') + .select((eb) => [ + sql`toUnixTimestamp(toStartOfHour(${eb.ref('timestamp')}))`.as( + 'hour', + ), + sql`sum(${eb.ref('gas_used')})`.as('count'), + ]) + .where('timestamp', '>=', sql`toDateTime(${windowStart})` as never) + .groupBy(sql`toStartOfHour(timestamp)`) + .orderBy(sql`toStartOfHour(timestamp)`, 'asc') + .execute()) as Array<{ hour: number | string; count: number | string }> + + const buckets: HeatmapBucket[] = rows.map((row) => ({ + hour: Number(row.hour), + count: Number(row.count), + })) + + return { buckets, windowStart, windowEnd } + } catch (error) { + console.error('[landing] heatmap gas query failed:', error) + return { buckets: [], windowStart, windowEnd } + } + }) + +// ---------- Tx rate over arbitrary window -------------------------------- // + +export type TxRateWindow = '1h' | '24h' | '7d' + +export type LandingTxRate = { + count: number + capped: boolean + windowSecs: number +} + +const WINDOW_SECONDS: Record = { + '1h': 60 * 60, + '24h': 24 * 60 * 60, + '7d': 7 * 24 * 60 * 60, +} + +export const fetchLandingTxRate = createServerFn({ method: 'GET' }) + .inputValidator((input: unknown): { window: TxRateWindow } => { + const value = (input as { window?: string } | undefined)?.window + if (value === '1h' || value === '24h' || value === '7d') { + return { window: value } + } + return { window: '24h' } + }) + .handler(async ({ data }): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const windowSecs = WINDOW_SECONDS[data.window] + const start = Math.floor(Date.now() / 1000) - windowSecs + + try { + const result = await FAST(chainId) + .selectFrom( + FAST(chainId) + .selectFrom('txs') + .select((eb) => eb.lit(1).as('x')) + .where('block_timestamp', '>=', sql`toDateTime(${start})` as never) + .limit(TOKEN_COUNT_MAX) + .as('sub'), + ) + .select((eb) => eb.fn.count('x').as('count')) + .executeTakeFirst() + + const count = Number(result?.count ?? 0) + return { + count, + capped: count >= TOKEN_COUNT_MAX, + windowSecs, + } + } catch (error) { + console.error('[landing] tx rate query failed:', error) + return { count: 0, capped: false, windowSecs } + } + }) + +// ---------- Chain vitals (24h tx count, genesis, latest) ----------------- // + +export type LandingChainVitals = { + latestBlockNumber: number + genesisTimestamp: number | null + txCount24h: number + txCount24hCapped: boolean + /** Chain id for display. */ + chainId: number +} + +export const fetchLandingChainVitals = createServerFn({ + method: 'GET', +}).handler(async (): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const nowSec = Math.floor(Date.now() / 1000) + const dayAgo = nowSec - 24 * 60 * 60 + + const [latestBlockNumber, genesisTs, txCountResult] = await Promise.all([ + fetchLatestBlockNumber(chainId).catch(() => 0n), + fetchGenesisBlockTimestamp(chainId).catch(() => null), + FAST(chainId) + .selectFrom( + FAST(chainId) + .selectFrom('txs') + .select((eb) => eb.lit(1).as('x')) + .where('block_timestamp', '>=', sql`toDateTime(${dayAgo})` as never) + .limit(TOKEN_COUNT_MAX) + .as('sub'), + ) + .select((eb) => eb.fn.count('x').as('count')) + .executeTakeFirst() + .catch((error) => { + console.error('[landing] 24h tx count failed:', error) + return undefined + }), + ]) + + const genesisTimestamp = parseTimestamp(genesisTs) ?? null + const txCount24h = Number(txCountResult?.count ?? 0) + const txCount24hCapped = txCount24h >= TOKEN_COUNT_MAX + + return { + latestBlockNumber: Number(latestBlockNumber), + genesisTimestamp, + txCount24h, + txCount24hCapped, + chainId, + } +}) + +// ---------- Token launches (last 30 days) -------------------------------- // + +export type LandingTokenLaunch = { + address: Address.Address + name: string + symbol: string + currency: string + timestamp: number +} + +export type LandingTokenLaunches = { + dailyCounts: Array<{ day: number; count: number }> + latest: LandingTokenLaunch[] + windowStart: number + windowEnd: number +} + +export const fetchLandingTokenLaunches = createServerFn({ + method: 'GET', +}).handler(async (): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const eventSignature = ABIS.getTokenCreatedEvent(chainId) + + const nowSec = Math.floor(Date.now() / 1000) + const windowStart = nowSec - TOKEN_LAUNCH_WINDOW_DAYS * 24 * 3600 + const windowEnd = nowSec + + try { + // Use the decoded-event virtual table — the topic0-only raw logs + // query doesn't scale on tidx, but `tokencreated` is indexed by + // block_num and very fast. + const rows = await QB(chainId) + .withSignatures([eventSignature]) + .selectFrom('tokencreated') + .select(['token', 'name', 'symbol', 'currency', 'block_timestamp']) + .orderBy('block_num', 'desc') + .orderBy('log_idx', 'desc') + .limit(500) + .execute() + + const latest: LandingTokenLaunch[] = [] + const dayBuckets = new Map() + + for (const row of rows) { + const ts = parseTimestamp(row.block_timestamp) + if (ts == null || ts <= 0) continue + if (ts < windowStart) continue + const day = Math.floor(ts / 86400) + dayBuckets.set(day, (dayBuckets.get(day) ?? 0) + 1) + + if (latest.length < 10 && row.token) { + latest.push({ + address: row.token as Address.Address, + name: String(row.name ?? ''), + symbol: String(row.symbol ?? ''), + currency: String(row.currency ?? ''), + timestamp: ts, + }) + } + } + + const dailyCounts = Array.from(dayBuckets.entries()) + .map(([day, count]) => ({ day, count })) + .sort((a, b) => a.day - b.day) + + return { dailyCounts, latest, windowStart, windowEnd } + } catch (error) { + console.error('[landing] token launches query failed:', error) + return { dailyCounts: [], latest: [], windowStart, windowEnd } + } +}) + +// ---------- Top tokens by holders ---------------------------------------- // + +export type LandingTopToken = { + address: Address.Address + symbol: string + name: string + count: number + capped: boolean +} + +export const fetchLandingTopTokens = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const entries = await getTokenListEntries(chainId) + if (entries.length === 0) return [] + + const candidates = entries.slice(0, TOP_TOKENS_CANDIDATE_CAP) + + // Per-token queries (in parallel). The batched `fetchTokenHoldersCountRows` + // path tends to 422 on tidx when several tokens with large transfer + // histories are combined into a single GROUP BY. + const results = await Promise.allSettled( + candidates.map(async (entry) => { + const address = entry.address as Address.Address + const { count, capped } = await fetchTokenHoldersCount( + address, + chainId, + TOKEN_COUNT_MAX, + ) + return { + address, + symbol: entry.symbol, + name: entry.name, + count, + capped, + } + }), + ) + + const tokens: LandingTopToken[] = [] + for (const r of results) { + if (r.status === 'fulfilled') tokens.push(r.value) + else console.error('[landing] top tokens per-token failed:', r.reason) + } + + return tokens.sort((a, b) => b.count - a.count).slice(0, TOP_TOKENS_LIMIT) + }, +) + +// ---------- Popular contract invocations (last 24h) ---------------------- // + +export type LandingPopularCall = { + to: Address.Address + selector: Hex.Hex + count: number +} + +export const fetchLandingPopularCalls = createServerFn({ method: 'GET' }) + .inputValidator((input: unknown): { window: TxRateWindow } => { + const value = (input as { window?: string } | undefined)?.window + if (value === '1h' || value === '24h' || value === '7d') { + return { window: value } + } + return { window: '24h' } + }) + .handler(async ({ data }): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const nowSec = Math.floor(Date.now() / 1000) + const windowStart = nowSec - WINDOW_SECONDS[data.window] + + try { + const rows = (await FAST(chainId) + .selectFrom('txs') + .select((eb) => [ + eb.ref('to').as('to'), + sql`substring(${eb.ref('input')}, 1, 10)`.as('selector'), + eb.fn.count('hash').as('count'), + ]) + .where( + 'block_timestamp', + '>=', + sql`toDateTime(${windowStart})` as never, + ) + .where('input', '!=', '0x' as never) + .groupBy(['to', sql`substring(input, 1, 10)`] as never) + .orderBy('count', 'desc') + .limit(10) + .execute()) as Array<{ + to: string + selector: string + count: number | string + }> + + return rows + .filter((r) => r.to && r.selector && r.selector.length === 10) + .map((r) => ({ + to: r.to as Address.Address, + selector: r.selector as Hex.Hex, + count: Number(r.count), + })) + } catch (error) { + console.error('[landing] popular calls query failed:', error) + return [] + } + }) + +// ---------- TVL snapshot (current top-token total supplies) -------------- // + +export type LandingTvlToken = { + address: Address.Address + symbol: string + name: string + totalSupply: string + decimals: number + usdValue: number +} + +export type LandingTvlSnapshot = { + tokens: LandingTvlToken[] + other: { + totalSupply: string + usdValue: number + count: number + } + totalUsd: number +} + +const ERC20_ABI = [ + { + type: 'function', + name: 'totalSupply', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'decimals', + inputs: [], + outputs: [{ type: 'uint8' }], + stateMutability: 'view', + }, +] as const + +export const fetchLandingTvlSeries = createServerFn({ + method: 'GET', +}).handler(async (): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const entries = await getTokenListEntries(chainId) + if (entries.length === 0) { + return { + tokens: [], + other: { totalSupply: '0', usdValue: 0, count: 0 }, + totalUsd: 0, + } + } + + try { + const contracts = entries.flatMap((entry) => [ + { + address: entry.address as Address.Address, + abi: ERC20_ABI, + functionName: 'totalSupply' as const, + }, + { + address: entry.address as Address.Address, + abi: ERC20_ABI, + functionName: 'decimals' as const, + }, + ]) + + const results = await readContracts(config, { + contracts, + allowFailure: true, + }) + + type Enriched = { + address: Address.Address + symbol: string + name: string + totalSupply: bigint + decimals: number + usdValue: number + } + + const enriched: Enriched[] = entries + .map((entry, i) => { + const supplyResult = results[i * 2]?.result as bigint | undefined + const decimalsResult = results[i * 2 + 1]?.result as + | number + | bigint + | undefined + if (supplyResult == null) return null + const decimals = Number(decimalsResult ?? 18) + // Normalize to USD: stablecoin tokens on Tempo are 1:1. Divide + // supply by 10^decimals using bigint-safe arithmetic. + const scale = BigInt(Math.max(1, decimals)) + const divisor = 10n ** scale + // Keep 2 decimals of precision via *100 / divisor. + const scaled = + Number((supplyResult * 100n) / (divisor > 0n ? divisor : 1n)) / 100 + return { + address: entry.address as Address.Address, + symbol: entry.symbol, + name: entry.name, + totalSupply: supplyResult, + decimals, + usdValue: Number.isFinite(scaled) ? scaled : 0, + } + }) + .filter((t): t is Enriched => t != null) + + enriched.sort((a, b) => b.usdValue - a.usdValue) + + const top = enriched.slice(0, 5) + const rest = enriched.slice(5) + const otherUsd = rest.reduce((acc, t) => acc + t.usdValue, 0) + const otherSupply = rest.reduce((acc, t) => acc + t.totalSupply, 0n) + + return { + tokens: top.map((t) => ({ + address: t.address, + symbol: t.symbol, + name: t.name, + totalSupply: t.totalSupply.toString(), + decimals: t.decimals, + usdValue: t.usdValue, + })), + other: { + totalSupply: otherSupply.toString(), + usdValue: otherUsd, + count: rest.length, + }, + totalUsd: enriched.reduce((acc, t) => acc + t.usdValue, 0), + } + } catch (error) { + console.error('[landing] tvl snapshot query failed:', error) + return { + tokens: [], + other: { totalSupply: '0', usdValue: 0, count: 0 }, + totalUsd: 0, + } + } +}) + +// ---------- Notable transactions (last 24h) ------------------------------ // + +export type LandingNotableTx = { + hash: Hex.Hex + from: Address.Address + to: Address.Address | null + gas_used: string + gas_price: string + /** Effective gas price in gwei (display-ready string with up to 2 decimals). */ + gwei: string + /** `gas_used` as a fraction of the block gas limit (0..1). */ + blockShare: number + block_timestamp: number + /** First 4-byte selector of the call (`0x...`) or empty for plain transfers. */ + selector: Hex.Hex | null + /** Human-readable description, ideally derived from receipt logs. */ + description: string +} + +export type LandingNotableTxs = { + rows: LandingNotableTx[] +} + +const KNOWN_SELECTORS: Record = { + '0xa9059cbb': 'transfer', + '0x23b872dd': 'transferFrom', + '0x095ea7b3': 'approve', + '0x40c10f19': 'mint', + '0x42966c68': 'burn', + '0x79cc6790': 'burnFrom', + '0x9dc29fac': 'burn', + '0xd505accf': 'permit', +} + +function describeCall( + to: string | null, + input: string | null | undefined, +): { selector: Hex.Hex | null; description: string } { + if (!to) return { selector: null, description: 'Contract creation' } + if (!input || input === '0x' || input.length < 10) { + return { selector: null, description: 'Native call' } + } + const selector = input.slice(0, 10).toLowerCase() as Hex.Hex + const fnName = KNOWN_SELECTORS[selector] + if (fnName) { + return { + selector, + description: `${fnName} → ${to.slice(0, 6)}…${to.slice(-4)}`, + } + } + return { + selector, + description: `${selector} → ${to.slice(0, 6)}…${to.slice(-4)}`, + } +} + +const compactAmount = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 2, +}) + +function formatTokenAmount(tokens: bigint, decimals = 18): string { + if (tokens === 0n) return '0' + const scale = 10n ** BigInt(Math.max(0, decimals - 4)) + if (scale === 0n) return tokens.toString() + const reduced = tokens / scale + const value = Number(reduced) / 10 ** 4 + if (!Number.isFinite(value)) return tokens.toString() + return compactAmount.format(value) +} + +function gweiFromBigint(wei: bigint): string { + if (wei === 0n) return '0' + const integer = wei / 10n ** 9n + const remainder = wei % 10n ** 9n + if (integer >= 1000n) return integer.toLocaleString() + const fractional = Number(remainder) / 1e9 + return (Number(integer) + fractional).toFixed(2) +} + +export const fetchLandingNotableTxs = createServerFn({ method: 'GET' }) + .inputValidator((input: unknown): { window: TxRateWindow } => { + const value = (input as { window?: string } | undefined)?.window + if (value === '1h' || value === '24h' || value === '7d') { + return { window: value } + } + return { window: '24h' } + }) + .handler(async ({ data }): Promise => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + const nowSec = Math.floor(Date.now() / 1000) + const windowStart = nowSec - WINDOW_SECONDS[data.window] + const chWindow = sql`toDateTime(${windowStart})` as never + + try { + const receipts = (await FAST(chainId) + .selectFrom('receipts') + .select([ + 'tx_hash', + 'from', + 'to', + 'gas_used', + 'effective_gas_price', + 'block_timestamp', + ]) + .where('block_timestamp', '>=', chWindow) + .orderBy('gas_used', 'desc') + .limit(NOTABLE_TX_LIMIT) + .execute()) as unknown as Array<{ + tx_hash: string + from: string + to: string | null + gas_used: string | number | bigint + effective_gas_price: string | number | bigint | null + block_timestamp: string | number | null + }> + + if (receipts.length === 0) return { rows: [] } + + const hashes = receipts.map((r) => r.tx_hash as Hex.Hex) + + // In parallel: lookup tx input/to (PG, cheap by hash) and the + // largest decoded Transfer event per tx (for richer descriptions). + const [tokenListEntries, latestBlockGasLimit] = await Promise.all([ + getTokenListEntries(chainId).catch(() => []), + QB(chainId) + .selectFrom('blocks') + .select(['gas_limit']) + .orderBy('num', 'desc') + .limit(1) + .executeTakeFirst() + .then( + (row) => + Number( + (row as { gas_limit?: number | string } | undefined) + ?.gas_limit ?? 0, + ) || 0, + ) + .catch(() => 0), + ]) + + const symbolByAddress = new Map() + for (const entry of tokenListEntries) { + symbolByAddress.set(entry.address.toLowerCase(), entry.symbol) + } + + const txInfoByHash = new Map< + string, + { input: string | null; to: string | null } + >() + const transferByHash = new Map< + string, + { token: string; from: string; to: string; tokens: bigint } + >() + + await Promise.all([ + // per-hash tx lookups + ...hashes.map(async (hash) => { + try { + const row = (await QB(chainId) + .selectFrom('txs') + .select(['input', 'to']) + .where('hash', '=', hash) + .limit(1) + .executeTakeFirst()) as + | { input: string | null; to: string | null } + | undefined + if (row) { + txInfoByHash.set(hash.toLowerCase(), { + input: row.input, + to: row.to, + }) + } + } catch (error) { + console.error('[landing] notable-tx hash lookup failed:', error) + } + }), + // transfer events for these hashes — pick max(tokens) per tx + (async () => { + try { + const transfers = (await QB(chainId) + .withSignatures([ + 'event Transfer(address indexed from, address indexed to, uint256 tokens)', + ]) + .selectFrom('transfer') + .select(['tx_hash', 'address', 'from', 'to', 'tokens']) + .where('tx_hash', 'in', hashes) + .execute()) as unknown as Array<{ + tx_hash: string + address: string + from: string + to: string + tokens: string | number | bigint + }> + for (const t of transfers) { + const key = t.tx_hash.toLowerCase() + const tokens = (() => { + try { + return BigInt(t.tokens as never) + } catch { + return 0n + } + })() + const existing = transferByHash.get(key) + if (!existing || tokens > existing.tokens) { + transferByHash.set(key, { + token: String(t.address), + from: String(t.from), + to: String(t.to), + tokens, + }) + } + } + } catch (error) { + console.error('[landing] notable-tx transfer lookup failed:', error) + } + })(), + ]) + + const rows: LandingNotableTx[] = receipts.map((r) => { + const key = r.tx_hash.toLowerCase() + const tx = txInfoByHash.get(key) + const transfer = transferByHash.get(key) + + let description: string + let selector: Hex.Hex | null = null + if (transfer) { + const symbol = + symbolByAddress.get(transfer.token.toLowerCase()) ?? + `${transfer.token.slice(0, 6)}…${transfer.token.slice(-4)}` + const amount = formatTokenAmount(transfer.tokens) + if (transfer.from === '0x0000000000000000000000000000000000000000') { + description = `Mint ${amount} ${symbol}` + } else if ( + transfer.to === '0x0000000000000000000000000000000000000000' + ) { + description = `Burn ${amount} ${symbol}` + } else { + description = `Transfer ${amount} ${symbol}` + } + } else { + const fallback = describeCall(tx?.to ?? r.to, tx?.input) + description = fallback.description + selector = fallback.selector + } + + const gasUsed = (() => { + try { + return BigInt(String(r.gas_used ?? 0)) + } catch { + return 0n + } + })() + const gasPrice = (() => { + try { + return BigInt(String(r.effective_gas_price ?? 0)) + } catch { + return 0n + } + })() + const blockShare = + latestBlockGasLimit > 0 + ? Math.min(1, Number(gasUsed) / latestBlockGasLimit) + : 0 + + return { + hash: r.tx_hash as Hex.Hex, + from: r.from as Address.Address, + to: (tx?.to ?? r.to) as Address.Address | null, + gas_used: gasUsed.toString(), + gas_price: gasPrice.toString(), + gwei: gweiFromBigint(gasPrice), + blockShare, + block_timestamp: parseTimestamp(r.block_timestamp) ?? 0, + selector, + description, + } + }) + + return { rows } + } catch (error) { + console.error('[landing] notable tx query failed:', error) + return { rows: [] } + } + }) diff --git a/apps/explorer/src/lib/server/tempo-queries.ts b/apps/explorer/src/lib/server/tempo-queries.ts index 3c337a526..adf4a9fd0 100644 --- a/apps/explorer/src/lib/server/tempo-queries.ts +++ b/apps/explorer/src/lib/server/tempo-queries.ts @@ -4,7 +4,10 @@ import * as OxHex from 'ox/Hex' import { Tidx } from 'tidx.ts' import { decodeAbiParameters, zeroAddress } from 'viem' import * as ABIS from '#lib/abis' -import { tempoQueryBuilder } from '#lib/server/tempo-queries-provider' +import { + tempoFastLookupQueryBuilder, + tempoQueryBuilder, +} from '#lib/server/tempo-queries-provider' import { parseTimestamp } from '#lib/timestamp' const QB = tempoQueryBuilder @@ -80,6 +83,35 @@ export async function fetchTokenHolderBalances( return aggregateTokenHolderBalances(transfers) } +/** + * Cheap CH approximation of distinct holders: count of distinct receivers + * across the entire transfer history. Overestimates real holders (an address + * that received then sent everything still counts) but completes in seconds + * for tokens where the precise GROUP BY (from, to) is too expensive. + */ +export async function fetchTokenDistinctReceivers( + address: Address.Address, + chainId: number, +): Promise { + try { + const result = await tempoFastLookupQueryBuilder(chainId) + .withSignatures([TRANSFER_SIGNATURE]) + .selectFrom('transfer') + .select((eb) => eb.fn.count(eb.ref('to')).distinct().as('count')) + .where('address', '=', address) + .executeTakeFirst() + return Number( + (result as { count?: number | string } | undefined)?.count ?? 0, + ) + } catch (error) { + console.error( + `[tidx] distinct receivers query failed for ${address}:`, + error, + ) + return 0 + } +} + export async function fetchTokenHoldersCountRows( addresses: Address.Address[], chainId: number, diff --git a/apps/explorer/src/lib/server/tokens.ts b/apps/explorer/src/lib/server/tokens.ts index 96144c64f..66a622d29 100644 --- a/apps/explorer/src/lib/server/tokens.ts +++ b/apps/explorer/src/lib/server/tokens.ts @@ -97,6 +97,12 @@ export async function getTokenListAddresses( return (await getTokenList(chainId)).addresses } +export async function getTokenListEntries( + chainId: number, +): Promise { + return (await getTokenList(chainId)).entries +} + function getAddressKey(address: string): string { return address.toLowerCase() } diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index 07f5b75d0..1ea74cd2b 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ApiTip20RolesRouteImport } from './routes/api/tip20-roles' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiCodeRouteImport } from './routes/api/code' +import { Route as LayoutValidatorsRouteImport } from './routes/_layout/validators' import { Route as LayoutTokensRouteImport } from './routes/_layout/tokens' import { Route as LayoutFeeAmmRouteImport } from './routes/_layout/fee-amm' import { Route as LayoutBlocksRouteImport } from './routes/_layout/blocks' @@ -76,6 +77,11 @@ const ApiCodeRoute = ApiCodeRouteImport.update({ path: '/api/code', getParentRoute: () => rootRouteImport, } as any) +const LayoutValidatorsRoute = LayoutValidatorsRouteImport.update({ + id: '/validators', + path: '/validators', + getParentRoute: () => LayoutRoute, +} as any) const LayoutTokensRoute = LayoutTokensRouteImport.update({ id: '/tokens', path: '/tokens', @@ -215,6 +221,7 @@ export interface FileRoutesByFullPath { '/blocks': typeof LayoutBlocksRoute '/fee-amm': typeof LayoutFeeAmmRoute '/tokens': typeof LayoutTokensRoute + '/validators': typeof LayoutValidatorsRoute '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute @@ -247,6 +254,7 @@ export interface FileRoutesByTo { '/blocks': typeof LayoutBlocksRoute '/fee-amm': typeof LayoutFeeAmmRoute '/tokens': typeof LayoutTokensRoute + '/validators': typeof LayoutValidatorsRoute '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute @@ -282,6 +290,7 @@ export interface FileRoutesById { '/_layout/blocks': typeof LayoutBlocksRoute '/_layout/fee-amm': typeof LayoutFeeAmmRoute '/_layout/tokens': typeof LayoutTokensRoute + '/_layout/validators': typeof LayoutValidatorsRoute '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute @@ -318,6 +327,7 @@ export interface FileRouteTypes { | '/blocks' | '/fee-amm' | '/tokens' + | '/validators' | '/api/code' | '/api/health' | '/api/search' @@ -350,6 +360,7 @@ export interface FileRouteTypes { | '/blocks' | '/fee-amm' | '/tokens' + | '/validators' | '/api/code' | '/api/health' | '/api/search' @@ -384,6 +395,7 @@ export interface FileRouteTypes { | '/_layout/blocks' | '/_layout/fee-amm' | '/_layout/tokens' + | '/_layout/validators' | '/api/code' | '/api/health' | '/api/search' @@ -484,6 +496,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCodeRouteImport parentRoute: typeof rootRouteImport } + '/_layout/validators': { + id: '/_layout/validators' + path: '/validators' + fullPath: '/validators' + preLoaderRoute: typeof LayoutValidatorsRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/tokens': { id: '/_layout/tokens' path: '/tokens' @@ -666,6 +685,7 @@ interface LayoutRouteChildren { LayoutBlocksRoute: typeof LayoutBlocksRoute LayoutFeeAmmRoute: typeof LayoutFeeAmmRoute LayoutTokensRoute: typeof LayoutTokensRoute + LayoutValidatorsRoute: typeof LayoutValidatorsRoute LayoutIndexRoute: typeof LayoutIndexRoute LayoutAddressAddressRoute: typeof LayoutAddressAddressRoute LayoutBlockIdRoute: typeof LayoutBlockIdRoute @@ -684,6 +704,7 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutBlocksRoute: LayoutBlocksRoute, LayoutFeeAmmRoute: LayoutFeeAmmRoute, LayoutTokensRoute: LayoutTokensRoute, + LayoutValidatorsRoute: LayoutValidatorsRoute, LayoutIndexRoute: LayoutIndexRoute, LayoutAddressAddressRoute: LayoutAddressAddressRoute, LayoutBlockIdRoute: LayoutBlockIdRoute, diff --git a/apps/explorer/src/routes/_layout/index.tsx b/apps/explorer/src/routes/_layout/index.tsx index 40cd51e12..a4caf27b9 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -1,10 +1,12 @@ -import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import type { Address, Hex } from 'ox' import * as React from 'react' import * as z from 'zod/mini' -import { ExploreInput } from '#comps/ExploreInput' +import { LandingBento } from '#comps/bento/LandingBento' +import { LiveStatus } from '#comps/bento/LiveStatus' +import { HeroSection } from '#comps/HeroSection' import { cx } from '#lib/css' -import { getTempoEnv } from '#lib/env' +import { getTempoEnv, hasIndexSupply } from '#lib/env' import BoxIcon from '~icons/lucide/box' import CoinsIcon from '~icons/lucide/coins' import FileIcon from '~icons/lucide/file' @@ -42,10 +44,15 @@ export const Route = createFileRoute('/_layout/')({ validateSearch: z.object({ q: z.optional(z.coerce.string()), }).parse, + loader: () => { + // Tile queries are intentionally not prefetched here — TanStack + // Start's SSR streaming awaits them, which can stall the response + // when tidx is slow (and our queries can each take seconds). Tiles + // hydrate client-side and show skeletons until ready. + }, }) function Component() { - const navigate = useNavigate() const { q } = Route.useSearch() const query = q?.trim() ?? '' const [inputValue, setInputValue] = React.useState(query) @@ -56,54 +63,23 @@ function Component() { return (
-
-
- -
-
-
-
- { - if (data.type === 'block') { - navigate({ - to: '/block/$id', - params: { id: data.value }, - }) - return - } - if (data.type === 'hash') { - navigate({ - to: '/tx/$hash', - params: { hash: data.value }, - }) - return - } - if (data.type === 'token') { - navigate({ - to: '/token/$address', - params: { address: data.value }, - }) - return - } - if (data.type === 'address') { - navigate({ - to: '/address/$address', - params: { address: data.value }, - }) - return - } - }} - /> +
+ +
+
-
+ {hasIndexSupply() ? ( +
+
+

+ Network at a glance +

+ +
+ +
+ ) : null}
) } @@ -183,19 +159,3 @@ function SpotlightPill(props: { ) } - -function LandingWords() { - return ( -
- - Search - - - Explore - - - Discover - -
- ) -} diff --git a/apps/explorer/src/routes/_layout/validators.tsx b/apps/explorer/src/routes/_layout/validators.tsx new file mode 100644 index 000000000..4db1e5014 --- /dev/null +++ b/apps/explorer/src/routes/_layout/validators.tsx @@ -0,0 +1,198 @@ +import { useQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import * as React from 'react' +import { Address } from '#comps/Address' +import { DataGrid } from '#comps/DataGrid' +import { Midcut } from 'midcut' +import { Sections } from '#comps/Sections' +import { cx } from '#lib/css' +import { useMediaQuery } from '#lib/hooks' +import { withLoaderTiming } from '#lib/profiling' +import { validatorsQueryOptions } from '#lib/queries' + +function ValidatorName({ name }: { name?: string }): React.JSX.Element { + if (!name) return + return ( + + {name} + + ) +} + +function Toggle({ + checked, + onChange, + label, +}: { + checked: boolean + onChange: (next: boolean) => void + label: string +}): React.JSX.Element { + return ( + + ) +} + +export const Route = createFileRoute('/_layout/validators')({ + component: ValidatorsPage, + head: () => ({ + meta: [{ title: 'Validators – Tempo Explorer' }], + }), + loader: ({ context }) => + withLoaderTiming('/_layout/validators', async () => + context.queryClient.ensureQueryData(validatorsQueryOptions()), + ), +}) + +function ValidatorsPage(): React.JSX.Element { + const loaderData = Route.useLoaderData() + + const { data: validators, isPending } = useQuery({ + ...validatorsQueryOptions(), + initialData: loaderData, + }) + + const [showInactive, setShowInactive] = React.useState(false) + + const isMobile = useMediaQuery('(max-width: 799px)') + const mode = isMobile ? 'stacked' : 'tabs' + + const activeCount = validators?.filter((v) => v.active).length ?? 0 + const totalCount = validators?.length ?? 0 + + const filteredValidators = React.useMemo(() => { + if (!validators) return [] + return showInactive ? validators : validators.filter((v) => v.active) + }, [validators, showInactive]) + + const columns: DataGrid.Column[] = [ + { + label: #, + align: 'start', + width: 40, + minWidth: 40, + }, + { label: 'Name', align: 'start', minWidth: 100 }, + { label: 'Address', align: 'start', width: 160, minWidth: 120 }, + { label: 'Status', align: 'start', width: 110, minWidth: 80 }, + { label: 'Public Key', align: 'start', width: 200, minWidth: 120 }, + ] + + const stackedColumns: DataGrid.Column[] = [ + { label: 'Name', align: 'start', minWidth: 80 }, + { label: 'Status', align: 'start', minWidth: 60 }, + ] + + return ( +
+
+

+ Validators +

+

+ Active validators securing the Tempo network. +

+
+ + ), + content: ( + + filteredValidators.map((validator, index) => ({ + cells: [ + + {index + 1} + , + , +
, + + + {validator.active ? 'Active' : 'Inactive'} + , + validator.publicKey ? ( + + ) : ( + + — + + ), + ], + link: { + href: `/address/${validator.validatorAddress}`, + title: `View validator ${validator.validatorAddress}`, + }, + })) + } + totalItems={filteredValidators.length} + page={1} + loading={isPending} + itemsLabel="validators" + itemsPerPage={filteredValidators.length || 10} + emptyState="No validators found." + pagination={false} + /> + ), + }, + ]} + activeSection={0} + /> +
+ ) +} diff --git a/apps/explorer/src/routes/styles.css b/apps/explorer/src/routes/styles.css index 8a5e859ae..f77151dde 100644 --- a/apps/explorer/src/routes/styles.css +++ b/apps/explorer/src/routes/styles.css @@ -305,6 +305,124 @@ input[type="number"] { scrollbar-gutter: stable; } +@keyframes fadeIn { + 0% { + opacity: 0; + transform: translateY(8px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +/* + * Hero background video uses a single white-on-black source asset; the + * filter below flips it for the active theme so the same asset works in + * light + dark without shipping two video files. + * + * Default (no explicit theme set + system light, or data-theme=light): + * invert + hue-rotate so wireframe reads black-on-white. + * Dark (data-theme=dark, or system dark with no override): + * leave source untouched (white wireframe on black). + */ +:root { + --hero-video-filter: invert(1) hue-rotate(180deg); + --hero-video-bg: #ffffff; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --hero-video-filter: none; + --hero-video-bg: #000000; + } +} +html[data-theme="light"] { + --hero-video-filter: invert(1) hue-rotate(180deg); + --hero-video-bg: #ffffff; +} +html[data-theme="dark"] { + --hero-video-filter: none; + --hero-video-bg: #000000; +} + +@keyframes liveDot { + 0% { + box-shadow: 0 0 0 0 rgb(34 197 94 / 0.55); + } + 70% { + box-shadow: 0 0 0 5px rgb(34 197 94 / 0); + } + 100% { + box-shadow: 0 0 0 0 rgb(34 197 94 / 0); + } +} + +@keyframes digitFlash { + 0% { + color: var(--color-accent); + transform: translateY(-2px); + opacity: 0.6; + } + 60% { + color: var(--color-accent); + transform: translateY(0); + opacity: 1; + } + 100% { + color: var(--color-primary); + transform: translateY(0); + opacity: 1; + } +} + +@keyframes blockTickFlash { + 0% { + box-shadow: 0 0 0 0 rgb(37 99 235 / 0.45); + } + 60% { + box-shadow: 0 0 0 6px rgb(37 99 235 / 0); + } + 100% { + box-shadow: 0 0 0 0 rgb(37 99 235 / 0); + } +} + +@keyframes liveHalo { + 0% { + box-shadow: 0 0 0 0 rgb(34 197 94 / 0.45); + } + 70% { + box-shadow: 0 0 0 6px rgb(34 197 94 / 0); + } + 100% { + box-shadow: 0 0 0 0 rgb(34 197 94 / 0); + } +} + +@keyframes liveHaloWarning { + 0% { + box-shadow: 0 0 0 0 rgb(226 163 54 / 0.45); + } + 70% { + box-shadow: 0 0 0 6px rgb(226 163 54 / 0); + } + 100% { + box-shadow: 0 0 0 0 rgb(226 163 54 / 0); + } +} + +@keyframes liveHaloOffline { + 0% { + box-shadow: 0 0 0 0 rgb(229 72 77 / 0.45); + } + 70% { + box-shadow: 0 0 0 6px rgb(229 72 77 / 0); + } + 100% { + box-shadow: 0 0 0 0 rgb(229 72 77 / 0); + } +} + @keyframes progressline { 0% { width: 0%;