A complete blockchain implementation featuring: - synord: Full node with GHOSTDAG consensus - explorer-web: Modern React blockchain explorer with 3D DAG visualization - CLI wallet and tools - Smart contract SDK and example contracts (DEX, NFT, token) - WASM crypto library for browser/mobile
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
/**
|
|
* Enhanced Address page with balance flow visualization and UTXO filtering.
|
|
*/
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import {
|
|
Wallet,
|
|
Coins,
|
|
Box,
|
|
ArrowDownLeft,
|
|
ArrowUpRight,
|
|
Filter,
|
|
TrendingUp,
|
|
Gift,
|
|
} from 'lucide-react';
|
|
import { useAddress, useAddressUtxos } from '../hooks/useApi';
|
|
import CopyButton from '../components/CopyButton';
|
|
import { formatSynor, truncateHash, cn } from '../lib/utils';
|
|
|
|
type UtxoFilter = 'all' | 'coinbase' | 'regular';
|
|
|
|
export default function Address() {
|
|
const { address } = useParams<{ address: string }>();
|
|
const { data: info, isLoading: infoLoading, error: infoError } = useAddress(address || '');
|
|
const { data: utxos, isLoading: utxosLoading } = useAddressUtxos(address || '');
|
|
const [utxoFilter, setUtxoFilter] = useState<UtxoFilter>('all');
|
|
|
|
// Filter UTXOs based on selection
|
|
const filteredUtxos = useMemo(() => {
|
|
if (!utxos) return [];
|
|
switch (utxoFilter) {
|
|
case 'coinbase':
|
|
return utxos.filter((u) => u.utxoEntry.isCoinbase);
|
|
case 'regular':
|
|
return utxos.filter((u) => !u.utxoEntry.isCoinbase);
|
|
default:
|
|
return utxos;
|
|
}
|
|
}, [utxos, utxoFilter]);
|
|
|
|
// Calculate coinbase count for filter badge
|
|
const coinbaseCount = useMemo(() => {
|
|
if (!utxos) return 0;
|
|
return utxos.filter((u) => u.utxoEntry.isCoinbase).length;
|
|
}, [utxos]);
|
|
|
|
if (!address) {
|
|
return <div className="card p-6 text-red-400">Address is required</div>;
|
|
}
|
|
|
|
if (infoLoading) {
|
|
return <AddressSkeleton />;
|
|
}
|
|
|
|
if (infoError) {
|
|
return (
|
|
<div className="card p-6 text-red-400">
|
|
Error loading address: {infoError.message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!info) {
|
|
return <div className="card p-6 text-gray-400">Address not found</div>;
|
|
}
|
|
|
|
// Calculate percentages for balance flow
|
|
const totalFlow = info.totalReceived + info.totalSent;
|
|
const receivedPercent = totalFlow > 0 ? (info.totalReceived / totalFlow) * 100 : 50;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Modern Header */}
|
|
<div className="relative">
|
|
{/* Background glow */}
|
|
<div className="absolute -top-10 left-0 w-[300px] h-[150px] bg-synor-500/10 rounded-full blur-[80px] pointer-events-none" />
|
|
|
|
<div className="relative flex flex-col md:flex-row md:items-center gap-4">
|
|
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
<div className="p-3 rounded-xl bg-gradient-to-br from-synor-500/20 to-violet-500/20 border border-synor-500/30">
|
|
<Wallet size={28} className="text-synor-400" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-2xl md:text-3xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
|
Address
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="hash text-sm text-gray-400 truncate max-w-[300px] md:max-w-none">
|
|
{info.address}
|
|
</span>
|
|
<CopyButton text={info.address} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Balance Overview Card */}
|
|
<div className="card overflow-hidden">
|
|
<div className="p-6">
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{/* Current Balance */}
|
|
<div className="sm:col-span-2 lg:col-span-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Coins size={16} className="text-synor-400" />
|
|
<span className="text-sm text-gray-400">Balance</span>
|
|
</div>
|
|
<p className="text-2xl lg:text-3xl font-bold text-synor-400 font-mono">
|
|
{info.balanceHuman}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Total Received */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<ArrowDownLeft size={16} className="text-green-400" />
|
|
<span className="text-sm text-gray-400">Total Received</span>
|
|
</div>
|
|
<p className="text-xl font-bold text-green-400 font-mono">
|
|
{formatSynor(info.totalReceived)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Total Sent */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<ArrowUpRight size={16} className="text-red-400" />
|
|
<span className="text-sm text-gray-400">Total Sent</span>
|
|
</div>
|
|
<p className="text-xl font-bold text-red-400 font-mono">
|
|
{formatSynor(info.totalSent)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Transaction Count */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<TrendingUp size={16} className="text-blue-400" />
|
|
<span className="text-sm text-gray-400">Transactions</span>
|
|
</div>
|
|
<p className="text-xl font-bold text-blue-400">
|
|
{info.transactionCount.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Balance Flow Visualization */}
|
|
<div className="border-t border-gray-800 px-6 py-4 bg-gray-900/30">
|
|
<div className="flex items-center justify-between text-sm mb-2">
|
|
<span className="text-gray-400">Balance Flow</span>
|
|
<span className="text-gray-500">
|
|
{info.utxoCount} UTXO{info.utxoCount !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
<div className="h-3 bg-gray-800 rounded-full overflow-hidden flex">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-green-500 to-green-400 transition-all duration-500"
|
|
style={{ width: `${receivedPercent}%` }}
|
|
title={`Received: ${formatSynor(info.totalReceived)}`}
|
|
/>
|
|
<div
|
|
className="h-full bg-gradient-to-r from-red-400 to-red-500 transition-all duration-500"
|
|
style={{ width: `${100 - receivedPercent}%` }}
|
|
title={`Sent: ${formatSynor(info.totalSent)}`}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
|
<span className="text-green-400">
|
|
In: {((info.totalReceived / totalFlow) * 100).toFixed(1)}%
|
|
</span>
|
|
<span className="text-red-400">
|
|
Out: {((info.totalSent / totalFlow) * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* UTXOs with Filtering */}
|
|
<div className="card">
|
|
<div className="card-header flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Coins size={18} className="text-synor-400" />
|
|
<h2 className="font-semibold">
|
|
UTXOs {utxos && `(${filteredUtxos.length}${utxoFilter !== 'all' ? ` of ${utxos.length}` : ''})`}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Filter Buttons */}
|
|
<div className="flex items-center gap-1 p-1 bg-gray-800/50 rounded-lg" role="group" aria-label="Filter UTXOs">
|
|
<FilterButton
|
|
active={utxoFilter === 'all'}
|
|
onClick={() => setUtxoFilter('all')}
|
|
label="All"
|
|
icon={<Filter size={14} />}
|
|
/>
|
|
<FilterButton
|
|
active={utxoFilter === 'coinbase'}
|
|
onClick={() => setUtxoFilter('coinbase')}
|
|
label="Coinbase"
|
|
icon={<Gift size={14} />}
|
|
badge={coinbaseCount > 0 ? coinbaseCount : undefined}
|
|
/>
|
|
<FilterButton
|
|
active={utxoFilter === 'regular'}
|
|
onClick={() => setUtxoFilter('regular')}
|
|
label="Regular"
|
|
icon={<Coins size={14} />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{utxosLoading ? (
|
|
<div className="p-4 text-center text-gray-500">Loading UTXOs...</div>
|
|
) : filteredUtxos.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="text-left text-sm text-gray-400 border-b border-gray-800">
|
|
<th className="px-4 py-3 font-medium">Transaction</th>
|
|
<th className="px-4 py-3 font-medium">Index</th>
|
|
<th className="px-4 py-3 font-medium text-right">Amount</th>
|
|
<th className="px-4 py-3 font-medium hidden sm:table-cell">Type</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredUtxos.map((utxo, i) => (
|
|
<tr key={i} className="table-row hover:bg-gray-800/30 transition-colors">
|
|
<td className="px-4 py-3">
|
|
<Link
|
|
to={`/tx/${utxo.outpoint.transactionId}`}
|
|
className="hash text-sm text-synor-400 hover:text-synor-300"
|
|
>
|
|
{truncateHash(utxo.outpoint.transactionId)}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400">
|
|
{utxo.outpoint.index}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<span className="font-mono text-sm text-green-400">
|
|
{formatSynor(utxo.utxoEntry.amount, 4)}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 hidden sm:table-cell">
|
|
<div className="flex items-center gap-2">
|
|
{utxo.utxoEntry.isCoinbase ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-amber-500/20 text-amber-400 rounded-full border border-amber-500/30">
|
|
<Gift size={10} />
|
|
Coinbase
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-gray-500">Regular</span>
|
|
)}
|
|
<span className="text-xs text-gray-600">
|
|
v{utxo.utxoEntry.scriptPublicKey.version}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="p-8 text-center text-gray-500">
|
|
<Box size={48} className="mx-auto mb-4 opacity-50" />
|
|
<p>
|
|
{utxoFilter === 'all'
|
|
? 'No UTXOs found for this address'
|
|
: `No ${utxoFilter} UTXOs found`}
|
|
</p>
|
|
{utxoFilter !== 'all' && utxos && utxos.length > 0 && (
|
|
<button
|
|
onClick={() => setUtxoFilter('all')}
|
|
className="mt-2 text-sm text-synor-400 hover:text-synor-300"
|
|
>
|
|
Show all UTXOs
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterButton({
|
|
active,
|
|
onClick,
|
|
label,
|
|
icon,
|
|
badge,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
badge?: number;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
|
active
|
|
? 'bg-synor-600 text-white'
|
|
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
|
|
)}
|
|
aria-pressed={active}
|
|
>
|
|
{icon}
|
|
<span className="hidden sm:inline">{label}</span>
|
|
{badge !== undefined && (
|
|
<span className={cn(
|
|
'px-1.5 py-0.5 text-xs rounded-full',
|
|
active ? 'bg-white/20' : 'bg-gray-700'
|
|
)}>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function AddressSkeleton() {
|
|
return (
|
|
<div className="space-y-6 animate-pulse">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-14 h-14 rounded-xl bg-gray-800" />
|
|
<div>
|
|
<div className="h-8 w-32 bg-gray-800 rounded mb-2" />
|
|
<div className="h-4 w-64 bg-gray-800 rounded" />
|
|
</div>
|
|
</div>
|
|
<div className="card p-6">
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i}>
|
|
<div className="h-4 w-20 bg-gray-800 rounded mb-2" />
|
|
<div className="h-8 w-32 bg-gray-800 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-6 pt-4 border-t border-gray-800">
|
|
<div className="h-3 bg-gray-800 rounded-full" />
|
|
</div>
|
|
</div>
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<div className="h-5 w-32 bg-gray-800 rounded" />
|
|
</div>
|
|
<div className="p-4">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-4 py-3">
|
|
<div className="h-4 w-32 bg-gray-800 rounded" />
|
|
<div className="h-4 w-12 bg-gray-800 rounded" />
|
|
<div className="flex-1" />
|
|
<div className="h-4 w-24 bg-gray-800 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|