feat: unified multi-chain explorer with Network Overview, Chains, and Validators pages

- Replace single-chain homepage with Network Overview showing P-chain stats,
  primary chains (C/P/X/A/B/Q/T/Z/K), and L1 subnet chains
- Add Chains directory page with Primary Network / L1-L2-L3 tabs
- Add Validators page with Dashboard, Validators list, and Delegators tabs
- Add P-chain API hooks (useCurrentValidators, useBlockchains, useSubnets)
- Apply monochrome theme: replace all blue/purple/cyan/teal with gray
- Add Geist Mono font support
- Add Chains and Validators to navigation sidebar
- Update route types, metadata, analytics, and cspell for new pages
This commit is contained in:
2026-02-25 22:53:10 -08:00
parent 39897c9458
commit 3c8d48e0e0
41 changed files with 1975 additions and 75 deletions
+5
View File
@@ -122,6 +122,7 @@
"grecaptcha",
"growthbook",
"gstatic",
"Hanzo",
"hairsp",
"healthz",
"helia",
@@ -149,6 +150,7 @@
"llms",
"lokijs",
"LUKSO",
"nanoLux",
"mainnets",
"megaeth",
"merkle",
@@ -177,6 +179,9 @@
"opblock",
"opengraph",
"paych",
"pchain",
"PCHAIN",
"CCHAIN",
"peekers",
"pino",
"pjpeg",
+73
View File
@@ -0,0 +1,73 @@
// Low-level P-chain JSON-RPC client.
// Derives the P-chain URL from NEXT_PUBLIC_NETWORK_RPC_URL by replacing the
// C-chain path (/ext/bc/C/rpc) with the P-chain path (/ext/bc/P).
import type { JsonRpcRequest, JsonRpcResponse } from './types';
import { getEnvValue } from 'configs/app/utils';
const PCHAIN_PATH = '/ext/bc/P';
const CCHAIN_PATH_PATTERN = /\/ext\/bc\/C\/rpc$/;
let requestId = 0;
function getPChainUrl(): string {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL');
if (!rpcUrl) {
throw new Error('NEXT_PUBLIC_NETWORK_RPC_URL is not configured');
}
// Replace C-chain path with P-chain path
if (CCHAIN_PATH_PATTERN.test(rpcUrl)) {
return rpcUrl.replace(CCHAIN_PATH_PATTERN, PCHAIN_PATH);
}
// Fallback: strip trailing slash and append P-chain path
const base = rpcUrl.replace(/\/+$/, '');
try {
const url = new URL(base);
return `${ url.origin }${ PCHAIN_PATH }`;
} catch {
throw new Error(`Invalid NEXT_PUBLIC_NETWORK_RPC_URL: ${ rpcUrl }`);
}
}
export async function pchainRpc<TResult>(
method: string,
params?: Record<string, unknown>,
): Promise<TResult> {
const url = getPChainUrl();
requestId += 1;
const body: JsonRpcRequest = {
jsonrpc: '2.0',
id: requestId,
method,
...(params !== undefined ? { params } : {}),
};
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`P-chain RPC error: HTTP ${ response.status }`);
}
const json = await response.json() as JsonRpcResponse<TResult>;
if (json.error) {
throw new Error(`P-chain RPC error: ${ json.error.message } (code ${ json.error.code })`);
}
if (json.result === undefined) {
throw new Error('P-chain RPC error: missing result in response');
}
return json.result;
}
export { getPChainUrl };
+23
View File
@@ -0,0 +1,23 @@
// P-chain API hooks and types.
export { pchainRpc, getPChainUrl } from './client';
export { useCurrentValidators } from './useCurrentValidators';
export { useBlockchains } from './useBlockchains';
export { useSubnets } from './useSubnets';
export type {
PChainValidator,
PChainDelegator,
PChainRewardOwner,
PChainBlockchain,
PChainSubnet,
GetCurrentValidatorsResponse,
GetBlockchainsResponse,
GetSubnetsResponse,
ValidatorStats,
JsonRpcRequest,
JsonRpcResponse,
JsonRpcError,
} from './types';
export type { UseCurrentValidatorsResult } from './useCurrentValidators';
+91
View File
@@ -0,0 +1,91 @@
// P-chain API response types for the Lux platform JSON-RPC API.
export interface PChainRewardOwner {
readonly locktime: string;
readonly threshold: string;
readonly addresses: ReadonlyArray<string>;
}
export interface PChainDelegator {
readonly txID: string;
readonly startTime: string;
readonly endTime: string;
readonly stakeAmount: string;
readonly nodeID: string;
readonly potentialReward: string;
readonly rewardOwner: PChainRewardOwner;
}
export interface PChainValidator {
readonly txID: string;
readonly startTime: string;
readonly endTime: string;
readonly stakeAmount: string;
readonly nodeID: string;
readonly weight: string;
readonly delegationFee: string;
readonly potentialReward: string;
readonly connected: boolean;
readonly uptime: string;
readonly delegators: ReadonlyArray<PChainDelegator> | null;
}
export interface PChainBlockchain {
readonly id: string;
readonly name: string;
readonly subnetID: string;
readonly vmID: string;
}
export interface PChainSubnet {
readonly id: string;
readonly controlKeys: ReadonlyArray<string>;
readonly threshold: string;
}
// JSON-RPC response wrappers
export interface GetCurrentValidatorsResponse {
readonly validators: ReadonlyArray<PChainValidator>;
}
export interface GetBlockchainsResponse {
readonly blockchains: ReadonlyArray<PChainBlockchain>;
}
export interface GetSubnetsResponse {
readonly subnets: ReadonlyArray<PChainSubnet>;
}
// Aggregated validator statistics
export interface ValidatorStats {
readonly totalStake: bigint;
readonly validatorCount: number;
readonly connectedCount: number;
readonly delegatorCount: number;
readonly totalDelegatedStake: bigint;
readonly averageUptime: number;
}
// JSON-RPC envelope types
export interface JsonRpcRequest {
readonly jsonrpc: '2.0';
readonly id: number;
readonly method: string;
readonly params?: Record<string, unknown>;
}
export interface JsonRpcResponse<TResult> {
readonly jsonrpc: '2.0';
readonly id: number;
readonly result?: TResult;
readonly error?: JsonRpcError;
}
export interface JsonRpcError {
readonly code: number;
readonly message: string;
readonly data?: unknown;
}
+46
View File
@@ -0,0 +1,46 @@
// React Query hook for platform.getBlockchains.
// Returns the list of all blockchains registered on the P-chain.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type {
GetBlockchainsResponse,
PChainBlockchain,
} from './types';
import { pchainRpc } from './client';
const BLOCKCHAINS_STALE_TIME_MS = 300_000;
const BLOCKCHAINS_QUERY_KEY = 'pchain:blockchains' as const;
const EMPTY_BLOCKCHAINS: ReadonlyArray<PChainBlockchain> = [];
async function fetchBlockchains(): Promise<ReadonlyArray<PChainBlockchain>> {
const response = await pchainRpc<GetBlockchainsResponse>(
'platform.getBlockchains',
{},
);
return response.blockchains;
}
export function useBlockchains() {
const query = useQuery({
queryKey: [ BLOCKCHAINS_QUERY_KEY ],
queryFn: fetchBlockchains,
staleTime: BLOCKCHAINS_STALE_TIME_MS,
});
const blockchains = React.useMemo(
() => query.data ?? EMPTY_BLOCKCHAINS,
[ query.data ],
);
return {
blockchains,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
+109
View File
@@ -0,0 +1,109 @@
// React Query hook for platform.getCurrentValidators.
// Returns the validator list and aggregated network statistics.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type {
GetCurrentValidatorsResponse,
PChainValidator,
ValidatorStats,
} from './types';
import { pchainRpc } from './client';
const VALIDATORS_STALE_TIME_MS = 60_000;
const VALIDATORS_QUERY_KEY = 'pchain:currentValidators' as const;
const UPTIME_PERCENTAGE_SCALE = 100;
const ZERO = BigInt(0);
function computeValidatorStats(
validators: ReadonlyArray<PChainValidator>,
): ValidatorStats {
let totalStake = ZERO;
let totalDelegatedStake = ZERO;
let connectedCount = 0;
let delegatorCount = 0;
let uptimeSum = 0;
for (const v of validators) {
totalStake += BigInt(v.stakeAmount);
if (v.connected) {
connectedCount += 1;
}
uptimeSum += parseFloat(v.uptime);
if (v.delegators) {
delegatorCount += v.delegators.length;
for (const d of v.delegators) {
totalDelegatedStake += BigInt(d.stakeAmount);
}
}
}
const averageUptime = validators.length > 0 ?
(uptimeSum / validators.length) * UPTIME_PERCENTAGE_SCALE :
0;
return {
totalStake,
validatorCount: validators.length,
connectedCount,
delegatorCount,
totalDelegatedStake,
averageUptime,
};
}
export interface UseCurrentValidatorsResult {
readonly validators: ReadonlyArray<PChainValidator>;
readonly stats: ValidatorStats;
}
async function fetchCurrentValidators(): Promise<UseCurrentValidatorsResult> {
const response = await pchainRpc<GetCurrentValidatorsResponse>(
'platform.getCurrentValidators',
{},
);
const validators = response.validators;
const stats = computeValidatorStats(validators);
return { validators, stats };
}
export function useCurrentValidators() {
const query = useQuery({
queryKey: [ VALIDATORS_QUERY_KEY ],
queryFn: fetchCurrentValidators,
staleTime: VALIDATORS_STALE_TIME_MS,
});
const validators = React.useMemo(
() => query.data?.validators ?? [],
[ query.data?.validators ],
);
const stats = React.useMemo(
() => query.data?.stats ?? {
totalStake: ZERO,
validatorCount: 0,
connectedCount: 0,
delegatorCount: 0,
totalDelegatedStake: ZERO,
averageUptime: 0,
},
[ query.data?.stats ],
);
return {
validators,
stats,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
+46
View File
@@ -0,0 +1,46 @@
// React Query hook for platform.getSubnets.
// Returns the list of all subnets on the P-chain.
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type {
GetSubnetsResponse,
PChainSubnet,
} from './types';
import { pchainRpc } from './client';
const SUBNETS_STALE_TIME_MS = 300_000;
const SUBNETS_QUERY_KEY = 'pchain:subnets' as const;
const EMPTY_SUBNETS: ReadonlyArray<PChainSubnet> = [];
async function fetchSubnets(): Promise<ReadonlyArray<PChainSubnet>> {
const response = await pchainRpc<GetSubnetsResponse>(
'platform.getSubnets',
{},
);
return response.subnets;
}
export function useSubnets() {
const query = useQuery({
queryKey: [ SUBNETS_QUERY_KEY ],
queryFn: fetchSubnets,
staleTime: SUBNETS_STALE_TIME_MS,
});
const subnets = React.useMemo(
() => query.data ?? EMPTY_SUBNETS,
[ query.data ],
);
return {
subnets,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
};
}
+9 -2
View File
@@ -91,12 +91,18 @@ export default function useNavItems(): ReturnType {
icon: 'navigation/name_services',
isActive: pathname.startsWith('/name-services'),
} : null;
const validators = config.features.validators.isEnabled ? {
const validators: NavItem = {
text: 'Validators',
nextRoute: { pathname: '/validators' as const },
icon: 'navigation/validator',
isActive: pathname === '/validators' || pathname === '/validators/[id]',
} : null;
};
const chains: NavItem = {
text: 'Chains',
nextRoute: { pathname: '/chains' as const },
icon: 'navigation/blockchain',
isActive: pathname === '/chains',
};
const rollupDeposits = {
text: `Deposits (L1${ rightLineArrow }L2)`,
nextRoute: { pathname: '/deposits' as const },
@@ -222,6 +228,7 @@ export default function useNavItems(): ReturnType {
blocks,
epochs,
topAccounts,
chains,
validators,
verifiedContracts,
nameLookup,
+1
View File
@@ -10,6 +10,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/tx/[hash]': 'Regular page',
'/blocks': 'Root page',
'/block/[height_or_hash]': 'Regular page',
'/chains': 'Root page',
'/block/countdown': 'Regular page',
'/block/countdown/[height]': 'Regular page',
'/accounts': 'Root page',
+1
View File
@@ -13,6 +13,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/tx/[hash]': 'View transaction %hash% on %network_title%',
'/blocks': DEFAULT_TEMPLATE,
'/block/[height_or_hash]': 'View the transactions, token transfers, and uncles for block %height_or_hash%',
'/chains': 'Explore all blockchains on the Lux Network including primary chains and L1/L2/L3 subnets.',
'/block/countdown': DEFAULT_TEMPLATE,
'/block/countdown/[height]': DEFAULT_TEMPLATE,
'/accounts': DEFAULT_TEMPLATE,
+1
View File
@@ -14,6 +14,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/tx/[hash]': '%network_name% transaction %hash%',
'/blocks': '%network_name% blocks',
'/block/[height_or_hash]': '%network_name% block %height_or_hash%',
'/chains': '%network_name% chains - all blockchains on the network',
'/block/countdown': '%network_name% block countdown index',
'/block/countdown/[height]': '%network_name% block %height% countdown',
'/accounts': '%network_name% top accounts',
+1
View File
@@ -8,6 +8,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/tx/[hash]': 'Transaction details',
'/blocks': 'Blocks',
'/block/[height_or_hash]': 'Block details',
'/chains': 'Chains',
'/block/countdown': 'Block countdown search',
'/block/countdown/[height]': 'Block countdown',
'/accounts': 'Top accounts',
+1
View File
@@ -39,6 +39,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/block/countdown/[height]", { "height": string }>
| StaticRoute<"/block/countdown">
| StaticRoute<"/blocks">
| StaticRoute<"/chains">
| DynamicRoute<"/cc/tx/[hash]", { "hash": string }>
| DynamicRoute<"/chain/[chain_slug]/accounts/label/[slug]", { "chain_slug": string; "slug": string }>
| DynamicRoute<"/chain/[chain_slug]/advanced-filter", { "chain_slug": string }>
+4
View File
@@ -43,6 +43,10 @@ class MyDocument extends Document {
href={ config.UI.fonts.body?.url ?? 'https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap' }
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
{ /* eslint-disable-next-line @next/next/no-sync-scripts */ }
<script src="/assets/envs.js"/>
+1 -1
View File
@@ -97,7 +97,7 @@ const OidcCallback: NextPage = () => {
<Flex direction="column" alignItems="center" gap={ 4 }>
<Text fontSize="xl" fontWeight="bold" color="red.500">Authentication Error</Text>
<Text color="gray.400">{ error }</Text>
<Link href="/" color="blue.400">
<Link href="/" color="gray.400">
Return to home
</Link>
</Flex>
+18
View File
@@ -0,0 +1,18 @@
import type { NextPage } from 'next';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import ChainsPage from 'ui/chains/ChainsPage';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/chains">
<ChainsPage/>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
+2 -2
View File
@@ -6,13 +6,13 @@ import PageNextJs from 'nextjs/PageNextJs';
import config from 'configs/app';
import OpSuperchainHome from 'ui/optimismSuperchain/home/OpSuperchainHome';
import Home from 'ui/pages/Home';
import NetworkOverview from 'ui/pages/NetworkOverview';
import LayoutHome from 'ui/shared/layout/LayoutHome';
const Page: NextPageWithLayout = () => {
return (
<PageNextJs pathname="/">
{ config.features.opSuperchain.isEnabled ? <OpSuperchainHome/> : <Home/> }
{ config.features.opSuperchain.isEnabled ? <OpSuperchainHome/> : <NetworkOverview/> }
</PageNextJs>
);
};
+3 -22
View File
@@ -1,37 +1,18 @@
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import config from 'configs/app';
const validatorsFeature = config.features.validators;
const Validators = dynamic(() => {
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'stability') {
return import('ui/pages/ValidatorsStability');
}
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'blackfort') {
return import('ui/pages/ValidatorsBlackfort');
}
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'zilliqa') {
return import('ui/pages/ValidatorsZilliqa');
}
throw new Error('Validators feature is not enabled.');
}, { ssr: false });
import ValidatorsPage from 'ui/validators/lux/ValidatorsPage';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/validators">
<Validators/>
<ValidatorsPage/>
</PageNextJs>
);
};
export default Page;
export { validators as getServerSideProps } from 'nextjs/getServerSideProps/main';
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
+1 -1
View File
@@ -34,7 +34,7 @@ export const Toaster = () => {
return (
<Toast.Root width={{ md: 'sm' }}>
{ toast.type === 'loading' ? (
<Spinner size="sm" color="blue.solid" my={ 1 }/>
<Spinner size="sm" color="gray.solid" my={ 1 }/>
) : null }
<Stack gap="0" flex="1" maxWidth="100%">
{ toast.title && <Toast.Title>{ toast.title }</Toast.Title> }
@@ -19,7 +19,7 @@ export interface ChartSelectionXProps {
}
export const ChartSelectionX = React.memo(({ anchorEl, height, scale, data, onSelect }: ChartSelectionXProps) => {
const [ borderColor ] = useToken('colors', 'blue.200');
const [ borderColor ] = useToken('colors', 'gray.400');
const ref = React.useRef(null);
const isActive = React.useRef(false);
@@ -137,7 +137,7 @@ export const ChartSelectionX = React.memo(({ anchorEl, height, scale, data, onSe
return (
<g className="ChartSelectionX" ref={ ref } opacity={ 0 }>
<rect className="ChartSelectionX__rect" width={ 0 } height={ height } fill="rgba(66, 153, 225, 0.1)"/>
<rect className="ChartSelectionX__rect" width={ 0 } height={ height } fill="rgba(160, 174, 192, 0.1)"/>
<line className="ChartSelectionX__line ChartSelectionX__line_left" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
<line className="ChartSelectionX__line ChartSelectionX__line_right" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
</g>
@@ -12,7 +12,7 @@ type Props = {
} & ({ label: string; children?: never } | { children: React.ReactNode; label?: never });
const ChartTooltipRow = ({ label, lineNum, children }: Props) => {
const labelColor = useToken('colors', 'blue.100');
const labelColor = useToken('colors', 'gray.400');
const textColor = useToken('colors', 'white');
return (
+1 -1
View File
@@ -21,7 +21,7 @@ export const ContentLoader = React.memo(({ text, ...props }: Props) => {
animation: `fromLeftToRight 700ms ease-in-out infinite alternate`,
left: '0%',
top: 0,
backgroundColor: 'blue.300',
backgroundColor: 'gray.400',
borderRadius: 'full',
}}
/>
+25 -25
View File
@@ -62,8 +62,8 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
// ELEMENTS
address: {
highlighted: {
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.blue.900}' } },
border: { value: { _light: '{colors.blue.200}', _dark: '{colors.blue.600}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.whiteAlpha.100}' } },
border: { value: { _light: '{colors.gray.300}', _dark: '{colors.gray.600}' } },
},
},
@@ -143,8 +143,8 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
},
selected: {
value: {
_light: heroBannerButton?._selected?.background?.[0] || '{colors.blue.50}',
_dark: heroBannerButton?._selected?.background?.[1] || heroBannerButton?._selected?.background?.[0] || '{colors.blue.50}',
_light: heroBannerButton?._selected?.background?.[0] || '{colors.whiteAlpha.100}',
_dark: heroBannerButton?._selected?.background?.[1] || heroBannerButton?._selected?.background?.[0] || '{colors.whiteAlpha.100}',
},
},
},
@@ -279,11 +279,11 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
},
bg: {
DEFAULT: { value: '{colors.alert.bg.info}' },
info: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.900}' } },
info: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
warning: { value: '{colors.alert.bg.warning}' },
success: { value: '{colors.alert.bg.success}' },
error: { value: '{colors.alert.bg.error}' },
loading: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.900}' } },
loading: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
},
},
input: {
@@ -368,11 +368,11 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
fg: { value: { _light: '{colors.red.500}', _dark: '{colors.red.200}' } },
},
purple: {
bg: { value: { _light: '{colors.purple.50}', _dark: '{colors.purple.800}' } },
fg: { value: { _light: '{colors.purple.500}', _dark: '{colors.purple.100}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
},
purple_alt: {
bg: { value: { _light: '{colors.purple.100}', _dark: '{colors.purple.800}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
fg: { value: { _light: '{colors.blackAlpha.800}', _dark: '{colors.whiteAlpha.800}' } },
},
orange: {
@@ -380,11 +380,11 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
fg: { value: { _light: '{colors.orange.500}', _dark: '{colors.orange.100}' } },
},
blue: {
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.blue.800}' } },
fg: { value: { _light: '{colors.blue.500}', _dark: '{colors.blue.100}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
},
blue_alt: {
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.blue.800}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
fg: { value: { _light: '{colors.blackAlpha.800}', _dark: '{colors.whiteAlpha.800}' } },
},
yellow: {
@@ -392,12 +392,12 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
fg: { value: { _light: '{colors.yellow.500}', _dark: '{colors.yellow.100}' } },
},
teal: {
bg: { value: { _light: '{colors.teal.50}', _dark: '{colors.teal.800}' } },
fg: { value: { _light: '{colors.teal.500}', _dark: '{colors.teal.100}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
},
cyan: {
bg: { value: { _light: '{colors.cyan.50}', _dark: '{colors.cyan.800}' } },
fg: { value: { _light: '{colors.cyan.500}', _dark: '{colors.cyan.100}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
},
pink: {
bg: { value: { _light: '{colors.pink.50}', _dark: '{colors.pink.800}' } },
@@ -418,28 +418,28 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
fg: { value: { _light: '{colors.red.600}', _dark: '{colors.red.200}' } },
},
blue: {
bg: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.800}' } },
fg: { value: { _light: '{colors.blue.600}', _dark: '{colors.blue.200}' } },
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
},
yellow: {
bg: { value: { _light: '{colors.yellow.100}', _dark: '{colors.yellow.800}' } },
fg: { value: { _light: '{colors.yellow.600}', _dark: '{colors.yellow.200}' } },
},
teal: {
bg: { value: { _light: '{colors.teal.100}', _dark: '{colors.teal.800}' } },
fg: { value: { _light: '{colors.teal.600}', _dark: '{colors.teal.200}' } },
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
},
cyan: {
bg: { value: { _light: '{colors.cyan.100}', _dark: '{colors.cyan.800}' } },
fg: { value: { _light: '{colors.cyan.600}', _dark: '{colors.cyan.200}' } },
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
},
orange: {
bg: { value: { _light: '{colors.orange.100}', _dark: '{colors.orange.600}' } },
fg: { value: { _light: '{colors.orange.600}', _dark: '{colors.orange.100}' } },
},
purple: {
bg: { value: { _light: '{colors.purple.50}', _dark: '{colors.purple.600}' } },
fg: { value: { _light: '{colors.purple.600}', _dark: '{colors.purple.50}' } },
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
},
pink: {
bg: { value: { _light: '{colors.pink.50}', _dark: '{colors.pink.600}' } },
@@ -458,7 +458,7 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
fg: { value: { _light: '{colors.blackAlpha.800}', _dark: '{colors.whiteAlpha.800}' } },
},
filter: {
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.whiteAlpha.200}' } },
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.whiteAlpha.200}' } },
},
select: {
bg: {
+3
View File
@@ -7,9 +7,12 @@ import config from 'configs/app';
export const BODY_TYPEFACE = config.UI.fonts.body?.name ?? 'Geist';
export const HEADING_TYPEFACE = config.UI.fonts.heading?.name ?? 'Geist';
export const MONO_TYPEFACE = 'Geist Mono';
export const fonts: ExcludeUndefined<ThemingConfig['tokens']>['fonts'] = {
heading: { value: `${ HEADING_TYPEFACE }, sans-serif` },
body: { value: `${ BODY_TYPEFACE }, sans-serif` },
mono: { value: `${ MONO_TYPEFACE }, monospace` },
};
export const textStyles: ThemingConfig['textStyles'] = {
+152
View File
@@ -0,0 +1,152 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import { Skeleton } from 'toolkit/chakra/skeleton';
interface ChainRowProps {
readonly name: string;
readonly fullName?: string;
readonly blockchainId?: string;
readonly subnetId?: string;
readonly vmId?: string;
readonly vmLabel?: string;
readonly chainId?: number | null;
readonly isActive?: boolean;
readonly isLoading?: boolean;
}
const TRUNCATE_PREFIX_LEN = 8;
const TRUNCATE_SUFFIX_LEN = 4;
function truncateId(id: string): string {
if (id.length <= TRUNCATE_PREFIX_LEN + TRUNCATE_SUFFIX_LEN + 3) {
return id;
}
return `${ id.slice(0, TRUNCATE_PREFIX_LEN) }...${ id.slice(-TRUNCATE_SUFFIX_LEN) }`;
}
const ChainRow = ({
name,
fullName,
blockchainId,
subnetId,
vmLabel,
chainId,
isActive = true,
isLoading = false,
}: ChainRowProps) => {
return (
<Skeleton loading={ isLoading }>
<Flex
alignItems="center"
py={ 3 }
px={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
transition="background 0.15s"
gap={ 4 }
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
>
{ /* Name column */ }
<Flex
direction="column"
minW={{ base: '100%', lg: '180px' }}
maxW={{ base: '100%', lg: '220px' }}
flexShrink={ 0 }
>
<Box
fontWeight="600"
fontSize="sm"
color="text.primary"
>
{ name }
</Box>
{ fullName && (
<Box
fontSize="xs"
color="text.secondary"
mt={ 0.5 }
>
{ fullName }
</Box>
) }
</Flex>
{ /* Blockchain ID column */ }
<Box
flex={ 1 }
minW={ 0 }
fontFamily="mono"
fontSize="sm"
color="text.secondary"
title={ blockchainId }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{ blockchainId ? truncateId(blockchainId) : '\u2014' }
</Box>
{ /* Subnet ID column */ }
<Box
flex={ 1 }
minW={ 0 }
fontFamily="mono"
fontSize="sm"
color="text.secondary"
title={ subnetId }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
display={{ base: 'none', lg: 'block' }}
>
{ subnetId ? truncateId(subnetId) : '\u2014' }
</Box>
{ /* VM badge */ }
<Flex alignItems="center" gap={ 2 } flexShrink={ 0 }>
{ vmLabel && (
<Box
bgColor={{ _light: 'gray.100', _dark: 'whiteAlpha.100' }}
color="text.secondary"
borderRadius="sm"
px={ 2 }
py={ 0.5 }
fontSize="xs"
fontFamily="mono"
whiteSpace="nowrap"
>
{ vmLabel }
</Box>
) }
{ chainId != null && (
<Box
bgColor={{ _light: 'gray.100', _dark: 'whiteAlpha.100' }}
color="text.secondary"
borderRadius="sm"
px={ 2 }
py={ 0.5 }
fontSize="xs"
fontFamily="mono"
whiteSpace="nowrap"
>
{ chainId }
</Box>
) }
</Flex>
{ /* Status indicator */ }
<Flex alignItems="center" flexShrink={ 0 } ml={{ base: 0, lg: 'auto' }}>
<Box
bgColor={ isActive ? 'green.400' : 'gray.400' }
borderRadius="full"
boxSize="8px"
/>
</Flex>
</Flex>
</Skeleton>
);
};
export default React.memo(ChainRow);
+261
View File
@@ -0,0 +1,261 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { PChainBlockchain } from 'lib/api/pchain';
import { useBlockchains } from 'lib/api/pchain';
import { Skeleton } from 'toolkit/chakra/skeleton';
import PageTitle from 'ui/shared/Page/PageTitle';
import ChainRow from './ChainRow';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY' as const;
const PRIMARY_CHAINS = [
{ name: 'C-Chain', fullName: 'Contract Chain', vm: 'EVM', chainId: 96369 },
{ name: 'P-Chain', fullName: 'Platform Chain', vm: 'PVM', chainId: null },
{ name: 'X-Chain', fullName: 'Exchange Chain', vm: 'AVM', chainId: null },
{ name: 'A-Chain', fullName: 'AI Chain', vm: 'AVM', chainId: null },
{ name: 'B-Chain', fullName: 'Bridge Chain', vm: 'BVM', chainId: null },
{ name: 'Q-Chain', fullName: 'Quantum Chain', vm: 'QVM', chainId: null },
{ name: 'T-Chain', fullName: 'Teleport Chain', vm: 'MPC', chainId: null },
{ name: 'Z-Chain', fullName: 'ZK Chain', vm: 'ZVM', chainId: null },
{ name: 'K-Chain', fullName: 'KMS Chain', vm: 'KVM', chainId: null },
] as const;
const SUBNET_CHAIN_IDS: Readonly<Record<string, number>> = {
Zoo: 200200,
Hanzo: 36963,
SPC: 36911,
Pars: 494949,
};
const TAB_IDS = {
primary: 'primary',
subnets: 'subnets',
} as const;
type TabId = typeof TAB_IDS[keyof typeof TAB_IDS];
const EMPTY_L1_CHAINS: ReadonlyArray<PChainBlockchain> = [];
// ---------------------------------------------------------------------------
// Table header
// ---------------------------------------------------------------------------
interface TableHeaderProps {
readonly showSubnetId: boolean;
}
const TableHeader = ({ showSubnetId }: TableHeaderProps) => (
<Flex
px={ 4 }
py={ 2 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
display={{ base: 'none', lg: 'flex' }}
>
<Box
minW="180px"
maxW="220px"
flexShrink={ 0 }
color="text.secondary"
fontWeight="600"
fontSize="xs"
textTransform="uppercase"
letterSpacing="wider"
>
Chain
</Box>
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
Blockchain ID
</Box>
{ showSubnetId && (
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
Subnet ID
</Box>
) }
<Box flexShrink={ 0 } w="120px" color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
VM
</Box>
<Box flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" ml="auto">
Status
</Box>
</Flex>
);
// ---------------------------------------------------------------------------
// Tab button
// ---------------------------------------------------------------------------
interface TabButtonProps {
readonly label: string;
readonly isActive: boolean;
readonly onClick: () => void;
}
const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
<Box
as="button"
px={ 4 }
py={ 2 }
fontSize="sm"
fontWeight={ isActive ? '600' : '400' }
color={ isActive ? 'text.primary' : 'text.secondary' }
borderBottom="2px solid"
borderColor={ isActive ? 'text.primary' : 'transparent' }
bg="transparent"
cursor="pointer"
transition="all 0.15s"
_hover={{ color: 'text.primary' }}
onClick={ onClick }
>
{ label }
</Box>
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const ChainsPage = () => {
const [ activeTab, setActiveTab ] = React.useState<TabId>(TAB_IDS.primary);
const { blockchains, isLoading } = useBlockchains();
const l1Chains = React.useMemo<ReadonlyArray<PChainBlockchain>>(() => {
if (!blockchains.length) {
return EMPTY_L1_CHAINS;
}
return blockchains.filter((chain) => chain.subnetID !== PRIMARY_NETWORK_ID);
}, [ blockchains ]);
const handlePrimaryClick = React.useCallback(() => {
setActiveTab(TAB_IDS.primary);
}, []);
const handleSubnetsClick = React.useCallback(() => {
setActiveTab(TAB_IDS.subnets);
}, []);
return (
<>
<PageTitle
title="Chains"
secondRow={ (
<Box fontSize="sm" color="text.secondary">
All blockchains on the Lux Network
</Box>
) }
/>
{ /* Tabs */ }
<Flex
borderBottom="1px solid"
borderColor="border.divider"
mb={ 4 }
gap={ 0 }
>
<TabButton
label="Primary Network"
isActive={ activeTab === TAB_IDS.primary }
onClick={ handlePrimaryClick }
/>
<TabButton
label={ `L1 / L2 / L3${ l1Chains.length > 0 ? ` (${ l1Chains.length })` : '' }` }
isActive={ activeTab === TAB_IDS.subnets }
onClick={ handleSubnetsClick }
/>
</Flex>
{ /* Primary Network tab */ }
{ activeTab === TAB_IDS.primary && (
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="md"
overflow="hidden"
>
<TableHeader showSubnetId={ false }/>
{ PRIMARY_CHAINS.map((chain) => (
<ChainRow
key={ chain.name }
name={ chain.name }
fullName={ chain.fullName }
vmLabel={ chain.vm }
chainId={ chain.chainId }
isActive
/>
)) }
</Box>
) }
{ /* L1/L2/L3 tab */ }
{ activeTab === TAB_IDS.subnets && (
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="md"
overflow="hidden"
>
<TableHeader showSubnetId/>
{ isLoading && (
<Box px={ 4 } py={ 6 }>
<Skeleton loading={ true } h="20px" mb={ 3 }/>
<Skeleton loading={ true } h="20px" mb={ 3 }/>
<Skeleton loading={ true } h="20px" mb={ 3 }/>
<Skeleton loading={ true } h="20px"/>
</Box>
) }
{ !isLoading && l1Chains.length === 0 && (
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
No L1/L2/L3 chains found
</Box>
) }
{ !isLoading && l1Chains.map((chain) => (
<ChainRow
key={ chain.id }
name={ chain.name }
blockchainId={ chain.id }
subnetId={ chain.subnetID }
vmId={ chain.vmID }
vmLabel={ resolveVmLabel(chain.vmID) }
chainId={ SUBNET_CHAIN_IDS[chain.name] ?? null }
isActive
/>
)) }
</Box>
) }
</>
);
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const KNOWN_VM_IDS: Readonly<Record<string, string>> = {
mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6: 'EVM',
ag3GReYPNuSR17rUP8acMdZipQBikdXNRKDyFszAysmy3vDXE: 'Subnet EVM',
jvYyfQTxGMJLuGWa55kdP2p2zSUYsQ5Raupu4TW34ZAUBAbtq: 'AVM',
rWhpuQPF1kb72esV2momhMuTYGkEb1oL29pt2EBXWsBY6MALT: 'PVM',
};
function resolveVmLabel(vmId?: string): string {
if (!vmId) {
return 'Unknown';
}
return KNOWN_VM_IDS[vmId] ?? truncateVmId(vmId);
}
function truncateVmId(vmId: string): string {
if (vmId.length <= 12) {
return vmId;
}
return `${ vmId.slice(0, 6) }...${ vmId.slice(-4) }`;
}
export default ChainsPage;
+2 -3
View File
@@ -11,10 +11,9 @@ import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import SearchBarMobile from 'ui/snippets/searchBar/SearchBarMobile';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
export const BACKGROUND_DEFAULT =
'radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)';
export const BACKGROUND_DEFAULT = 'var(--chakra-colors-whiteAlpha-50)';
const TEXT_COLOR_DEFAULT = 'white';
const BORDER_DEFAULT = 'none';
const BORDER_DEFAULT = '1px solid var(--chakra-colors-whiteAlpha-100)';
const HeroBanner = () => {
+295
View File
@@ -0,0 +1,295 @@
// Network Overview homepage for the Lux multi-chain explorer.
// Displays P-chain stats, primary chains, and L1 (subnet) chains.
import { Box, Flex, Grid, Text } from '@chakra-ui/react';
import React from 'react';
import { useBlockchains, useCurrentValidators } from 'lib/api/pchain';
import type { PChainBlockchain } from 'lib/api/pchain';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Heading } from 'toolkit/chakra/heading';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tag } from 'toolkit/chakra/tag';
import HeroBanner from 'ui/home/HeroBanner';
// ── Constants ──
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY';
const LUX_DECIMALS = 9;
const PRIMARY_CHAINS = [
{ id: 'C', name: 'C-Chain', fullName: 'Contract Chain', vm: 'EVM', href: '/' },
{ id: 'P', name: 'P-Chain', fullName: 'Platform Chain', vm: 'PVM', href: undefined },
{ id: 'X', name: 'X-Chain', fullName: 'Exchange Chain', vm: 'AVM', href: undefined },
{ id: 'A', name: 'A-Chain', fullName: 'AI Chain', vm: 'AVM', href: undefined },
{ id: 'B', name: 'B-Chain', fullName: 'Bridge Chain', vm: 'BVM', href: undefined },
{ id: 'Q', name: 'Q-Chain', fullName: 'Quantum Chain', vm: 'QVM', href: undefined },
{ id: 'T', name: 'T-Chain', fullName: 'Teleport Chain', vm: 'MPC', href: undefined },
{ id: 'Z', name: 'Z-Chain', fullName: 'ZK Chain', vm: 'ZVM', href: undefined },
{ id: 'K', name: 'K-Chain', fullName: 'KMS Chain', vm: 'KVM', href: undefined },
] as const;
const STAT_CARD_BG = { _light: 'gray.50', _dark: 'whiteAlpha.50' };
const CHAIN_CARD_HOVER_BG = { _light: 'gray.50', _dark: 'whiteAlpha.50' };
// ── Helpers ──
function formatStake(nanoLux: bigint): string {
const lux = Number(nanoLux) / Math.pow(10, LUX_DECIMALS);
if (lux >= 1_000_000) return `${ (lux / 1_000_000).toFixed(1) }M`;
if (lux >= 1_000) return `${ (lux / 1_000).toFixed(1) }K`;
return lux.toFixed(0);
}
function formatUptime(uptime: number): string {
return `${ uptime.toFixed(1) }%`;
}
// ── Sub-components ──
interface StatCardProps {
readonly label: string;
readonly value: string;
readonly isLoading: boolean;
}
const StatCard = ({ label, value, isLoading }: StatCardProps) => (
<Box
p={{ base: 4, lg: 5 }}
borderRadius="lg"
border="1px solid"
borderColor="border.divider"
bgColor={ STAT_CARD_BG }
>
<Text fontSize="sm" color="text.secondary" mb={ 1 }>
{ label }
</Text>
<Skeleton loading={ isLoading }>
<Text fontSize={{ base: 'xl', lg: '2xl' }} fontWeight={ 600 } color="text.primary">
{ value }
</Text>
</Skeleton>
</Box>
);
interface PrimaryChainCardProps {
readonly id: string;
readonly name: string;
readonly fullName: string;
readonly vm: string;
readonly href: string | undefined;
}
const PrimaryChainCard = ({ name, fullName, vm, href }: PrimaryChainCardProps) => {
const content = (
<Flex
align="center"
justify="space-between"
p={ 4 }
borderRadius="md"
border="1px solid"
borderColor="border.divider"
cursor={ href ? 'pointer' : 'default' }
_hover={ href ? { bg: CHAIN_CARD_HOVER_BG } : undefined }
transition="background 0.15s ease"
>
<Flex align="center" gap={ 3 }>
<Box>
<Text fontWeight={ 500 } color="text.primary" fontSize="sm">
{ name }
</Text>
<Text fontSize="xs" color="text.secondary">
{ fullName }
</Text>
</Box>
</Flex>
<Flex align="center" gap={ 2 }>
<Tag size="sm" variant="subtle">{ vm }</Tag>
{ href && (
<Text color="text.secondary" fontSize="sm">
{ '\u2192' }
</Text>
) }
</Flex>
</Flex>
);
if (href) {
return (
<Link href={ href } variant="plain">
{ content }
</Link>
);
}
return content;
};
interface L1ChainCardProps {
readonly chain: PChainBlockchain;
}
const L1ChainCard = ({ chain }: L1ChainCardProps) => (
<Flex
align="center"
justify="space-between"
p={ 4 }
borderRadius="md"
border="1px solid"
borderColor="border.divider"
transition="background 0.15s ease"
>
<Flex align="center" gap={ 3 }>
<Box>
<Text fontWeight={ 500 } color="text.primary" fontSize="sm">
{ chain.name }
</Text>
<Text fontSize="xs" color="text.secondary" wordBreak="break-all">
{ chain.id.slice(0, 12) }...
</Text>
</Box>
</Flex>
<Tag size="sm" variant="subtle">L1</Tag>
</Flex>
);
interface L1ChainsListProps {
readonly chains: ReadonlyArray<PChainBlockchain>;
readonly isLoading: boolean;
}
const SKELETON_PLACEHOLDER_COUNT = 4;
const L1ChainsList = ({ chains, isLoading }: L1ChainsListProps) => {
if (isLoading) {
return (
<Flex direction="column" gap={ 2 }>
{ Array.from({ length: SKELETON_PLACEHOLDER_COUNT }).map((_, i) => (
<Skeleton key={ i } loading={ true } h="68px" borderRadius="md"/>
)) }
</Flex>
);
}
if (chains.length === 0) {
return (
<Text color="text.secondary" fontSize="sm" py={ 4 }>
No L1 chains registered yet.
</Text>
);
}
return (
<Flex direction="column" gap={ 2 }>
{ chains.map((chain) => (
<L1ChainCard key={ chain.id } chain={ chain }/>
)) }
</Flex>
);
};
// ── Main component ──
const NetworkOverview = () => {
const isMobile = useIsMobile();
const { stats, isLoading: validatorsLoading } = useCurrentValidators();
const { blockchains, isLoading: chainsLoading } = useBlockchains();
const l1Chains = React.useMemo(
() => blockchains.filter((c) => c.subnetID !== PRIMARY_NETWORK_ID),
[ blockchains ],
);
const totalChains = React.useMemo(
() => PRIMARY_CHAINS.length + l1Chains.length,
[ l1Chains.length ],
);
const isLoading = validatorsLoading || chainsLoading;
return (
<Box as="main">
<HeroBanner/>
{ /* Stats bar */ }
<Grid
templateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' }}
gap={{ base: 2, lg: 3 }}
mt={ 6 }
>
<StatCard
label="Total Chains"
value={ String(totalChains) }
isLoading={ isLoading }
/>
<StatCard
label="Validators"
value={ String(stats.validatorCount) }
isLoading={ isLoading }
/>
<StatCard
label="Total Stake"
value={ `${ formatStake(stats.totalStake) } LUX` }
isLoading={ isLoading }
/>
<StatCard
label="Avg Uptime"
value={ formatUptime(stats.averageUptime) }
isLoading={ isLoading }
/>
</Grid>
{ /* Chains section: Primary Network + L1 Chains */ }
<Grid
templateColumns={{ base: '1fr', lg: '1fr 1fr' }}
gap={{ base: 6, lg: 8 }}
mt={ 8 }
>
{ /* Primary Network column */ }
<Box>
<Heading level="2" mb={ 4 }>
Primary Network
</Heading>
<Flex direction="column" gap={ 2 }>
{ PRIMARY_CHAINS.map((chain) => (
<PrimaryChainCard
key={ chain.id }
id={ chain.id }
name={ chain.name }
fullName={ chain.fullName }
vm={ chain.vm }
href={ chain.href }
/>
)) }
</Flex>
</Box>
{ /* L1 Chains column */ }
<Box>
<Flex align="center" justify="space-between" mb={ 4 }>
<Flex align="center" gap={ 2 }>
<Heading level="2">
L1 Chains
</Heading>
<Skeleton loading={ chainsLoading }>
<Tag size="sm" variant="subtle">{ l1Chains.length }</Tag>
</Skeleton>
</Flex>
</Flex>
<L1ChainsList chains={ l1Chains } isLoading={ chainsLoading }/>
</Box>
</Grid>
{ /* Bottom search bar (desktop only) */ }
{ !isMobile && (
<Box mt={ 10 } mb={ 4 }>
<Text color="text.secondary" fontSize="sm" mb={ 3 } textAlign="center">
Search by address, transaction hash, block, or token
</Text>
</Box>
) }
</Box>
);
};
export default React.memo(NetworkOverview);
+2 -2
View File
@@ -14,8 +14,8 @@ const LogIndex = ({ children, isLoading, ...props }: Props) => {
<Tooltip content="Log index">
<Skeleton loading={ isLoading } asChild>
<Center
color={ isLoading ? 'transparent' : { _light: 'blue.600', _dark: 'gray.50' } }
bgColor={ isLoading ? undefined : { _light: 'blue.50', _dark: 'gray.600' } }
color={ isLoading ? 'transparent' : { _light: 'gray.600', _dark: 'gray.50' } }
bgColor={ isLoading ? undefined : { _light: 'gray.100', _dark: 'gray.600' } }
borderRadius="base"
px={ 2 }
{ ...props }
+6 -6
View File
@@ -38,11 +38,11 @@ export const light: monaco.editor.IStandaloneThemeData = {
'custom.inputOption.hoverBackground': 'rgba(184, 184, 184, 0.31)',
// don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.fileLink.hoverForeground': '#A0AEC0', // gray.400
'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100
'custom.riskWarning.background': '#FFFAF0', // orange.50
'custom.mainContract.header': 'rgba(233, 216, 253, 1)', // purple.100
'custom.mainContract.body': 'rgba(250, 245, 255, 1)', // purple.50
'custom.mainContract.header': 'rgba(226, 232, 240, 1)', // gray.200
'custom.mainContract.body': 'rgba(247, 250, 252, 1)', // gray.50
} as const,
};
@@ -84,10 +84,10 @@ export const dark: monaco.editor.IStandaloneThemeData = {
'custom.inputOption.hoverBackground': 'rgba(90, 93, 94, 0.31)',
// don't know the name of this variables in vscode
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.fileLink.hoverForeground': '#A0AEC0', // gray.400
'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300
'custom.riskWarning.background': 'rgba(246, 173, 85, 0.1)', // orange.300
'custom.mainContract.header': 'rgba(183, 148, 244, 0.3)', // purple.300
'custom.mainContract.body': 'rgba(214, 188, 250, 0.1)', // purple.200
'custom.mainContract.header': 'rgba(160, 174, 192, 0.3)', // gray.400
'custom.mainContract.body': 'rgba(203, 213, 224, 0.1)', // gray.300
} as const,
};
+1 -1
View File
@@ -39,7 +39,7 @@ const initReown = () => {
},
themeVariables: {
'--w3m-font-family': `${ BODY_TYPEFACE }, sans-serif`,
'--w3m-accent': colors.blue[600].value,
'--w3m-accent': colors.gray[600].value,
'--w3m-border-radius-master': '2px',
'--w3m-z-index': zIndex?.modal2?.value,
},
+2 -2
View File
@@ -71,7 +71,7 @@ const Footer = () => {
{
icon: 'docs' as const,
iconSize: '20px',
text: 'Documentation',
text: 'Documentation',
url: 'https://docs.lux.network',
},
{
@@ -125,7 +125,7 @@ text: 'Documentation',
}, []);
const renderProjectInfo = React.useCallback((gridArea?: GridProps['gridArea']) => {
const logoColor = { base: 'blue.600', _dark: 'white' };
const logoColor = { base: 'gray.600', _dark: 'white' };
return (
<Box gridArea={ gridArea }>
@@ -49,7 +49,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
const isAuth = useIsAuth();
const iconColor = useColorModeValue('blue.600', 'blue.300');
const iconColor = useColorModeValue('gray.600', 'gray.300');
const openedItem = mainNavItems[openedGroupIndex];
@@ -65,7 +65,7 @@ const SearchBarRecentKeywords = ({ onClick, onClear }: Props) => {
borderBottomWidth: '0',
}}
_hover={{
bgColor: { _light: 'blue.50', _dark: 'gray.800' },
bgColor: { _light: 'gray.100', _dark: 'gray.800' },
}}
fontSize="sm"
_first={{
@@ -22,7 +22,7 @@ const SearchBarSuggestItemLink = React.forwardRef<HTMLAnchorElement, Props>(({ c
borderBottomWidth: '0',
}}
_hover={{
bgColor: { _light: 'blue.50', _dark: 'gray.800' },
bgColor: { _light: 'gray.100', _dark: 'gray.800' },
}}
textStyle="sm"
_first={{
@@ -13,7 +13,7 @@ interface Props {
const SettingsSample = ({ label, value, bg, onClick, isActive }: Props) => {
const bgColor = { base: 'white', _dark: 'gray.900' };
const activeBgColor = { base: 'blue.50', _dark: 'whiteAlpha.100' };
const activeBgColor = { base: 'gray.100', _dark: 'whiteAlpha.100' };
const activeBorderColor = { base: 'blackAlpha.800', _dark: 'gray.50' };
return (
+181
View File
@@ -0,0 +1,181 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { PChainDelegator, PChainValidator } from 'lib/api/pchain';
import dayjs from 'lib/date/dayjs';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { formatStake, truncateNodeId } from './utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface FlatDelegator extends PChainDelegator {
readonly validatorNodeID: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TXID_PREFIX_LEN = 10;
const TXID_SUFFIX_LEN = 6;
function truncateTxId(txId: string): string {
const minLength = TXID_PREFIX_LEN + TXID_SUFFIX_LEN + 3;
if (txId.length <= minLength) {
return txId;
}
return `${ txId.slice(0, TXID_PREFIX_LEN) }...${ txId.slice(-TXID_SUFFIX_LEN) }`;
}
function formatTimestamp(unixSeconds: string): string {
const ts = Number(unixSeconds);
if (ts === 0) {
return '\u2014';
}
return dayjs.unix(ts).format('lll');
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface DelegatorsListProps {
readonly validators: ReadonlyArray<PChainValidator>;
readonly isLoading: boolean;
}
const DelegatorsList = ({ validators, isLoading }: DelegatorsListProps) => {
const delegators = React.useMemo<ReadonlyArray<FlatDelegator>>(() => {
const result: Array<FlatDelegator> = [];
for (const v of validators) {
if (v.delegators) {
for (const d of v.delegators) {
result.push({ ...d, validatorNodeID: v.nodeID });
}
}
}
// Sort by stake descending
result.sort((a, b) => {
const aStake = BigInt(a.stakeAmount);
const bStake = BigInt(b.stakeAmount);
if (bStake > aStake) return 1;
if (bStake < aStake) return -1;
return 0;
});
return result;
}, [ validators ]);
return (
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="lg"
overflow="hidden"
>
{ /* Header */ }
<Flex
px={ 4 }
py={ 2 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
display={{ base: 'none', lg: 'flex' }}
>
<Box w="180px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
TX ID
</Box>
<Box w="260px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
Node ID
</Box>
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
Stake Amount
</Box>
<Box w="180px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
Start
</Box>
<Box w="180px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
End
</Box>
<Box w="120px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
Potential Reward
</Box>
</Flex>
{ /* Loading */ }
{ isLoading && (
<Box px={ 4 } py={ 6 }>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px"/>
</Box>
) }
{ /* Empty state */ }
{ !isLoading && delegators.length === 0 && (
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
No delegators found
</Box>
) }
{ /* Rows */ }
{ !isLoading && delegators.map((d) => (
<Flex
key={ `${ d.txID }-${ d.validatorNodeID }` }
px={ 4 }
py={ 3 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
alignItems="center"
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
transition="background 0.15s"
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
>
<Box
w={{ base: '100%', lg: '180px' }}
flexShrink={ 0 }
fontFamily="mono"
fontSize="sm"
color="text.primary"
title={ d.txID }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{ truncateTxId(d.txID) }
</Box>
<Box
w={{ base: '100%', lg: '260px' }}
flexShrink={ 0 }
fontFamily="mono"
fontSize="sm"
color="text.secondary"
title={ d.validatorNodeID }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{ truncateNodeId(d.validatorNodeID) }
</Box>
<Box flex={ 1 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
{ formatStake(d.stakeAmount) } LUX
</Box>
<Box w={{ base: 'auto', lg: '180px' }} flexShrink={ 0 } fontSize="xs" color="text.secondary">
{ formatTimestamp(d.startTime) }
</Box>
<Box w={{ base: 'auto', lg: '180px' }} flexShrink={ 0 } fontSize="xs" color="text.secondary">
{ formatTimestamp(d.endTime) }
</Box>
<Box w={{ base: 'auto', lg: '120px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
{ formatStake(d.potentialReward) } LUX
</Box>
</Flex>
)) }
</Box>
);
};
export default React.memo(DelegatorsList);
+255
View File
@@ -0,0 +1,255 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { PChainValidator, ValidatorStats } from 'lib/api/pchain';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { formatStake, formatUptime, truncateNodeId } from './utils';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TOP_VALIDATORS_COUNT = 20;
const PERCENTAGE_SCALE = 100;
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
interface StatCardProps {
readonly label: string;
readonly value: string;
readonly isLoading: boolean;
}
const StatCard = ({ label, value, isLoading }: StatCardProps) => (
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="lg"
p={ 5 }
bgColor={{ _light: 'gray.50', _dark: 'whiteAlpha.50' }}
>
<Box fontSize="xs" color="text.secondary" fontWeight="600" textTransform="uppercase" letterSpacing="wider" mb={ 1 }>
{ label }
</Box>
<Skeleton loading={ isLoading }>
<Box fontSize="2xl" fontWeight="700" color="text.primary">
{ value }
</Box>
</Skeleton>
</Box>
);
// ---------------------------------------------------------------------------
// Stake breakdown
// ---------------------------------------------------------------------------
interface StakeBreakdownProps {
readonly stats: ValidatorStats;
readonly isLoading: boolean;
}
const StakeBreakdown = ({ stats, isLoading }: StakeBreakdownProps) => {
const validatorStake = stats.totalStake - stats.totalDelegatedStake;
const totalNumber = Number(stats.totalStake);
const validatorPct = totalNumber > 0 ?
(Number(validatorStake) / totalNumber * PERCENTAGE_SCALE).toFixed(1) :
'0';
const delegationPct = totalNumber > 0 ?
(Number(stats.totalDelegatedStake) / totalNumber * PERCENTAGE_SCALE).toFixed(1) :
'0';
return (
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="lg"
p={ 5 }
bgColor={{ _light: 'gray.50', _dark: 'whiteAlpha.50' }}
>
<Box fontSize="xs" color="text.secondary" fontWeight="600" textTransform="uppercase" letterSpacing="wider" mb={ 1 }>
Total Stake
</Box>
<Skeleton loading={ isLoading }>
<Box fontSize="2xl" fontWeight="700" color="text.primary" mb={ 3 }>
{ formatStake(stats.totalStake) } LUX
</Box>
</Skeleton>
<Skeleton loading={ isLoading }>
<Flex gap={ 4 } fontSize="sm" color="text.secondary">
<Box>Validators: { formatStake(validatorStake) } LUX ({ validatorPct }%)</Box>
<Box>Delegated: { formatStake(stats.totalDelegatedStake) } LUX ({ delegationPct }%)</Box>
</Flex>
</Skeleton>
</Box>
);
};
// ---------------------------------------------------------------------------
// Active validators table (top N)
// ---------------------------------------------------------------------------
interface ActiveValidatorsTableProps {
readonly validators: ReadonlyArray<PChainValidator>;
readonly isLoading: boolean;
}
const ActiveValidatorsTable = ({ validators, isLoading }: ActiveValidatorsTableProps) => {
const sorted = React.useMemo(
() => [ ...validators ].sort((a, b) => {
const aStake = BigInt(a.stakeAmount);
const bStake = BigInt(b.stakeAmount);
if (bStake > aStake) return 1;
if (bStake < aStake) return -1;
return 0;
}).slice(0, TOP_VALIDATORS_COUNT),
[ validators ],
);
return (
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="lg"
overflow="hidden"
>
<Box px={ 4 } py={ 3 } fontWeight="600" fontSize="sm" color="text.primary" borderBottom="1px solid" borderColor="border.divider">
Active Validators (Top { TOP_VALIDATORS_COUNT })
</Box>
{ /* Header */ }
<Flex
px={ 4 }
py={ 2 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
display={{ base: 'none', lg: 'flex' }}
>
<Box w="320px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
Node ID
</Box>
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
Stake
</Box>
<Box w="120px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
Delegation Fee
</Box>
<Box w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="center">
Connected
</Box>
<Box w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
Uptime
</Box>
</Flex>
{ /* Rows */ }
{ isLoading && (
<Box px={ 4 } py={ 6 }>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px"/>
</Box>
) }
{ !isLoading && sorted.map((v) => (
<Flex
key={ v.nodeID }
px={ 4 }
py={ 3 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
alignItems="center"
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
transition="background 0.15s"
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
>
<Box
w={{ base: '100%', lg: '320px' }}
flexShrink={ 0 }
fontFamily="mono"
fontSize="sm"
color="text.primary"
title={ v.nodeID }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{ truncateNodeId(v.nodeID) }
</Box>
<Box flex={ 1 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
{ formatStake(v.stakeAmount) } LUX
</Box>
<Box w={{ base: 'auto', lg: '120px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
{ v.delegationFee }%
</Box>
<Flex w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } justifyContent={{ base: 'flex-start', lg: 'center' }} alignItems="center">
<Box
bgColor={ v.connected ? 'green.400' : 'red.400' }
borderRadius="full"
boxSize="8px"
/>
</Flex>
<Box w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
{ formatUptime(v.uptime) }
</Box>
</Flex>
)) }
</Box>
);
};
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
interface ValidatorsDashboardProps {
readonly validators: ReadonlyArray<PChainValidator>;
readonly stats: ValidatorStats;
readonly isLoading: boolean;
}
const ValidatorsDashboard = ({ validators, stats, isLoading }: ValidatorsDashboardProps) => {
return (
<Flex direction="column" gap={ 6 }>
{ /* Stat cards */ }
<Box
display="grid"
gridTemplateColumns={{ base: '1fr 1fr', lg: 'repeat(4, 1fr)' }}
gap={ 3 }
>
<StatCard
label="Validators"
value={ stats.validatorCount.toLocaleString() }
isLoading={ isLoading }
/>
<StatCard
label="Connected"
value={ `${ stats.connectedCount }/${ stats.validatorCount }` }
isLoading={ isLoading }
/>
<StatCard
label="Delegators"
value={ stats.delegatorCount.toLocaleString() }
isLoading={ isLoading }
/>
<StatCard
label="Avg Uptime"
value={ `${ stats.averageUptime.toFixed(1) }%` }
isLoading={ isLoading }
/>
</Box>
{ /* Stake breakdown */ }
<StakeBreakdown stats={ stats } isLoading={ isLoading }/>
{ /* Active validators table */ }
<ActiveValidatorsTable validators={ validators } isLoading={ isLoading }/>
</Flex>
);
};
export default React.memo(ValidatorsDashboard);
+186
View File
@@ -0,0 +1,186 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { PChainValidator } from 'lib/api/pchain';
import { Input } from 'toolkit/chakra/input';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { formatStake, formatUptime, truncateNodeId } from './utils';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface ValidatorsListProps {
readonly validators: ReadonlyArray<PChainValidator>;
readonly isLoading: boolean;
}
const ValidatorsList = ({ validators, isLoading }: ValidatorsListProps) => {
const [ search, setSearch ] = React.useState('');
const handleSearchChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
},
[],
);
// Sort by stake desc, then filter by search
const filtered = React.useMemo(() => {
const sorted = [ ...validators ].sort((a, b) => {
const aStake = BigInt(a.stakeAmount);
const bStake = BigInt(b.stakeAmount);
if (bStake > aStake) return 1;
if (bStake < aStake) return -1;
return 0;
});
if (!search.trim()) {
return sorted;
}
const term = search.trim().toLowerCase();
return sorted.filter((v) => v.nodeID.toLowerCase().includes(term));
}, [ validators, search ]);
return (
<Flex direction="column" gap={ 4 }>
{ /* Search */ }
<Box maxW="400px">
<Input
placeholder="Search by Node ID..."
value={ search }
onChange={ handleSearchChange }
border="1px solid"
borderColor="border.divider"
borderRadius="md"
bgColor="transparent"
color="text.primary"
size="md"
/>
</Box>
{ /* Table */ }
<Box
border="1px solid"
borderColor="border.divider"
borderRadius="lg"
overflow="hidden"
>
{ /* Header */ }
<Flex
px={ 4 }
py={ 2 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
display={{ base: 'none', lg: 'flex' }}
>
<Box w="320px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
Node ID
</Box>
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
Stake Amount
</Box>
<Box
w="120px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right"
>
Delegation Fee
</Box>
<Box
w="100px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right"
>
Delegators
</Box>
<Box
w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="center"
>
Connected
</Box>
<Box
w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right"
>
Uptime
</Box>
</Flex>
{ /* Loading */ }
{ isLoading && (
<Box px={ 4 } py={ 6 }>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px" mb={ 3 }/>
<Skeleton loading h="16px"/>
</Box>
) }
{ /* Empty state */ }
{ !isLoading && filtered.length === 0 && (
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
{ search.trim() ? 'No validators match your search' : 'No validators found' }
</Box>
) }
{ /* Rows */ }
{ !isLoading && filtered.map((v) => (
<Flex
key={ v.nodeID }
px={ 4 }
py={ 3 }
gap={ 4 }
borderBottom="1px solid"
borderColor="border.divider"
alignItems="center"
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
transition="background 0.15s"
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
>
<Box
w={{ base: '100%', lg: '320px' }}
flexShrink={ 0 }
fontFamily="mono"
fontSize="sm"
color="text.primary"
title={ v.nodeID }
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{ truncateNodeId(v.nodeID) }
</Box>
<Box flex={ 1 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
{ formatStake(v.stakeAmount) } LUX
</Box>
<Box w={{ base: 'auto', lg: '120px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
{ v.delegationFee }%
</Box>
<Box w={{ base: 'auto', lg: '100px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
{ v.delegators?.length ?? 0 }
</Box>
<Flex w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } justifyContent={{ base: 'flex-start', lg: 'center' }} alignItems="center">
<Box
bgColor={ v.connected ? 'green.400' : 'red.400' }
borderRadius="full"
boxSize="8px"
/>
</Flex>
<Box w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
{ formatUptime(v.uptime) }
</Box>
</Flex>
)) }
</Box>
</Flex>
);
};
export default React.memo(ValidatorsList);
+134
View File
@@ -0,0 +1,134 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import { useCurrentValidators } from 'lib/api/pchain';
import PageTitle from 'ui/shared/Page/PageTitle';
import DelegatorsList from './DelegatorsList';
import ValidatorsDashboard from './ValidatorsDashboard';
import ValidatorsList from './ValidatorsList';
// ---------------------------------------------------------------------------
// Tab definitions
// ---------------------------------------------------------------------------
const TABS = {
dashboard: 'dashboard',
validators: 'validators',
delegators: 'delegators',
} as const;
type TabId = typeof TABS[keyof typeof TABS];
// ---------------------------------------------------------------------------
// Tab button
// ---------------------------------------------------------------------------
interface TabButtonProps {
readonly label: string;
readonly isActive: boolean;
readonly onClick: () => void;
}
const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
<Box
as="button"
px={ 4 }
py={ 2 }
fontSize="sm"
fontWeight={ isActive ? '600' : '400' }
color={ isActive ? 'text.primary' : 'text.secondary' }
borderBottom="2px solid"
borderColor={ isActive ? 'text.primary' : 'transparent' }
bg="transparent"
cursor="pointer"
transition="all 0.15s"
_hover={{ color: 'text.primary' }}
onClick={ onClick }
>
{ label }
</Box>
);
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
const ValidatorsPage = () => {
const [ activeTab, setActiveTab ] = React.useState<TabId>(TABS.dashboard);
const { validators, stats, isLoading } = useCurrentValidators();
const handleDashboardClick = React.useCallback(() => {
setActiveTab(TABS.dashboard);
}, []);
const handleValidatorsClick = React.useCallback(() => {
setActiveTab(TABS.validators);
}, []);
const handleDelegatorsClick = React.useCallback(() => {
setActiveTab(TABS.delegators);
}, []);
return (
<>
<PageTitle
title="Validators"
secondRow={ (
<Box fontSize="sm" color="text.secondary">
P-Chain validators securing the Lux Network
</Box>
) }
/>
{ /* Tabs */ }
<Flex
borderBottom="1px solid"
borderColor="border.divider"
mb={ 6 }
gap={ 0 }
>
<TabButton
label="Dashboard"
isActive={ activeTab === TABS.dashboard }
onClick={ handleDashboardClick }
/>
<TabButton
label={ `Validators (${ stats.validatorCount })` }
isActive={ activeTab === TABS.validators }
onClick={ handleValidatorsClick }
/>
<TabButton
label={ `Delegators (${ stats.delegatorCount })` }
isActive={ activeTab === TABS.delegators }
onClick={ handleDelegatorsClick }
/>
</Flex>
{ /* Tab content */ }
{ activeTab === TABS.dashboard && (
<ValidatorsDashboard
validators={ validators }
stats={ stats }
isLoading={ isLoading }
/>
) }
{ activeTab === TABS.validators && (
<ValidatorsList
validators={ validators }
isLoading={ isLoading }
/>
) }
{ activeTab === TABS.delegators && (
<DelegatorsList
validators={ validators }
isLoading={ isLoading }
/>
) }
</>
);
};
export default ValidatorsPage;
+26
View File
@@ -0,0 +1,26 @@
// Formatting helpers for Lux P-chain validator data.
const LUX_DECIMALS = 9;
const TRUNCATE_PREFIX_LEN = 12;
const TRUNCATE_SUFFIX_LEN = 6;
const UPTIME_PERCENTAGE_SCALE = 100;
const UPTIME_FRACTION_DIGITS = 2;
const STAKE_FRACTION_DIGITS = 2;
export function formatStake(nanoLux: bigint | string): string {
const raw = typeof nanoLux === 'string' ? nanoLux : String(nanoLux);
const lux = Number(BigInt(raw)) / Math.pow(10, LUX_DECIMALS);
return lux.toLocaleString(undefined, { maximumFractionDigits: STAKE_FRACTION_DIGITS });
}
export function formatUptime(uptime: string): string {
return `${ (parseFloat(uptime) * UPTIME_PERCENTAGE_SCALE).toFixed(UPTIME_FRACTION_DIGITS) }%`;
}
export function truncateNodeId(nodeId: string): string {
const minLength = TRUNCATE_PREFIX_LEN + TRUNCATE_SUFFIX_LEN + 3;
if (nodeId.length <= minLength) {
return nodeId;
}
return `${ nodeId.slice(0, TRUNCATE_PREFIX_LEN) }...${ nodeId.slice(-TRUNCATE_SUFFIX_LEN) }`;
}