mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-23 03:37:55 +00:00
4f683538d3
Add full internationalization across 127+ components and pages. 790+ translation keys in en, tr, kmr, ckb, ar, fa locales. Remove duplicate keys and delete unused .json locale files.
895 lines
34 KiB
TypeScript
895 lines
34 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import Layout from '@/components/Layout';
|
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Search,
|
|
Blocks,
|
|
ArrowRightLeft,
|
|
Users,
|
|
Zap,
|
|
CheckCircle,
|
|
XCircle,
|
|
RefreshCw,
|
|
Loader2,
|
|
ExternalLink,
|
|
Copy,
|
|
Activity,
|
|
Database,
|
|
Timer,
|
|
Hash,
|
|
Wallet,
|
|
ChevronRight,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
interface BlockInfo {
|
|
number: number;
|
|
hash: string;
|
|
parentHash: string;
|
|
stateRoot: string;
|
|
extrinsicsRoot: string;
|
|
extrinsicsCount: number;
|
|
timestamp: number;
|
|
author?: string;
|
|
}
|
|
|
|
interface ExtrinsicInfo {
|
|
hash: string;
|
|
blockNumber: number;
|
|
index: number;
|
|
section: string;
|
|
method: string;
|
|
signer?: string;
|
|
success: boolean;
|
|
timestamp: number;
|
|
args?: string;
|
|
}
|
|
|
|
interface NetworkStats {
|
|
bestBlock: number;
|
|
finalizedBlock: number;
|
|
totalExtrinsics: number;
|
|
activeValidators: number;
|
|
avgBlockTime: number;
|
|
tps: number;
|
|
totalAccounts: number;
|
|
era: number;
|
|
}
|
|
|
|
type ExplorerView = 'overview' | 'accounts' | 'assets' | 'account' | 'block' | 'tx';
|
|
|
|
const Explorer: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { t } = useTranslation();
|
|
const { api, isApiReady, assetHubApi, isAssetHubReady } = usePezkuwi();
|
|
|
|
// Parse URL to determine view
|
|
const getViewFromPath = (): { view: ExplorerView; param?: string } => {
|
|
const path = location.pathname.replace('/explorer', '').replace(/^\//, '');
|
|
if (!path) return { view: 'overview' };
|
|
|
|
const parts = path.split('/');
|
|
const viewType = parts[0];
|
|
const param = parts[1];
|
|
|
|
switch (viewType) {
|
|
case 'accounts':
|
|
return param ? { view: 'account', param } : { view: 'accounts' };
|
|
case 'account':
|
|
return { view: 'account', param };
|
|
case 'assets':
|
|
return { view: 'assets' };
|
|
case 'block':
|
|
return { view: 'block', param };
|
|
case 'tx':
|
|
return { view: 'tx', param };
|
|
default:
|
|
return { view: 'overview' };
|
|
}
|
|
};
|
|
|
|
const { view: currentView, param: viewParam } = getViewFromPath();
|
|
|
|
const [stats, setStats] = useState<NetworkStats>({
|
|
bestBlock: 0,
|
|
finalizedBlock: 0,
|
|
totalExtrinsics: 0,
|
|
activeValidators: 0,
|
|
avgBlockTime: 6,
|
|
tps: 0,
|
|
totalAccounts: 0,
|
|
era: 0,
|
|
});
|
|
|
|
const [recentBlocks, setRecentBlocks] = useState<BlockInfo[]>([]);
|
|
const [recentExtrinsics, setRecentExtrinsics] = useState<ExtrinsicInfo[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchError, setSearchError] = useState('');
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
|
|
|
// Format address for display
|
|
const formatAddress = (address: string) => {
|
|
if (!address) return '';
|
|
return `${address.slice(0, 6)}...${address.slice(-6)}`;
|
|
};
|
|
|
|
// Copy to clipboard
|
|
const copyToClipboard = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
};
|
|
|
|
// Fetch network stats
|
|
const fetchStats = useCallback(async () => {
|
|
if (!api || !isApiReady) return;
|
|
|
|
try {
|
|
const [header, finalizedHash, validators] = await Promise.all([
|
|
api.rpc.chain.getHeader(),
|
|
api.rpc.chain.getFinalizedHead(),
|
|
api.query.session?.validators?.() || Promise.resolve([]),
|
|
]);
|
|
|
|
const finalizedHeader = await api.rpc.chain.getHeader(finalizedHash);
|
|
|
|
// Era lives on Asset Hub (staking moved from RC to AH)
|
|
let eraNumber = 0;
|
|
if (assetHubApi && isAssetHubReady) {
|
|
try {
|
|
const activeEra = await assetHubApi.query.staking.activeEra();
|
|
if (activeEra && activeEra.isSome) {
|
|
const unwrapped = activeEra.unwrap();
|
|
const json = unwrapped.toJSON() as { index?: number };
|
|
eraNumber = json?.index ?? 0;
|
|
}
|
|
} catch {
|
|
// AH staking query failed — leave era as 0
|
|
}
|
|
}
|
|
|
|
setStats(prev => ({
|
|
...prev,
|
|
bestBlock: header.number.toNumber(),
|
|
finalizedBlock: finalizedHeader.number.toNumber(),
|
|
activeValidators: Array.isArray(validators) ? validators.length : 0,
|
|
era: eraNumber,
|
|
}));
|
|
} catch (error) {
|
|
console.error('Error fetching stats:', error);
|
|
}
|
|
}, [api, isApiReady, assetHubApi, isAssetHubReady]);
|
|
|
|
// Fetch recent blocks
|
|
const fetchRecentBlocks = useCallback(async () => {
|
|
if (!api || !isApiReady) return;
|
|
|
|
try {
|
|
const header = await api.rpc.chain.getHeader();
|
|
const currentBlock = header.number.toNumber();
|
|
const blocks: BlockInfo[] = [];
|
|
|
|
// Fetch last 10 blocks
|
|
for (let i = 0; i < 10 && currentBlock - i > 0; i++) {
|
|
const blockNumber = currentBlock - i;
|
|
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
|
|
const signedBlock = await api.rpc.chain.getBlock(blockHash);
|
|
|
|
// Try to get timestamp from block
|
|
let timestamp = Date.now() - i * 6000; // Fallback: estimate based on 6s blocks
|
|
const timestampExtrinsic = signedBlock.block.extrinsics.find(
|
|
ext => ext.method.section === 'timestamp' && ext.method.method === 'set'
|
|
);
|
|
if (timestampExtrinsic) {
|
|
timestamp = Number(timestampExtrinsic.method.args[0].toString());
|
|
}
|
|
|
|
blocks.push({
|
|
number: blockNumber,
|
|
hash: blockHash.toString(),
|
|
parentHash: signedBlock.block.header.parentHash.toString(),
|
|
stateRoot: signedBlock.block.header.stateRoot.toString(),
|
|
extrinsicsRoot: signedBlock.block.header.extrinsicsRoot.toString(),
|
|
extrinsicsCount: signedBlock.block.extrinsics.length,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
setRecentBlocks(blocks);
|
|
|
|
// Calculate TPS from recent blocks
|
|
if (blocks.length >= 2) {
|
|
const timeDiff = (blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / 1000;
|
|
const totalExts = blocks.reduce((sum, b) => sum + b.extrinsicsCount, 0);
|
|
const tps = timeDiff > 0 ? totalExts / timeDiff : 0;
|
|
setStats(prev => ({ ...prev, tps: Math.round(tps * 100) / 100 }));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching blocks:', error);
|
|
}
|
|
}, [api, isApiReady]);
|
|
|
|
// Fetch recent extrinsics
|
|
const fetchRecentExtrinsics = useCallback(async () => {
|
|
if (!api || !isApiReady) return;
|
|
|
|
try {
|
|
const header = await api.rpc.chain.getHeader();
|
|
const currentBlock = header.number.toNumber();
|
|
const extrinsics: ExtrinsicInfo[] = [];
|
|
|
|
// Fetch extrinsics from last 5 blocks
|
|
for (let i = 0; i < 5 && currentBlock - i > 0 && extrinsics.length < 15; i++) {
|
|
const blockNumber = currentBlock - i;
|
|
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
|
|
const signedBlock = await api.rpc.chain.getBlock(blockHash);
|
|
const apiAt = await api.at(blockHash);
|
|
const allRecords = await apiAt.query.system.events();
|
|
|
|
// Get timestamp
|
|
let timestamp = Date.now() - i * 6000;
|
|
const timestampExtrinsic = signedBlock.block.extrinsics.find(
|
|
ext => ext.method.section === 'timestamp' && ext.method.method === 'set'
|
|
);
|
|
if (timestampExtrinsic) {
|
|
timestamp = Number(timestampExtrinsic.method.args[0].toString());
|
|
}
|
|
|
|
signedBlock.block.extrinsics.forEach((ext, index) => {
|
|
// Skip timestamp and inherent extrinsics for cleaner display
|
|
if (ext.method.section === 'timestamp' || ext.method.section === 'parachainSystem') {
|
|
return;
|
|
}
|
|
|
|
// Check if extrinsic succeeded
|
|
const events = (allRecords as unknown as Array<{ phase: { isApplyExtrinsic: boolean; asApplyExtrinsic: { eq: (n: number) => boolean } }; event: { section: string; method: string } }>)
|
|
.filter(({ phase }) =>
|
|
phase.isApplyExtrinsic && phase.asApplyExtrinsic.eq(index)
|
|
);
|
|
|
|
const success = events.some(({ event }) =>
|
|
event.section === 'system' && event.method === 'ExtrinsicSuccess'
|
|
);
|
|
const failed = events.some(({ event }) =>
|
|
event.section === 'system' && event.method === 'ExtrinsicFailed'
|
|
);
|
|
|
|
extrinsics.push({
|
|
hash: ext.hash.toString(),
|
|
blockNumber,
|
|
index,
|
|
section: ext.method.section,
|
|
method: ext.method.method,
|
|
signer: ext.isSigned ? ext.signer.toString() : undefined,
|
|
success: success || !failed,
|
|
timestamp,
|
|
});
|
|
});
|
|
}
|
|
|
|
setRecentExtrinsics(extrinsics.slice(0, 15));
|
|
setStats(prev => ({ ...prev, totalExtrinsics: extrinsics.length }));
|
|
} catch (error) {
|
|
console.error('Error fetching extrinsics:', error);
|
|
}
|
|
}, [api, isApiReady]);
|
|
|
|
// Search handler
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim() || !api || !isApiReady) return;
|
|
|
|
setIsSearching(true);
|
|
setSearchError('');
|
|
|
|
try {
|
|
const query = searchQuery.trim();
|
|
|
|
// Check if it's a block number
|
|
if (/^\d+$/.test(query)) {
|
|
const blockNumber = parseInt(query);
|
|
const header = await api.rpc.chain.getHeader();
|
|
if (blockNumber <= header.number.toNumber()) {
|
|
navigate(`/explorer/block/${blockNumber}`);
|
|
return;
|
|
} else {
|
|
setSearchError('Block number does not exist yet');
|
|
}
|
|
}
|
|
// Check if it's a hash (block or extrinsic)
|
|
else if (/^0x[a-fA-F0-9]{64}$/.test(query)) {
|
|
// Try as block hash first
|
|
try {
|
|
const block = await api.rpc.chain.getBlock(query);
|
|
if (block) {
|
|
navigate(`/explorer/block/${block.block.header.number.toNumber()}`);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Not a block hash, might be extrinsic hash
|
|
navigate(`/explorer/tx/${query}`);
|
|
return;
|
|
}
|
|
}
|
|
// Check if it's an address
|
|
else if (query.length >= 47 && query.length <= 48) {
|
|
navigate(`/explorer/account/${query}`);
|
|
return;
|
|
}
|
|
else {
|
|
setSearchError('Invalid search query. Enter a block number, hash, or address.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
setSearchError('Search failed. Please try again.');
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
// Subscribe to new blocks
|
|
useEffect(() => {
|
|
if (!api || !isApiReady) return;
|
|
|
|
let unsubscribe: (() => void) | undefined;
|
|
|
|
const subscribe = async () => {
|
|
unsubscribe = await api.rpc.chain.subscribeNewHeads(() => {
|
|
fetchStats();
|
|
fetchRecentBlocks();
|
|
fetchRecentExtrinsics();
|
|
setLastUpdate(new Date());
|
|
});
|
|
};
|
|
|
|
subscribe();
|
|
|
|
return () => {
|
|
if (unsubscribe) unsubscribe();
|
|
};
|
|
}, [api, isApiReady, fetchStats, fetchRecentBlocks, fetchRecentExtrinsics]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
setIsLoading(true);
|
|
await Promise.all([
|
|
fetchStats(),
|
|
fetchRecentBlocks(),
|
|
fetchRecentExtrinsics(),
|
|
]);
|
|
setIsLoading(false);
|
|
};
|
|
|
|
if (isApiReady) {
|
|
loadData();
|
|
}
|
|
}, [isApiReady, fetchStats, fetchRecentBlocks, fetchRecentExtrinsics]);
|
|
|
|
// Refresh handler
|
|
const handleRefresh = () => {
|
|
fetchStats();
|
|
fetchRecentBlocks();
|
|
fetchRecentExtrinsics();
|
|
setLastUpdate(new Date());
|
|
};
|
|
|
|
if (!isApiReady) {
|
|
return (
|
|
<Layout>
|
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<Loader2 className="w-12 h-12 text-green-500 animate-spin mx-auto mb-4" />
|
|
<h2 className="text-xl font-semibold text-white mb-2">{t('networkStats.connecting')}</h2>
|
|
<p className="text-gray-400">{t('networkStats.disconnectedDesc')}</p>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="min-h-screen bg-gray-950">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-8 gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
|
<Blocks className="w-8 h-8 text-green-500" />
|
|
{t('explorer.title')}
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className="flex items-center gap-1 text-green-400 text-sm">
|
|
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
|
|
{t('explorer.live')}
|
|
</span>
|
|
<span className="text-gray-500 text-sm">
|
|
Last updated {formatDistanceToNow(lastUpdate, { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleRefresh}
|
|
variant="outline"
|
|
className="border-gray-700 text-gray-300 hover:text-white"
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
{t('explorer.refresh')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* View Navigation */}
|
|
<div className="flex gap-2 mb-6 flex-wrap">
|
|
<Button
|
|
variant={currentView === 'overview' ? 'default' : 'outline'}
|
|
onClick={() => navigate('/explorer')}
|
|
className={currentView === 'overview' ? 'bg-green-600' : 'border-gray-700'}
|
|
size="sm"
|
|
>
|
|
<Blocks className="w-4 h-4 mr-2" />
|
|
{t('explorer.overviewTab')}
|
|
</Button>
|
|
<Button
|
|
variant={currentView === 'accounts' ? 'default' : 'outline'}
|
|
onClick={() => navigate('/explorer/accounts')}
|
|
className={currentView === 'accounts' ? 'bg-green-600' : 'border-gray-700'}
|
|
size="sm"
|
|
>
|
|
<Users className="w-4 h-4 mr-2" />
|
|
{t('explorer.accountsTab')}
|
|
</Button>
|
|
<Button
|
|
variant={currentView === 'assets' ? 'default' : 'outline'}
|
|
onClick={() => navigate('/explorer/assets')}
|
|
className={currentView === 'assets' ? 'bg-green-600' : 'border-gray-700'}
|
|
size="sm"
|
|
>
|
|
<Wallet className="w-4 h-4 mr-2" />
|
|
{t('explorer.assetsTab')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<Card className="bg-gray-900 border-gray-800 mb-8">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
<Input
|
|
type="text"
|
|
placeholder={t('explorer.searchPlaceholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
setSearchError('');
|
|
}}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
className="pl-10 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={handleSearch}
|
|
disabled={isSearching || !searchQuery.trim()}
|
|
className="bg-gradient-to-r from-green-600 to-yellow-500 hover:from-green-700 hover:to-yellow-600"
|
|
>
|
|
{isSearching ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Search className="w-4 h-4 mr-2" />
|
|
{t('explorer.search')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{searchError && (
|
|
<div className="flex items-center gap-2 mt-3 text-red-400 text-sm">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{searchError}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* View-specific content */}
|
|
{currentView === 'accounts' && (
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardHeader>
|
|
<CardTitle className="text-white flex items-center gap-2">
|
|
<Users className="w-5 h-5 text-blue-500" />
|
|
{t('explorer.accountsTab')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-gray-400 mb-4">
|
|
{t('explorer.noAccountsFound')}
|
|
</p>
|
|
<div className="bg-gray-800 rounded-lg p-6 text-center">
|
|
<Users className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
|
<p className="text-gray-500">{t('explorer.search')}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{currentView === 'assets' && (
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardHeader>
|
|
<CardTitle className="text-white flex items-center gap-2">
|
|
<Wallet className="w-5 h-5 text-yellow-500" />
|
|
{t('explorer.assetsTab')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4">
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-white font-semibold">HEZ</span>
|
|
<Badge className="bg-green-500/20 text-green-400">{t('explorer.native')}</Badge>
|
|
</div>
|
|
<p className="text-gray-400 text-sm">Native token of PezkuwiChain</p>
|
|
</div>
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-white font-semibold">PEZ</span>
|
|
<Badge className="bg-purple-500/20 text-purple-400">Asset ID: 1</Badge>
|
|
</div>
|
|
<p className="text-gray-400 text-sm">Pezkuwi governance token</p>
|
|
</div>
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-white font-semibold">wHEZ</span>
|
|
<Badge className="bg-cyan-500/20 text-cyan-400">Asset ID: 0</Badge>
|
|
</div>
|
|
<p className="text-gray-400 text-sm">Wrapped HEZ token</p>
|
|
</div>
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-white font-semibold">wUSDT</span>
|
|
<Badge className="bg-blue-500/20 text-blue-400">Asset ID: 2</Badge>
|
|
</div>
|
|
<p className="text-gray-400 text-sm">Wrapped USDT stablecoin (6 decimals)</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{currentView === 'account' && viewParam && (
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardHeader>
|
|
<CardTitle className="text-white flex items-center gap-2">
|
|
<Wallet className="w-5 h-5 text-purple-500" />
|
|
{t('explorer.accountsTab')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="bg-gray-800 rounded-lg p-4 mb-4">
|
|
<div className="text-xs text-gray-400 mb-1">{t('explorer.address')}</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="text-white font-mono text-sm break-all">{viewParam}</code>
|
|
<button
|
|
onClick={() => copyToClipboard(viewParam)}
|
|
className="text-gray-400 hover:text-white"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-gray-400 text-center py-8">
|
|
{t('explorer.balance')}...
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Stats Grid - Only show for overview */}
|
|
{currentView === 'overview' && (
|
|
<>
|
|
{isLoading ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
{[...Array(8)].map((_, i) => (
|
|
<Card key={i} className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="h-4 bg-gray-800 rounded animate-pulse mb-2 w-1/2"></div>
|
|
<div className="h-8 bg-gray-800 rounded animate-pulse w-3/4"></div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<Database className="w-4 h-4" />
|
|
{t('explorer.bestBlock')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
#{stats.bestBlock.toLocaleString()}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
|
{t('explorer.finalized')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
#{stats.finalizedBlock.toLocaleString()}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<Users className="w-4 h-4 text-blue-500" />
|
|
{t('explorer.validators')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
{stats.activeValidators}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<Activity className="w-4 h-4 text-purple-500" />
|
|
{t('explorer.era')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
{stats.era}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<Timer className="w-4 h-4 text-yellow-500" />
|
|
{t('explorer.blockTime')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
~{stats.avgBlockTime}s
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<Zap className="w-4 h-4 text-orange-500" />
|
|
{t('explorer.tps')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
{stats.tps.toFixed(2)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gray-900 border-gray-800 col-span-2">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
|
|
<ArrowRightLeft className="w-4 h-4 text-cyan-500" />
|
|
{t('explorer.recentExtrinsics')}
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">
|
|
{recentExtrinsics.length} in last {recentBlocks.length} blocks
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Blocks and Extrinsics Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* Recent Blocks */}
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg text-white flex items-center gap-2">
|
|
<Blocks className="w-5 h-5 text-green-500" />
|
|
{t('explorer.recentBlocks')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{isLoading ? (
|
|
[...Array(5)].map((_, i) => (
|
|
<div key={i} className="bg-gray-800 rounded-lg p-4 animate-pulse">
|
|
<div className="h-4 bg-gray-700 rounded w-1/3 mb-2"></div>
|
|
<div className="h-3 bg-gray-700 rounded w-1/2"></div>
|
|
</div>
|
|
))
|
|
) : recentBlocks.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
{t('explorer.noBlocksFound')}
|
|
</div>
|
|
) : (
|
|
recentBlocks.slice(0, 8).map((block) => (
|
|
<div
|
|
key={block.number}
|
|
className="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors cursor-pointer group"
|
|
onClick={() => navigate(`/explorer/block/${block.number}`)}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge className="bg-green-500/20 text-green-400 border-green-500/50">
|
|
#{block.number.toLocaleString()}
|
|
</Badge>
|
|
<span className="text-gray-400 text-sm">
|
|
{formatDistanceToNow(new Date(block.timestamp), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-green-400 transition-colors" />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Hash className="w-3 h-3 text-gray-500" />
|
|
<span className="font-mono text-gray-400">
|
|
{formatAddress(block.hash)}
|
|
</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
copyToClipboard(block.hash);
|
|
}}
|
|
className="text-gray-500 hover:text-white"
|
|
>
|
|
<Copy className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<span className="text-sm">
|
|
<span className="text-gray-500">{t('explorer.recentExtrinsics')}:</span>{' '}
|
|
<span className="text-green-400 font-semibold">{block.extrinsicsCount}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent Extrinsics */}
|
|
<Card className="bg-gray-900 border-gray-800">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg text-white flex items-center gap-2">
|
|
<ArrowRightLeft className="w-5 h-5 text-purple-500" />
|
|
{t('explorer.recentExtrinsics')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{isLoading ? (
|
|
[...Array(5)].map((_, i) => (
|
|
<div key={i} className="bg-gray-800 rounded-lg p-4 animate-pulse">
|
|
<div className="h-4 bg-gray-700 rounded w-1/3 mb-2"></div>
|
|
<div className="h-3 bg-gray-700 rounded w-1/2"></div>
|
|
</div>
|
|
))
|
|
) : recentExtrinsics.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
{t('explorer.noExtrinsicsFound')}
|
|
</div>
|
|
) : (
|
|
recentExtrinsics.slice(0, 8).map((ext, idx) => (
|
|
<div
|
|
key={`${ext.hash}-${idx}`}
|
|
className="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors cursor-pointer group"
|
|
onClick={() => navigate(`/explorer/tx/${ext.hash}`)}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
{ext.success ? (
|
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-red-500" />
|
|
)}
|
|
<Badge
|
|
variant="outline"
|
|
className={`${
|
|
ext.success
|
|
? 'border-green-500/50 text-green-400'
|
|
: 'border-red-500/50 text-red-400'
|
|
}`}
|
|
>
|
|
{ext.section}.{ext.method}
|
|
</Badge>
|
|
</div>
|
|
<span className="text-gray-400 text-sm">
|
|
{t('explorer.block')} #{ext.blockNumber.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Hash className="w-3 h-3 text-gray-500" />
|
|
<span className="font-mono text-purple-400">
|
|
{formatAddress(ext.hash)}
|
|
</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
copyToClipboard(ext.hash);
|
|
}}
|
|
className="text-gray-500 hover:text-white"
|
|
>
|
|
<Copy className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
{ext.signer && (
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Wallet className="w-3 h-3 text-gray-500" />
|
|
<span className="font-mono text-gray-400">
|
|
{formatAddress(ext.signer)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Quick Links */}
|
|
<Card className="bg-gray-900 border-gray-800 mt-8">
|
|
<CardContent className="p-6">
|
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
<ExternalLink className="w-5 h-5 text-green-500" />
|
|
{t('explorer.quickLinks')}
|
|
</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<a
|
|
href="https://telemetry.pezkuwichain.io"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 p-3 rounded-lg bg-gray-800 hover:bg-gray-750 text-gray-300 hover:text-white transition-colors"
|
|
>
|
|
<Activity className="w-4 h-4 text-red-400" />
|
|
{t('explorer.telemetry')}
|
|
</a>
|
|
<a
|
|
href="/governance"
|
|
className="flex items-center gap-2 p-3 rounded-lg bg-gray-800 hover:bg-gray-750 text-gray-300 hover:text-white transition-colors"
|
|
>
|
|
<Users className="w-4 h-4 text-blue-400" />
|
|
{t('explorer.governance')}
|
|
</a>
|
|
<a
|
|
href="/wallet"
|
|
className="flex items-center gap-2 p-3 rounded-lg bg-gray-800 hover:bg-gray-750 text-gray-300 hover:text-white transition-colors"
|
|
>
|
|
<Wallet className="w-4 h-4 text-yellow-400" />
|
|
{t('explorer.wallet')}
|
|
</a>
|
|
<a
|
|
href="/docs"
|
|
className="flex items-center gap-2 p-3 rounded-lg bg-gray-800 hover:bg-gray-750 text-gray-300 hover:text-white transition-colors"
|
|
>
|
|
<Blocks className="w-4 h-4 text-purple-400" />
|
|
{t('explorer.documentation')}
|
|
</a>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Explorer;
|