Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e02c03e83d |
+70
-4
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 © 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user