1 Commits

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