UX polish: hamburger menu, copy buttons, skeletons, toasts, dark/light theme

- Add mobile hamburger menu with animated toggle (Header.js)
- Add CopyButton component with clipboard API + toast notifications
- Replace all loading spinners with skeleton loading screens
- Add custom Toast notification system (no new dependencies)
- Add dark/light theme toggle with localStorage persistence
- Use CSS variables for theme-aware colors across all components
- Build compiles successfully with zero warnings

New files: CopyButton.js, Skeleton.js, Toast.js, ThemeContext.js
Modified: All pages and components updated for UX improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 19:40:30 -07:00
parent b74440bfd5
commit e02c03e83d
17 changed files with 766 additions and 321 deletions
+70 -4
View File
@@ -2,6 +2,31 @@
@tailwind components;
@tailwind utilities;
/* Theme CSS Variables */
:root {
--bg-primary: #0f172a;
--bg-card: #1e293b;
--bg-secondary: #1f2937;
--bg-tertiary: #374151;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--border-color: #374151;
--border-light: #4b5563;
}
.light {
--bg-primary: #f8fafc;
--bg-card: #ffffff;
--bg-secondary: #f1f5f9;
--bg-tertiary: #e2e8f0;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--border-light: #cbd5e1;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
@@ -9,6 +34,8 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
}
code {
@@ -17,7 +44,9 @@ code {
}
.card {
@apply bg-maza-gray rounded-lg shadow-lg p-6 mb-4;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
@apply rounded-lg shadow-lg p-6 mb-4;
}
.btn {
@@ -29,15 +58,22 @@ code {
}
.btn-secondary {
@apply bg-gray-600 text-white hover:bg-gray-700;
background-color: var(--bg-tertiary);
color: var(--text-primary);
@apply hover:opacity-80;
}
.input {
@apply w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-gray-100 focus:outline-none focus:border-maza-blue;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
@apply w-full px-4 py-3 rounded-lg focus:outline-none focus:border-maza-blue;
}
.stat-card {
@apply bg-gradient-to-br from-maza-gray to-gray-800 rounded-lg p-4 shadow-lg;
background: linear-gradient(to bottom right, var(--bg-card), var(--bg-secondary));
border: 1px solid var(--border-color);
@apply rounded-lg p-4 shadow-lg;
}
.hash {
@@ -51,3 +87,33 @@ code {
.error {
@apply bg-red-900/30 border border-red-700 text-red-300 px-4 py-3 rounded-lg;
}
.light .error {
@apply bg-red-50 border-red-300 text-red-700;
}
/* Theme-aware utility classes */
.bg-theme-primary { background-color: var(--bg-primary); }
.bg-theme-card { background-color: var(--bg-card); }
.bg-theme-secondary { background-color: var(--bg-secondary); }
.bg-theme-tertiary { background-color: var(--bg-tertiary); }
.text-theme-primary { color: var(--text-primary); }
.text-theme-secondary { color: var(--text-secondary); }
.text-theme-muted { color: var(--text-muted); }
.border-theme { border-color: var(--border-color); }
/* Toast animation */
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
+23 -17
View File
@@ -1,5 +1,7 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './components/Toast';
import Header from './components/Header';
import Footer from './components/Footer';
import Home from './pages/Home';
@@ -13,23 +15,27 @@ import './App.css';
function App() {
return (
<Router>
<div className="min-h-screen bg-maza-dark text-gray-100 flex flex-col">
<Header />
<main className="flex-grow container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/block/:hashOrHeight" element={<BlockDetail />} />
<Route path="/tx/:txid" element={<TransactionDetail />} />
<Route path="/address/:address" element={<AddressDetail />} />
<Route path="/stats" element={<NetworkStats />} />
<Route path="/nodes" element={<NodeMap />} />
<Route path="/richlist" element={<RichList />} />
</Routes>
</main>
<Footer />
</div>
</Router>
<ThemeProvider>
<ToastProvider>
<Router>
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}>
<Header />
<main className="flex-grow container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/block/:hashOrHeight" element={<BlockDetail />} />
<Route path="/tx/:txid" element={<TransactionDetail />} />
<Route path="/address/:address" element={<AddressDetail />} />
<Route path="/stats" element={<NetworkStats />} />
<Route path="/nodes" element={<NodeMap />} />
<Route path="/richlist" element={<RichList />} />
</Routes>
</main>
<Footer />
</div>
</Router>
</ToastProvider>
</ThemeProvider>
);
}
+37
View File
@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { useToast } from './Toast';
function CopyButton({ text, className = '' }) {
const [copied, setCopied] = useState(false);
const toast = useToast();
const handleCopy = async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast('Copied to clipboard!', 'success');
setTimeout(() => setCopied(false), 2000);
} catch {
toast('Failed to copy', 'error');
}
};
return (
<button
onClick={handleCopy}
className={`inline-flex items-center justify-center p-1.5 rounded hover:bg-gray-600 transition-colors ${className}`}
title="Copy to clipboard"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-gray-400 hover:text-gray-200" />
)}
</button>
);
}
export default CopyButton;
+6 -6
View File
@@ -2,9 +2,9 @@ import React from 'react';
function Footer() {
return (
<footer className="bg-maza-gray border-t border-gray-700 py-6 mt-12">
<footer className="py-6 mt-12" style={{ backgroundColor: 'var(--bg-card)', borderTop: '1px solid var(--border-color)' }}>
<div className="container mx-auto px-4">
<div className="text-center text-gray-400 mb-4">
<div className="text-center mb-4" style={{ color: 'var(--text-secondary)' }}>
<p>Mazacoin Explorer &copy; 2026 | Built for the Mazacoin community</p>
<p className="text-sm mt-2">
<a href="https://mazacoin.org" target="_blank" rel="noopener noreferrer" className="hover:text-maza-blue">
@@ -12,11 +12,11 @@ function Footer() {
</a>
</p>
</div>
<div className="flex items-center justify-center gap-3 text-gray-400 text-sm">
<div className="flex items-center justify-center gap-3 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>Provided by</span>
<img
src="/samiahmed77777-logo.png"
alt="samiahmed77777"
<img
src="/samiahmed77777-logo.png"
alt="samiahmed77777"
className="h-8"
/>
</div>
+81 -23
View File
@@ -1,35 +1,93 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Activity, MapPin, TrendingUp } from 'lucide-react';
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Activity, MapPin, TrendingUp, Menu, X, Sun, Moon } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
function Header() {
const [menuOpen, setMenuOpen] = useState(false);
const { theme, toggleTheme } = useTheme();
const location = useLocation();
const closeMenu = () => setMenuOpen(false);
const navLinks = [
{ to: '/', label: 'Home', icon: null },
{ to: '/stats', label: 'Network Stats', icon: Activity },
{ to: '/nodes', label: 'Node Map', icon: MapPin },
{ to: '/richlist', label: 'Rich List', icon: TrendingUp },
];
const isActive = (path) => location.pathname === path;
return (
<header className="bg-maza-gray shadow-lg border-b border-gray-700">
<header className="shadow-lg border-b" style={{ backgroundColor: 'var(--bg-card)', borderColor: 'var(--border-color)' }}>
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center space-x-3">
<Link to="/" className="flex items-center space-x-3" onClick={closeMenu}>
<img src="/maza-logo.png" alt="Mazacoin" className="w-8 h-8" />
<span className="text-2xl font-bold">Mazacoin Explorer</span>
</Link>
<nav className="flex items-center space-x-6">
<Link to="/" className="hover:text-maza-blue transition-colors">
Home
</Link>
<Link to="/stats" className="hover:text-maza-blue transition-colors flex items-center gap-2">
<Activity className="w-4 h-4" />
Network Stats
</Link>
<Link to="/nodes" className="hover:text-maza-blue transition-colors flex items-center gap-2">
<MapPin className="w-4 h-4" />
Node Map
</Link>
<Link to="/richlist" className="hover:text-maza-blue transition-colors flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Rich List
</Link>
</nav>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-6">
<nav className="flex items-center space-x-6">
{navLinks.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
className={`hover:text-maza-blue transition-colors flex items-center gap-2 ${isActive(to) ? 'text-maza-blue' : ''}`}
>
{Icon && <Icon className="w-4 h-4" />}
{label}
</Link>
))}
</nav>
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:opacity-80 transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-400" /> : <Moon className="w-5 h-5 text-blue-600" />}
</button>
</div>
{/* Mobile controls */}
<div className="flex items-center gap-2 md:hidden">
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:opacity-80 transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-400" /> : <Moon className="w-5 h-5 text-blue-600" />}
</button>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 rounded-lg hover:opacity-80 transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
{menuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
</div>
{/* Mobile menu */}
{menuOpen && (
<nav className="md:hidden mt-4 pt-4 space-y-2" style={{ borderTop: '1px solid var(--border-color)' }}>
{navLinks.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
onClick={closeMenu}
className={`flex items-center gap-3 px-3 py-3 rounded-lg transition-colors hover:opacity-80 ${isActive(to) ? 'text-maza-blue' : ''}`}
style={{ backgroundColor: isActive(to) ? 'var(--bg-secondary)' : 'transparent' }}
>
{Icon && <Icon className="w-5 h-5" />}
<span className="font-medium">{label}</span>
</Link>
))}
</nav>
)}
</div>
</header>
);
+24 -22
View File
@@ -3,6 +3,8 @@ import { Line } from 'react-chartjs-2';
import { Chart as ChartJS, CategoryScale, LinearScale, LogarithmicScale, PointElement, LineElement, Title, Tooltip, Legend, TimeScale } from 'chart.js';
import 'chartjs-adapter-date-fns';
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext';
import { SkeletonChart } from './Skeleton';
ChartJS.register(CategoryScale, LinearScale, LogarithmicScale, PointElement, LineElement, Title, Tooltip, Legend, TimeScale);
@@ -13,6 +15,7 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
const [loading, setLoading] = useState(true);
const [activeTimeframe, setActiveTimeframe] = useState(timeframe);
const [activeCurrency, setActiveCurrency] = useState(currency);
const { theme } = useTheme();
useEffect(() => {
fetchChartData();
@@ -56,6 +59,9 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
}
};
const gridColor = theme === 'light' ? '#e2e8f0' : '#1f2937';
const tickColor = theme === 'light' ? '#64748b' : '#9ca3af';
const options = {
responsive: true,
maintainAspectRatio: false,
@@ -63,7 +69,7 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
legend: {
display: true,
labels: {
color: '#9ca3af'
color: tickColor
}
},
title: {
@@ -99,19 +105,19 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
}
},
grid: {
color: '#1f2937'
color: gridColor
},
ticks: {
color: '#9ca3af'
color: tickColor
}
},
y: {
type: 'logarithmic',
grid: {
color: '#1f2937'
color: gridColor
},
ticks: {
color: '#9ca3af',
color: tickColor,
callback: function(value) {
return value.toFixed(activeCurrency === 'eth' ? 10 : 8);
}
@@ -140,28 +146,22 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
];
if (loading) {
return (
<div className="card">
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
</div>
</div>
);
return <SkeletonChart />;
}
if (!chartData) {
return (
<div className="card">
<p className="text-gray-400 text-center py-8">Building price history... Check back soon!</p>
<p className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>Building price history... Check back soon!</p>
</div>
);
}
return (
<div className="card">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-4">
<h2 className="text-2xl font-bold">Price Chart</h2>
<div className="flex gap-4">
<div className="flex gap-4 flex-wrap">
{/* Currency selector */}
<div className="flex gap-2">
{currencies.map(curr => (
@@ -170,15 +170,16 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
onClick={() => setActiveCurrency(curr.value)}
className={`px-3 py-1 rounded-lg font-medium transition-colors ${
activeCurrency === curr.value
? `${curr.color} bg-gray-700`
: 'text-gray-400 hover:text-gray-300'
? `${curr.color} bg-theme-secondary`
: 'hover:opacity-80'
}`}
style={activeCurrency !== curr.value ? { color: 'var(--text-secondary)' } : undefined}
>
{curr.label}
</button>
))}
</div>
{/* Timeframe selector */}
<div className="flex gap-2">
{timeframes.map(tf => (
@@ -188,8 +189,9 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
className={`px-3 py-1 rounded-lg font-medium transition-colors ${
activeTimeframe === tf.value
? 'bg-maza-blue text-white'
: 'text-gray-400 hover:text-gray-300'
: 'hover:opacity-80'
}`}
style={activeTimeframe !== tf.value ? { color: 'var(--text-secondary)' } : undefined}
>
{tf.label}
</button>
@@ -197,12 +199,12 @@ function PriceChart({ currency = 'btc', timeframe = '30d' }) {
</div>
</div>
</div>
<div className="h-96">
<Line data={chartData} options={options} />
</div>
<div className="mt-4 text-sm text-gray-500 text-center">
<div className="mt-4 text-sm text-center" style={{ color: 'var(--text-muted)' }}>
Historical data from FreiExchange + CoinGecko. Data collection started March 2026.
</div>
</div>
+3 -2
View File
@@ -49,7 +49,8 @@ function SearchBar() {
/>
<button
type="submit"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-maza-blue transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 hover:text-maza-blue transition-colors"
style={{ color: 'var(--text-secondary)' }}
disabled={loading}
>
{loading ? (
@@ -59,7 +60,7 @@ function SearchBar() {
)}
</button>
</form>
{error && (
<div className="error mt-4">
{error}
+96
View File
@@ -0,0 +1,96 @@
import React from 'react';
export function SkeletonLine({ width = 'w-full', height = 'h-4', className = '' }) {
return (
<div className={`animate-pulse bg-gray-700 rounded ${width} ${height} ${className}`} />
);
}
export function SkeletonCard() {
return (
<div className="stat-card animate-pulse">
<div className="bg-gray-700 rounded h-3 w-24 mb-2" />
<div className="bg-gray-600 rounded h-8 w-32" />
</div>
);
}
export function SkeletonBlock() {
return (
<div className="p-4 bg-gray-800 rounded-lg animate-pulse">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-gray-700 rounded-lg h-8 w-20" />
<div className="bg-gray-700 rounded h-4 w-40" />
</div>
<div className="flex items-center gap-6">
<div className="bg-gray-700 rounded h-4 w-24" />
<div className="bg-gray-700 rounded h-4 w-12" />
</div>
</div>
</div>
);
}
export function SkeletonTableRow({ cols = 4 }) {
return (
<tr className="animate-pulse">
{Array.from({ length: cols }).map((_, i) => (
<td key={i} className="px-6 py-4">
<div className="bg-gray-700 rounded h-4 w-full" />
</td>
))}
</tr>
);
}
export function SkeletonDetailCard() {
return (
<div className="card animate-pulse">
<div className="bg-gray-700 rounded h-6 w-48 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i}>
<div className="bg-gray-700 rounded h-3 w-20 mb-2" />
<div className="bg-gray-600 rounded h-6 w-full" />
</div>
))}
<div className="md:col-span-2">
<div className="bg-gray-700 rounded h-3 w-16 mb-2" />
<div className="bg-gray-600 rounded h-10 w-full" />
</div>
</div>
</div>
);
}
export function SkeletonChart() {
return (
<div className="card animate-pulse">
<div className="flex items-center justify-between mb-6">
<div className="bg-gray-700 rounded h-7 w-32" />
<div className="flex gap-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-gray-700 rounded h-8 w-12" />
))}
</div>
</div>
<div className="bg-gray-700 rounded h-96 w-full" />
</div>
);
}
export function SkeletonTxList({ count = 3 }) {
return (
<div className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="p-3 bg-gray-800 rounded animate-pulse">
<div className="flex items-center gap-3">
<div className="bg-gray-700 rounded h-4 w-8" />
<div className="bg-gray-700 rounded h-4 flex-1" />
</div>
</div>
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { CheckCircle, XCircle, Info, X } from 'lucide-react';
const ToastContext = createContext();
let toastId = 0;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const toast = useCallback((message, type = 'success') => {
const id = ++toastId;
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map(t => (
<div
key={t.id}
className={`flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg text-sm font-medium animate-slide-in
${t.type === 'success' ? 'bg-green-600 text-white' : ''}
${t.type === 'error' ? 'bg-red-600 text-white' : ''}
${t.type === 'info' ? 'bg-blue-600 text-white' : ''}
`}
>
{t.type === 'success' && <CheckCircle className="w-4 h-4 shrink-0" />}
{t.type === 'error' && <XCircle className="w-4 h-4 shrink-0" />}
{t.type === 'info' && <Info className="w-4 h-4 shrink-0" />}
<span>{t.message}</span>
<button onClick={() => removeToast(t.id)} className="ml-2 hover:opacity-70">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
+37
View File
@@ -0,0 +1,37 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('maza-theme') || 'dark';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'light') {
root.classList.add('light');
} else {
root.classList.remove('light');
}
localStorage.setItem('maza-theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+21 -24
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, TrendingUp } from 'lucide-react';
import { TrendingUp } from 'lucide-react';
import axios from 'axios';
import CopyButton from '../components/CopyButton';
import { SkeletonLine } from '../components/Skeleton';
const API_URL = process.env.REACT_APP_API_URL || '';
@@ -27,10 +29,6 @@ function AddressDetail() {
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(address);
};
const formatBalance = (balance) => {
return balance.toLocaleString('en-US', {
minimumFractionDigits: 2,
@@ -46,48 +44,47 @@ function AddressDetail() {
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<h2 className="text-xl font-bold mb-4">Address</h2>
<div className="bg-gray-800 p-4 rounded mb-4">
<div className="hash text-lg break-all mb-2">{address}</div>
<button
onClick={copyToClipboard}
className="btn btn-secondary text-sm flex items-center gap-2"
>
<Copy className="w-4 h-4" />
Copy Address
</button>
<div className="p-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center gap-2">
<div className="hash text-lg break-all flex-1">{address}</div>
<CopyButton text={address} />
</div>
</div>
{/* Balance Info */}
<div className="bg-gray-800 p-4 rounded mb-4">
<div className="p-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-bold mb-3 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-yellow-400" />
Balance
</h3>
{loadingBalance ? (
<div className="text-gray-400 text-sm">Loading balance...</div>
<div className="space-y-2">
<SkeletonLine width="w-48" height="h-8" />
<SkeletonLine width="w-32" height="h-4" />
</div>
) : balanceData?.balance !== null && balanceData?.balance !== undefined ? (
<>
<div className="text-3xl font-bold text-green-400 mb-2">
{formatBalance(balanceData.balance)} MAZA
</div>
{balanceData.rank && (
<div className="text-sm text-gray-400">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Rank #{balanceData.rank} in rich list
</div>
)}
{balanceData.lastSeen && (
<div className="text-xs text-gray-500 mt-1">
<div className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
Last seen in block: {balanceData.lastSeen.toLocaleString()}
</div>
)}
</>
) : (
<div className="text-sm text-gray-400">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{balanceData?.message || 'Balance data not available yet'}
{balanceData?.lastScannedBlock && (
<div className="text-xs text-gray-500 mt-1">
<div className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
Scanner progress: block {balanceData.lastScannedBlock.toLocaleString()}
</div>
)}
@@ -103,7 +100,7 @@ function AddressDetail() {
<li>Balance is calculated by our blockchain scanner tracking all transaction outputs</li>
<li>Scanner is running in the background and processes blocks continuously</li>
<li>Top addresses appear in the rich list as they're discovered</li>
<li>Full transaction history requires <code className="bg-gray-800 px-1 rounded">txindex=1</code> on the node</li>
<li>Full transaction history requires <code className="px-1 rounded" style={{ backgroundColor: 'var(--bg-secondary)' }}>txindex=1</code> on the node</li>
</ul>
</div>
</div>
@@ -119,7 +116,7 @@ function AddressDetail() {
<div className="card">
<h2 className="text-xl font-bold mb-4">Transaction History</h2>
<div className="text-gray-400 text-center py-8">
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<p className="mb-2">Transaction history for addresses requires txindex to be enabled on the Mazacoin node.</p>
<p className="text-sm">This feature will be available once the node is configured with txindex and fully synced.</p>
</div>
+74 -57
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Clock, Hash, Layers } from 'lucide-react';
import { ChevronLeft, ChevronRight, Clock, Layers } from 'lucide-react';
import axios from 'axios';
import { format } from 'date-fns';
import CopyButton from '../components/CopyButton';
import { SkeletonDetailCard, SkeletonTxList } from '../components/Skeleton';
const API_URL = process.env.REACT_APP_API_URL || '';
@@ -14,12 +16,13 @@ function BlockDetail() {
useEffect(() => {
fetchBlock();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hashOrHeight]);
const fetchBlock = async () => {
setLoading(true);
setError('');
try {
const response = await axios.get(`${API_URL}/api/block/${hashOrHeight}`);
setBlock(response.data);
@@ -32,18 +35,21 @@ function BlockDetail() {
if (loading) {
return (
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
<div className="space-y-6">
<div className="flex items-center justify-center">
<div className="animate-pulse bg-gray-700 rounded h-10 w-48" />
</div>
<SkeletonDetailCard />
<div className="card animate-pulse">
<div className="bg-gray-700 rounded h-6 w-40 mb-4" />
<SkeletonTxList count={3} />
</div>
</div>
);
}
if (error) {
return (
<div className="error">
{error}
</div>
);
return <div className="error">{error}</div>;
}
if (!block) return null;
@@ -59,12 +65,12 @@ function BlockDetail() {
<ChevronLeft className="w-4 h-4" />
Previous Block
</Link>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Layers className="w-8 h-8 text-maza-blue" />
Block #{block.height}
</h1>
<Link
to={`/block/${block.height + 1}`}
className="btn btn-secondary flex items-center gap-2"
@@ -77,67 +83,76 @@ function BlockDetail() {
{/* Block Details */}
<div className="card">
<h2 className="text-xl font-bold mb-4">Block Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-gray-400 text-sm mb-1">Height</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Height</div>
<div className="text-lg font-bold">{block.height}</div>
</div>
<div>
<div className="text-gray-400 text-sm mb-1">Timestamp</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Timestamp</div>
<div className="text-lg flex items-center gap-2">
<Clock className="w-4 h-4" />
{format(new Date(block.time * 1000), 'PPpp')}
</div>
</div>
<div className="md:col-span-2">
<div className="text-gray-400 text-sm mb-1">Hash</div>
<div className="hash text-lg bg-gray-800 p-3 rounded">{block.hash}</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Hash</div>
<div className="flex items-center gap-2 p-3 rounded" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="hash text-lg flex-1">{block.hash}</div>
<CopyButton text={block.hash} />
</div>
</div>
{block.previousblockhash && (
<div className="md:col-span-2">
<div className="text-gray-400 text-sm mb-1">Previous Block Hash</div>
<Link
to={`/block/${block.previousblockhash}`}
className="hash text-lg bg-gray-800 p-3 rounded hover:bg-gray-700 block transition-colors"
>
{block.previousblockhash}
</Link>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Previous Block Hash</div>
<div className="flex items-center gap-2 p-3 rounded transition-colors" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<Link
to={`/block/${block.previousblockhash}`}
className="hash text-lg flex-1 text-maza-blue hover:underline"
>
{block.previousblockhash}
</Link>
<CopyButton text={block.previousblockhash} />
</div>
</div>
)}
{block.nextblockhash && (
<div className="md:col-span-2">
<div className="text-gray-400 text-sm mb-1">Next Block Hash</div>
<Link
to={`/block/${block.nextblockhash}`}
className="hash text-lg bg-gray-800 p-3 rounded hover:bg-gray-700 block transition-colors"
>
{block.nextblockhash}
</Link>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Next Block Hash</div>
<div className="flex items-center gap-2 p-3 rounded transition-colors" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<Link
to={`/block/${block.nextblockhash}`}
className="hash text-lg flex-1 text-maza-blue hover:underline"
>
{block.nextblockhash}
</Link>
<CopyButton text={block.nextblockhash} />
</div>
</div>
)}
<div>
<div className="text-gray-400 text-sm mb-1">Difficulty</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Difficulty</div>
<div className="text-lg">{block.difficulty?.toFixed(4)}</div>
</div>
<div>
<div className="text-gray-400 text-sm mb-1">Size</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Size</div>
<div className="text-lg">{(block.size / 1024).toFixed(2)} KB</div>
</div>
<div>
<div className="text-gray-400 text-sm mb-1">Confirmations</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Confirmations</div>
<div className="text-lg text-green-400">{block.confirmations}</div>
</div>
<div>
<div className="text-gray-400 text-sm mb-1">Version</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Version</div>
<div className="text-lg">{block.version}</div>
</div>
</div>
@@ -153,29 +168,31 @@ function BlockDetail() {
</span>
)}
</h2>
{block.tx && block.tx.length > 0 ? (
<div className="space-y-2">
{block.tx.map((txid, index) => (
<Link
<div
key={txid}
to={`/tx/${txid}`}
className="block p-3 bg-gray-800 hover:bg-gray-700 rounded transition-colors"
className="flex items-center gap-2 p-3 rounded transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="text-gray-400 text-sm shrink-0">#{index}</div>
<div className="hash flex-1 truncate">{txid}</div>
</div>
{index === 0 && (
<div className="text-xs text-yellow-400 shrink-0">Coinbase</div>
)}
</div>
</Link>
<Link
to={`/tx/${txid}`}
className="flex items-center gap-3 flex-1 min-w-0 hover:text-maza-blue transition-colors"
>
<div className="text-sm shrink-0" style={{ color: 'var(--text-secondary)' }}>#{index}</div>
<div className="hash flex-1 truncate">{txid}</div>
</Link>
<CopyButton text={txid} />
{index === 0 && (
<div className="text-xs text-yellow-400 shrink-0">Coinbase</div>
)}
</div>
))}
</div>
) : (
<div className="text-gray-400 text-center py-8">
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
No transactions in this block
</div>
)}
+36 -26
View File
@@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
import { Clock, Layers } from 'lucide-react';
import SearchBar from '../components/SearchBar';
import PriceChart from '../components/PriceChart';
import CopyButton from '../components/CopyButton';
import { SkeletonCard, SkeletonBlock } from '../components/Skeleton';
import axios from 'axios';
import { formatDistance } from 'date-fns';
import io from 'socket.io-client';
@@ -17,15 +19,15 @@ function Home() {
useEffect(() => {
fetchData();
// WebSocket for real-time updates
const socket = io(API_URL);
socket.emit('subscribe:blocks');
socket.on('block:new', (block) => {
setLatestBlocks(prev => [block, ...prev].slice(0, 10));
});
return () => socket.disconnect();
}, []);
@@ -36,8 +38,7 @@ function Home() {
axios.get(`${API_URL}/api/stats`, { timeout: 30000 }),
axios.get(`${API_URL}/api/price`, { timeout: 30000 })
]);
// Ensure we got valid data
if (Array.isArray(blocksRes.data)) {
setLatestBlocks(blocksRes.data);
}
@@ -49,7 +50,6 @@ function Home() {
}
} catch (error) {
console.error('Error fetching data:', error);
// Set empty/fallback data on error
setLatestBlocks([]);
} finally {
setLoading(false);
@@ -61,18 +61,22 @@ function Home() {
{/* Hero Section */}
<div className="text-center py-12">
<h1 className="text-5xl font-bold mb-4">Mazacoin Blockchain Explorer</h1>
<p className="text-xl text-gray-400 mb-8">
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}>
Explore blocks, transactions, and addresses on the Mazacoin network
</p>
<SearchBar />
</div>
{/* Network Stats */}
{stats && stats.blockHeight !== undefined && (
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
) : stats && stats.blockHeight !== undefined && (
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{price && price.btc !== undefined && (
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">MAZA Price</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>MAZA Price</div>
<div className="text-lg font-bold text-yellow-400">
{price.btc?.toFixed(8) || '0.00000000'} BTC
</div>
@@ -82,31 +86,31 @@ function Home() {
<div className="text-sm font-bold text-blue-400">
{price.eth?.toFixed(10) || '0.0000000000'} ETH
</div>
<div className="text-xs text-gray-500 mt-1">
<div className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
<a href="https://freiexchange.com/market/MAZA/BTC" target="_blank" rel="noreferrer" className="text-maza-blue hover:underline">FreiExchange</a>
</div>
</div>
)}
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Block Height</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Block Height</div>
<div className="text-2xl font-bold text-maza-blue">
{stats.blockHeight?.toLocaleString() || '0'}
</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Difficulty</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Difficulty</div>
<div className="text-2xl font-bold">
{stats.difficulty?.toFixed(2) || '0.00'}
</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Network Hashrate</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Network Hashrate</div>
<div className="text-2xl font-bold">
{((stats.networkHashrate || 0) / 1000000).toFixed(2)} MH/s
</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Connections</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Connections</div>
<div className="text-2xl font-bold text-green-400">
{stats.connections || 0}
</div>
@@ -123,29 +127,35 @@ function Home() {
<Layers className="w-6 h-6 text-maza-blue" />
Latest Blocks
</h2>
{loading ? (
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => <SkeletonBlock key={i} />)}
</div>
) : (
<div className="space-y-2">
{latestBlocks.map((block) => (
<Link
<div
key={block.height}
to={`/block/${block.height}`}
className="block p-4 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
className="p-4 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-maza-blue/20 text-maza-blue px-3 py-1 rounded-lg font-bold">
<Link
to={`/block/${block.height}`}
className="bg-maza-blue/20 text-maza-blue px-3 py-1 rounded-lg font-bold hover:bg-maza-blue/30 transition-colors"
>
{block.height}
</div>
<div className="hash text-gray-400">
{block.hash.substring(0, 16)}...
</Link>
<div className="flex items-center gap-1">
<span className="hash" style={{ color: 'var(--text-secondary)' }}>
{block.hash.substring(0, 16)}...
</span>
<CopyButton text={block.hash} />
</div>
</div>
<div className="flex items-center gap-6 text-sm text-gray-400">
<div className="flex items-center gap-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatDistance(new Date(block.time * 1000), new Date(), { addSuffix: true })}
@@ -160,7 +170,7 @@ function Home() {
)}
</div>
</div>
</Link>
</div>
))}
</div>
)}
+17 -13
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Activity, Server, Zap, Clock } from 'lucide-react';
import axios from 'axios';
import { SkeletonCard } from '../components/Skeleton';
const API_URL = process.env.REACT_APP_API_URL || '';
@@ -10,7 +11,7 @@ function NetworkStats() {
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 30000); // Update every 30s
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
@@ -27,8 +28,11 @@ function NetworkStats() {
if (loading) {
return (
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
<div className="space-y-6">
<div className="animate-pulse bg-gray-700 rounded h-10 w-64" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
</div>
);
}
@@ -44,7 +48,7 @@ function NetworkStats() {
<Activity className="w-8 h-8 text-maza-blue" />
Network Statistics
</h1>
<div className="text-sm text-gray-400">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Last updated: {new Date(stats.timestamp).toLocaleTimeString()}
</div>
</div>
@@ -57,7 +61,7 @@ function NetworkStats() {
<Server className="w-6 h-6 text-maza-blue" />
</div>
<div>
<div className="text-gray-400 text-sm">Block Height</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Block Height</div>
<div className="text-3xl font-bold">{stats.blockHeight?.toLocaleString() || '0'}</div>
</div>
</div>
@@ -69,7 +73,7 @@ function NetworkStats() {
<Zap className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-gray-400 text-sm">Network Hashrate</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Network Hashrate</div>
<div className="text-3xl font-bold">
{((stats.networkHashrate || 0) / 1000000).toFixed(2)} MH/s
</div>
@@ -83,7 +87,7 @@ function NetworkStats() {
<Activity className="w-6 h-6 text-green-400" />
</div>
<div>
<div className="text-gray-400 text-sm">Active Connections</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Active Connections</div>
<div className="text-3xl font-bold text-green-400">{stats.connections}</div>
</div>
</div>
@@ -95,19 +99,19 @@ function NetworkStats() {
<Clock className="w-6 h-6 text-yellow-400" />
</div>
<div>
<div className="text-gray-400 text-sm">Difficulty</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Difficulty</div>
<div className="text-2xl font-bold">{stats.difficulty?.toFixed(2) || '0.00'}</div>
</div>
</div>
</div>
<div className="card">
<div className="text-gray-400 text-sm mb-1">Protocol Version</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Protocol Version</div>
<div className="text-2xl font-bold">{stats.protocolVersion}</div>
</div>
<div className="card">
<div className="text-gray-400 text-sm mb-1">Client Version</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Client Version</div>
<div className="text-2xl font-bold">{stats.version}</div>
</div>
</div>
@@ -115,12 +119,12 @@ function NetworkStats() {
{/* Additional Info */}
<div className="card">
<h2 className="text-xl font-bold mb-4">About Mazacoin</h2>
<div className="text-gray-300 space-y-2">
<div className="space-y-2">
<p>
Mazacoin (MAZA) is a cryptocurrency designed for the Oglala Lakota Nation.
Mazacoin (MAZA) is a cryptocurrency designed for the Oglala Lakota Nation.
It is based on the Bitcoin protocol and uses Proof-of-Work consensus.
</p>
<p className="text-sm text-gray-400">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
This explorer provides real-time blockchain data directly from Mazacoin nodes.
</p>
</div>
+49 -37
View File
@@ -2,18 +2,20 @@ import React, { useState, useEffect } from 'react';
import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps';
import { MapPin, Globe } from 'lucide-react';
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext';
import { SkeletonCard } from '../components/Skeleton';
const API_URL = process.env.REACT_APP_API_URL || '';
// World map TopoJSON URL
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
function NodeMap() {
const [nodes, setNodes] = useState([]);
const [nodes, setNodes] = useState([]); // eslint-disable-line no-unused-vars
const [geoNodes, setGeoNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ total: 0, active: 0, countries: {} });
const [price, setPrice] = useState(null);
const { theme } = useTheme();
useEffect(() => {
fetchNodes();
@@ -21,7 +23,7 @@ function NodeMap() {
const interval = setInterval(() => {
fetchNodes();
fetchPrice();
}, 60000); // Update every minute
}, 60000);
return () => clearInterval(interval);
}, []);
@@ -36,10 +38,9 @@ function NodeMap() {
const fetchNodes = async () => {
try {
// Fetch nodes with geolocation from backend API
const response = await axios.get(`${API_URL}/api/nodes`);
const data = response.data;
setNodes(data.nodes || []);
setGeoNodes(data.nodes || []);
setStats({
@@ -54,10 +55,21 @@ function NodeMap() {
}
};
const mapFill = theme === 'light' ? '#e2e8f0' : '#1f2937';
const mapStroke = theme === 'light' ? '#cbd5e1' : '#374151';
const mapHover = theme === 'light' ? '#cbd5e1' : '#2d3748';
const mapBg = theme === 'light' ? '#f1f5f9' : undefined;
if (loading) {
return (
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
<div className="space-y-6">
<div className="animate-pulse bg-gray-700 rounded h-10 w-48" />
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
<div className="card">
<div className="animate-pulse bg-gray-700 rounded h-[600px] w-full" />
</div>
</div>
);
}
@@ -69,7 +81,7 @@ function NodeMap() {
<MapPin className="w-8 h-8 text-maza-blue" />
Live Node Map
</h1>
<div className="text-sm text-gray-400">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Showing all nodes discovered in the last 24 hours
</div>
</div>
@@ -78,7 +90,7 @@ function NodeMap() {
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{price && price.btc !== undefined && (
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">MAZA Price</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>MAZA Price</div>
<div className="text-lg font-bold text-yellow-400">
{price.btc?.toFixed(8) || '0.00000000'} BTC
</div>
@@ -90,22 +102,22 @@ function NodeMap() {
</div>
</div>
)}
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Nodes (24h)</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Nodes (24h)</div>
<div className="text-3xl font-bold text-yellow-400">{stats.total}</div>
<div className="text-sm text-gray-400 mt-1">
<div className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
<span className="text-yellow-400 font-semibold">{stats.active} active</span>
</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Mapped Nodes</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Mapped Nodes</div>
<div className="text-3xl font-bold text-maza-blue">{geoNodes.length}</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">IP Protocol</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>IP Protocol</div>
<div className="text-sm">
<div className="text-lg font-bold text-blue-400">
IPv4: {geoNodes.filter(n => n.ipVersion === 4).length}
@@ -115,15 +127,15 @@ function NodeMap() {
</div>
</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Countries</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Countries</div>
<div className="text-3xl font-bold">{Object.keys(stats.countries).length}</div>
</div>
</div>
{/* Legend */}
<div className="card bg-gray-800/50 border border-gray-700">
<div className="card" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center justify-center gap-8 py-2">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-yellow-400 shadow-lg" style={{ filter: 'drop-shadow(0 0 8px rgba(251, 191, 36, 0.8))' }}></div>
@@ -131,14 +143,14 @@ function NodeMap() {
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-gray-500 opacity-60"></div>
<span className="text-sm font-medium text-gray-400">Seen in Last 24h ({geoNodes.filter(n => !n.isActive).length})</span>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Seen in Last 24h ({geoNodes.filter(n => !n.isActive).length})</span>
</div>
</div>
</div>
{/* Map */}
<div className="card">
<div className="h-[600px] rounded-lg overflow-hidden bg-gray-900">
<div className="h-[600px] rounded-lg overflow-hidden" style={{ backgroundColor: mapBg || 'rgb(17, 24, 39)' }}>
<ComposableMap
projection="geoMercator"
projectionConfig={{
@@ -153,29 +165,29 @@ function NodeMap() {
<Geography
key={geo.rsmKey}
geography={geo}
fill="#1f2937"
stroke="#374151"
fill={mapFill}
stroke={mapStroke}
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { fill: "#2d3748", outline: "none" },
hover: { fill: mapHover, outline: "none" },
pressed: { outline: "none" },
}}
/>
))
}
</Geographies>
{geoNodes.map((node, index) => {
const isActive = node.isActive;
const markerColor = isActive ? '#fbbf24' : '#6b7280'; // yellow for active, gray for inactive
const markerColor = isActive ? '#fbbf24' : '#6b7280';
const glowColor = isActive ? 'rgba(251, 191, 36, 0.8)' : 'rgba(107, 114, 128, 0.4)';
return (
<Marker key={index} coordinates={[node.lon, node.lat]}>
<g>
<circle
r={6}
<circle
r={6}
fill={markerColor}
stroke={isActive ? '#f59e0b' : '#4b5563'}
strokeWidth={2}
@@ -184,8 +196,8 @@ function NodeMap() {
filter: `drop-shadow(0 0 8px ${glowColor})`
}}
/>
<circle
r={12}
<circle
r={12}
fill={markerColor}
fillOpacity={0.2}
stroke="none"
@@ -193,7 +205,7 @@ function NodeMap() {
/>
</g>
<title>
{isActive ? '🟡 ACTIVE' : 'Seen in last 24h'}
{isActive ? 'ACTIVE' : 'Seen in last 24h'}
{'\n'}{node.city}, {node.country}
{'\n'}IP: {node.ip || node.addr?.split(':')[0]} (IPv{node.ipVersion || '4'})
{node.subver ? `\nClient: ${node.subver}` : ''}
@@ -206,9 +218,9 @@ function NodeMap() {
</ZoomableGroup>
</ComposableMap>
</div>
{geoNodes.length === 0 && (
<div className="text-center text-gray-400 py-8">
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<p>No nodes could be geolocated yet.</p>
<p className="text-sm mt-2">Geolocation is in progress...</p>
</div>
@@ -221,13 +233,13 @@ function NodeMap() {
<Globe className="w-6 h-6" />
Node Distribution by Country
</h2>
{Object.keys(stats.countries).length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(stats.countries)
.sort(([, a], [, b]) => b - a)
.map(([country, count]) => (
<div key={country} className="bg-gray-800 p-3 rounded">
<div key={country} className="p-3 rounded" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center justify-between">
<div className="font-medium">{country}</div>
<div className="text-maza-blue font-bold">{count}</div>
@@ -236,7 +248,7 @@ function NodeMap() {
))}
</div>
) : (
<div className="text-gray-400 text-center py-8">
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
No country data available yet.
</div>
)}
+59 -34
View File
@@ -1,6 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Trophy, TrendingUp, AlertCircle } from 'lucide-react';
import axios from 'axios';
import CopyButton from '../components/CopyButton';
import { SkeletonCard, SkeletonTableRow } from '../components/Skeleton';
const API_URL = process.env.REACT_APP_API_URL || '';
@@ -11,7 +14,7 @@ function RichList() {
useEffect(() => {
fetchRichList();
const interval = setInterval(fetchRichList, 60000); // Update every minute
const interval = setInterval(fetchRichList, 60000);
return () => clearInterval(interval);
}, []);
@@ -45,10 +48,10 @@ function RichList() {
};
const getRankColor = (rank) => {
if (rank === 1) return 'text-yellow-400'; // Gold
if (rank === 2) return 'text-gray-300'; // Silver
if (rank === 3) return 'text-yellow-600'; // Bronze
return 'text-gray-400';
if (rank === 1) return 'text-yellow-400';
if (rank === 2) return 'text-gray-300';
if (rank === 3) return 'text-yellow-600';
return '';
};
const getRankIcon = (rank) => {
@@ -60,8 +63,26 @@ function RichList() {
if (loading) {
return (
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
<div className="space-y-6">
<div className="animate-pulse bg-gray-700 rounded h-10 w-48" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
<div className="card overflow-hidden">
<table className="min-w-full">
<thead className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border-color)' }}>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>Rank</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>Address</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>Balance (MAZA)</th>
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>Last Seen Block</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 10 }).map((_, i) => <SkeletonTableRow key={i} cols={4} />)}
</tbody>
</table>
</div>
</div>
);
}
@@ -101,22 +122,22 @@ function RichList() {
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Total Addresses</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Total Addresses</div>
<div className="text-3xl font-bold text-yellow-400">{data?.totalAddresses || 0}</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Last Scanned Block</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Last Scanned Block</div>
<div className="text-2xl font-bold text-maza-blue">{formatBlockNumber(data?.lastScannedBlock)}</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Top 100 Total Balance</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Top 100 Total Balance</div>
<div className="text-2xl font-bold text-green-400">{formatBalance(getTotalBalance())} MAZA</div>
</div>
<div className="stat-card">
<div className="text-gray-400 text-sm mb-1">Status</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Status</div>
<div className={`text-xl font-bold ${data?.isScanning ? 'text-yellow-400' : 'text-green-400'}`}>
{data?.isScanning ? 'Scanning...' : 'Up to date'}
</div>
@@ -130,9 +151,9 @@ function RichList() {
<div>
<p className="font-semibold mb-1">About the Rich List</p>
<p>
This list shows the top 100 addresses by balance. Balances are calculated by tracking
transaction outputs as the blockchain is scanned. The scanner runs in the background
and processes blocks incrementally. Large exchanges and mining pools typically appear
This list shows the top 100 addresses by balance. Balances are calculated by tracking
transaction outputs as the blockchain is scanned. The scanner runs in the background
and processes blocks incrementally. Large exchanges and mining pools typically appear
at the top of this list.
</p>
</div>
@@ -143,27 +164,28 @@ function RichList() {
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="bg-gray-800 border-b border-gray-700">
<thead className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border-color)' }}>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Rank
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Address
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Balance (MAZA)
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
<th className="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Last Seen Block
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
<tbody style={{ borderColor: 'var(--border-color)' }}>
{data?.addresses?.map((addr) => (
<tr
<tr
key={addr.address}
className="hover:bg-gray-800/50 transition-colors"
className="transition-colors hover:opacity-80"
style={{ borderBottom: '1px solid var(--border-color)' }}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
@@ -174,19 +196,22 @@ function RichList() {
</div>
</td>
<td className="px-6 py-4">
<a
href={`/address/${addr.address}`}
className="text-maza-blue hover:text-blue-300 font-mono text-sm break-all"
>
{addr.address}
</a>
<div className="flex items-center gap-1">
<Link
to={`/address/${addr.address}`}
className="text-maza-blue hover:text-blue-300 font-mono text-sm break-all"
>
{addr.address}
</Link>
<CopyButton text={addr.address} />
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<span className="text-green-400 font-bold">
{formatBalance(addr.balance)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center text-gray-400 text-sm">
<td className="px-6 py-4 whitespace-nowrap text-center text-sm" style={{ color: 'var(--text-secondary)' }}>
{formatBlockNumber(addr.lastSeen)}
</td>
</tr>
@@ -198,8 +223,8 @@ function RichList() {
{!data?.addresses || data.addresses.length === 0 ? (
<div className="card">
<div className="text-center text-gray-400 py-12">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-gray-500" />
<div className="text-center py-12" style={{ color: 'var(--text-secondary)' }}>
<AlertCircle className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--text-muted)' }} />
<p className="text-lg mb-2">No data available yet</p>
<p className="text-sm">
The blockchain scanner is starting up. Please check back in a few minutes.
+77 -56
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowRight, Clock } from 'lucide-react';
import { Clock } from 'lucide-react';
import axios from 'axios';
import { format } from 'date-fns';
import CopyButton from '../components/CopyButton';
import { SkeletonDetailCard } from '../components/Skeleton';
const API_URL = process.env.REACT_APP_API_URL || '';
@@ -14,12 +16,13 @@ function TransactionDetail() {
useEffect(() => {
fetchTransaction();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [txid]);
const fetchTransaction = async () => {
setLoading(true);
setError('');
try {
const response = await axios.get(`${API_URL}/api/tx/${txid}`);
setTx(response.data);
@@ -32,8 +35,13 @@ function TransactionDetail() {
if (loading) {
return (
<div className="loading">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-maza-blue"></div>
<div className="space-y-6">
<div className="animate-pulse bg-gray-700 rounded h-10 w-64" />
<SkeletonDetailCard />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SkeletonDetailCard />
<SkeletonDetailCard />
</div>
</div>
);
}
@@ -44,8 +52,7 @@ function TransactionDetail() {
if (!tx) return null;
// Use backend-calculated totals if available, otherwise calculate
const totalInput = tx.totalInput !== undefined ? tx.totalInput :
const totalInput = tx.totalInput !== undefined ? tx.totalInput :
tx.vin?.reduce((sum, input) => sum + (input.value || 0), 0) || 0;
const totalOutput = tx.totalOutput !== undefined ? tx.totalOutput :
tx.vout?.reduce((sum, output) => sum + (output.value || 0), 0) || 0;
@@ -58,53 +65,59 @@ function TransactionDetail() {
{/* Transaction Info */}
<div className="card">
<h2 className="text-xl font-bold mb-4">Transaction Information</h2>
<div className="grid grid-cols-1 gap-6">
<div>
<div className="text-gray-400 text-sm mb-1">Transaction ID</div>
<div className="hash text-lg bg-gray-800 p-3 rounded">{tx.txid}</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Transaction ID</div>
<div className="flex items-center gap-2 p-3 rounded" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="hash text-lg flex-1">{tx.txid}</div>
<CopyButton text={tx.txid} />
</div>
</div>
{tx.blockhash && (
<div>
<div className="text-gray-400 text-sm mb-1">Block Hash</div>
<Link
to={`/block/${tx.blockhash}`}
className="hash text-lg bg-gray-800 p-3 rounded hover:bg-gray-700 block transition-colors"
>
{tx.blockhash}
</Link>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Block Hash</div>
<div className="flex items-center gap-2 p-3 rounded transition-colors" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<Link
to={`/block/${tx.blockhash}`}
className="hash text-lg flex-1 text-maza-blue hover:underline"
>
{tx.blockhash}
</Link>
<CopyButton text={tx.blockhash} />
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{tx.confirmations !== undefined && (
<div>
<div className="text-gray-400 text-sm mb-1">Confirmations</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Confirmations</div>
<div className="text-lg text-green-400">{tx.confirmations}</div>
</div>
)}
{tx.time && (
<div>
<div className="text-gray-400 text-sm mb-1">Time</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Time</div>
<div className="text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
{format(new Date(tx.time * 1000), 'PPpp')}
</div>
</div>
)}
{tx.size && (
<div>
<div className="text-gray-400 text-sm mb-1">Size</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Size</div>
<div className="text-lg">{tx.size} bytes</div>
</div>
)}
{fee > 0 && (
<div>
<div className="text-gray-400 text-sm mb-1">Fee</div>
<div className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Fee</div>
<div className="text-lg">{fee.toFixed(8)} MAZA</div>
</div>
)}
@@ -119,40 +132,46 @@ function TransactionDetail() {
<h3 className="text-lg font-bold mb-4">
Inputs ({tx.vin?.length || 0})
</h3>
{tx.vin && tx.vin.length > 0 ? (
<>
<div className="space-y-3 mb-4">
{tx.vin.map((input, index) => (
<div key={index} className="bg-gray-800 p-3 rounded">
<div key={index} className="p-3 rounded" style={{ backgroundColor: 'var(--bg-secondary)' }}>
{input.coinbase || input.isCoinbase ? (
<div className="text-sm text-gray-400">Coinbase (Newly Generated Coins)</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Coinbase (Newly Generated Coins)</div>
) : (
<>
{input.txid && (
<Link
to={`/tx/${input.txid}`}
className="hash text-xs text-maza-blue hover:underline block mb-1"
>
{input.txid}:{input.vout}
</Link>
<div className="flex items-center gap-1 mb-1">
<Link
to={`/tx/${input.txid}`}
className="hash text-xs text-maza-blue hover:underline"
>
{input.txid}:{input.vout}
</Link>
<CopyButton text={input.txid} />
</div>
)}
{input.value !== undefined && input.value !== null ? (
<>
<div className="text-lg font-bold">{input.value.toFixed(8)} MAZA</div>
{input.address && input.address !== 'Unknown' && (
<Link
to={`/address/${input.address}`}
className="hash text-xs text-maza-blue hover:underline block mt-1"
>
{input.address}
</Link>
<div className="flex items-center gap-1 mt-1">
<Link
to={`/address/${input.address}`}
className="hash text-xs text-maza-blue hover:underline"
>
{input.address}
</Link>
<CopyButton text={input.address} />
</div>
)}
</>
) : input.error ? (
<div className="text-xs text-yellow-400">{input.error}</div>
) : (
<div className="text-xs text-gray-500">Amount unknown</div>
<div className="text-xs" style={{ color: 'var(--text-muted)' }}>Amount unknown</div>
)}
</>
)}
@@ -160,16 +179,16 @@ function TransactionDetail() {
))}
</div>
{totalInput > 0 && (
<div className="pt-3 border-t border-gray-700">
<div className="pt-3" style={{ borderTop: '1px solid var(--border-color)' }}>
<div className="flex justify-between items-center">
<span className="text-gray-400">Total Input:</span>
<span style={{ color: 'var(--text-secondary)' }}>Total Input:</span>
<span className="text-xl font-bold text-green-400">{totalInput.toFixed(8)} MAZA</span>
</div>
</div>
)}
</>
) : (
<div className="text-gray-400 text-center py-4">No inputs</div>
<div className="text-center py-4" style={{ color: 'var(--text-secondary)' }}>No inputs</div>
)}
</div>
@@ -178,36 +197,38 @@ function TransactionDetail() {
<h3 className="text-lg font-bold mb-4">
Outputs ({tx.vout?.length || 0})
</h3>
{tx.vout && tx.vout.length > 0 ? (
<>
<div className="space-y-3 mb-4">
{tx.vout.map((output, index) => (
<div key={index} className="bg-gray-800 p-3 rounded">
<div key={index} className="p-3 rounded" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-lg font-bold mb-1">{output.value.toFixed(8)} MAZA</div>
{output.scriptPubKey?.addresses?.map((address, i) => (
<Link
key={i}
to={`/address/${address}`}
className="hash text-xs text-maza-blue hover:underline block"
>
{address}
</Link>
<div key={i} className="flex items-center gap-1">
<Link
to={`/address/${address}`}
className="hash text-xs text-maza-blue hover:underline"
>
{address}
</Link>
<CopyButton text={address} />
</div>
))}
</div>
))}
</div>
{totalOutput > 0 && (
<div className="pt-3 border-t border-gray-700">
<div className="pt-3" style={{ borderTop: '1px solid var(--border-color)' }}>
<div className="flex justify-between items-center">
<span className="text-gray-400">Total Output:</span>
<span style={{ color: 'var(--text-secondary)' }}>Total Output:</span>
<span className="text-xl font-bold text-blue-400">{totalOutput.toFixed(8)} MAZA</span>
</div>
</div>
)}
</>
) : (
<div className="text-gray-400 text-center py-4">No outputs</div>
<div className="text-center py-4" style={{ color: 'var(--text-secondary)' }}>No outputs</div>
)}
</div>
</div>