diff --git a/.env.example b/.env.example deleted file mode 100644 index eef6885..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -# DedooExplorer Configuration -# Copy this file to .env and customize for your coin - -# Server -PORT=3001 - -# Electrs API -ELECTRS_API=http://127.0.0.1:50010 - -# Explorer Branding -EXPLORER_NAME=Junkcoin Explorer -COIN_NAME=Junkcoin -COIN_TICKER=JKC -COIN_TAGLINE=A peer to peer meme currency serving the internet community - -# Logo (path relative to /public or absolute URL) -LOGO_URL=/img/dedoo-logo.svg - -# Social Media Links (leave empty to hide) -WEBSITE_URL=https://junk-coin.com/ -GITHUB_URL=https://github.com/junkcoin-Foundation/junkcoin-core -TELEGRAM_URL=https://t.me/junkcoin2014 -TWITTER_URL=https://x.com/junkcoin_jkc -DISCORD_URL= - -# Mining/Consensus -ALGORITHM=Scrypt -DIFF_ADJUSTMENT=Custom -BLOCK_TIME=60 diff --git a/package-lock.json b/package-lock.json index ada6c25..50b01a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -784,9 +784,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/public/css/style.css b/public/css/style.css index 14394b8..cf42bdb 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -155,8 +155,9 @@ a:hover { margin: 0 auto; padding: 0 var(--spacing-lg); display: flex; + flex-wrap: wrap; align-items: center; - gap: var(--spacing-lg); + gap: var(--spacing-md); } .nav-brand { @@ -185,8 +186,9 @@ a:hover { } .search-form { - flex: 1; - max-width: 500px; + flex: 1 1 280px; + min-width: 0; + max-width: 420px; position: relative; } @@ -221,7 +223,33 @@ a:hover { .nav-links { display: flex; + flex-wrap: wrap; gap: var(--spacing-xs); + flex: 1 1 auto; + min-width: 0; +} + +.nav-toggle { + display: none; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + background: var(--bg-input); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.nav-toggle i { + font-size: 1.25rem; +} + +.nav-toggle:hover { + border-color: var(--primary); + color: var(--primary); } .nav-link { @@ -250,6 +278,187 @@ a:hover { font-size: 1rem; } +.nav-backdrop { + display: none; +} + +.nav-modal-panel { + display: none; +} + +.theme-toggle { + min-width: 48px; + height: 48px; + border-radius: var(--radius-lg); + padding: 0 calc(var(--spacing-md) + 2px); +} + +.theme-toggle i { + font-size: 1.25rem; +} + +@media (max-width: 768px) { + .nav-container { + flex-wrap: wrap; + gap: var(--spacing-sm); + } + + .search-form { + order: 3; + flex: 1 1 100%; + max-width: 100%; + margin-top: var(--spacing-sm); + } + + .nav-toggle { + display: inline-flex; + margin-left: auto; + } + + .nav-toggle[aria-expanded="true"] i.fa-bars { + display: none; + } + + .nav-toggle[aria-expanded="true"] i.fa-times { + display: inline-block; + } + + .nav-toggle i.fa-times { + display: none; + } + + .theme-toggle { + margin-left: var(--spacing-xs); + } + + /* Hide desktop nav links on mobile */ + .nav-links-desktop { + display: none !important; + } + + /* Hamburger modal backdrop (mobile only) */ + .nav-backdrop { + display: none; + pointer-events: none; + position: fixed; + inset: 0; + z-index: 999; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.2s ease; + } + + .nav-backdrop.open { + display: block; + opacity: 1; + pointer-events: auto; + } + + /* Hamburger modal panel - fixed height, inner scroll */ + .nav-modal-panel { + position: fixed; + top: 0; + left: var(--spacing-md); + right: var(--spacing-md); + z-index: 1000; + max-height: 88vh; + margin-top: 60px; + display: flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + transform: translateY(-12px); + opacity: 0; + visibility: hidden; + transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s ease; + } + + .nav-modal-panel.open { + display: flex; + transform: translateY(0); + opacity: 1; + visibility: visible; + } + + .nav-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); + flex-shrink: 0; + } + + .nav-modal-title { + font-weight: 600; + font-size: 1.125rem; + color: var(--text-primary); + } + + .nav-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; + } + + .nav-modal-close:hover { + background: var(--border-light); + color: var(--text-primary); + } + + .nav-modal-close i { + font-size: 1.25rem; + } + + /* Scrollable list - this div scrolls, not the panel */ + .nav-links-scroll { + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-lg); + min-height: 0; + flex: 1 1 auto; + } + + .nav-modal-panel .nav-link { + width: 100%; + justify-content: flex-start; + color: var(--text-primary); + font-size: 1rem; + padding: 14px 16px; + border-radius: var(--radius-md); + margin-bottom: var(--spacing-xs); + } + + .nav-modal-panel .nav-link:last-child { + margin-bottom: 0; + } + + .nav-modal-panel .nav-link span { + display: inline-block; + } + + .nav-modal-panel .nav-link i { + width: 24px; + text-align: center; + margin-right: var(--spacing-sm); + } +} + /* ============ MAIN CONTENT ============ */ .main-content { flex: 1; @@ -363,6 +572,12 @@ a:hover { color: var(--primary); } +.stat-note { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 4px; +} + /* ============ CONTENT CARDS ============ */ .content-card { background: var(--bg-card); @@ -438,203 +653,765 @@ a:hover { border-collapse: collapse; } -.data-table th { - text-align: left; - padding: var(--spacing-md) var(--spacing-lg); - font-size: 0.75rem; +.data-table th { + text-align: left; + padding: var(--spacing-md) var(--spacing-lg); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-secondary); + text-transform: uppercase; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid var(--border-color); +} + +.data-table td { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); + font-size: 0.9rem; +} + +.data-table tbody tr { + transition: background var(--transition-fast); +} + +.data-table tbody tr:hover { + background: var(--bg-card-hover); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.clickable-row { + cursor: pointer; +} + +.text-muted { + color: var(--text-muted); +} + +.text-center { + text-align: center; +} + +/* Hash Values */ +.hash-value { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.85rem; +} + +.hash-full { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.85rem; + word-break: break-all; + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); +} + +.block-height { + color: var(--primary); + font-weight: 600; +} + +.copy-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.copy-btn:hover { + color: var(--primary); + background: rgba(245, 166, 35, 0.1); +} + +.time-cell { + display: flex; + flex-direction: column; +} + +.time-ago { + color: var(--text-primary); +} + +.time-full { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ============ BADGES ============ */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-md); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.badge-confirmed { + background: rgba(76, 217, 100, 0.15); + color: var(--success); +} + +.badge-pending { + background: rgba(245, 166, 35, 0.15); + color: var(--primary); +} + +.badge-info { + background: rgba(90, 200, 250, 0.15); + color: var(--info); +} + +.stat-card-success { + border-left: 3px solid var(--success); +} + +.stat-card-warning { + border-left: 3px solid var(--warning); +} + +.success-row { + background: rgba(76, 217, 100, 0.05); +} + +.warning-row { + background: rgba(245, 166, 35, 0.05); +} + +/* ============ PAGINATION ============ */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-lg); + border-top: 1px solid var(--border-color); +} + +.page-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 10px 16px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition-fast); +} + +.page-btn:hover:not(.disabled) { + background: var(--bg-card-hover); + border-color: var(--primary); + color: var(--primary); +} + +.page-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.page-numbers { + display: flex; + gap: var(--spacing-xs); +} + +.page-num { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + transition: all var(--transition-fast); +} + +.page-num:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.page-num.active { + background: var(--primary); + color: #000; + font-weight: 600; +} + +.page-ellipsis { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.page-info { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.pagination-footer { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + flex-wrap: wrap; + padding: var(--spacing-lg); + border-top: 1px solid var(--border-color); +} + +.pagination-footer .page-btn.active { + background: var(--primary); + color: #000; + border-color: var(--primary); +} + +.pagination-bar { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + flex-wrap: wrap; + padding: var(--spacing-lg); + border-top: 1px solid var(--border-color); +} + +.pagination-bar .pagination-info { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.pagination-bar .pagination-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 8px 14px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.875rem; + text-decoration: none; + transition: all var(--transition-fast); +} + +.pagination-bar .pagination-btn:hover { + background: var(--bg-card-hover); + border-color: var(--primary); + color: var(--primary); +} + +.pagination-bar .pagination-btn.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Coin Flow page */ +.coin-flow-page .flow-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.coin-flow-page .flow-item { + padding: var(--spacing-md) var(--spacing-lg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-card); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.coin-flow-page .flow-item:hover { + background: var(--bg-card-hover); + border-color: var(--border-light); +} + +.coin-flow-page .flow-meta { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + font-size: 0.875rem; + color: var(--text-secondary); +} + +.coin-flow-page .flow-time { + font-variant-numeric: tabular-nums; +} + +.coin-flow-page .flow-block { + color: var(--primary); + text-decoration: none; +} + +.coin-flow-page .flow-block:hover { + text-decoration: underline; +} + +.coin-flow-page .flow-arrow-row { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.coin-flow-page .flow-from, +.coin-flow-page .flow-to { + flex: 1; + min-width: 140px; +} + +.coin-flow-page .flow-label { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.coin-flow-page .flow-addresses { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; +} + +.coin-flow-page .flow-addr { + font-family: var(--font-mono, monospace); + font-size: 0.875rem; + color: var(--primary); + text-decoration: none; +} + +.coin-flow-page .flow-addr:hover { + text-decoration: underline; +} + +.coin-flow-page .flow-addr.coinbase { + color: var(--success); +} + +.coin-flow-page .flow-amount { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.coin-flow-page .flow-arrow { + flex-shrink: 0; + padding-top: 1.25rem; + color: var(--text-muted); +} + +.coin-flow-page .flow-total { + margin-top: var(--spacing-sm); + font-size: 0.9rem; +} + +.coin-flow-page .flow-fee { + margin-left: var(--spacing-md); + font-size: 0.8rem; + color: var(--text-muted); +} + +.coin-flow-page .flow-txid { + margin-top: 6px; + font-size: 0.8rem; + display: flex; + align-items: center; + gap: 6px; +} + +.coin-flow-page .flow-txid .hash-value { + color: var(--text-secondary); + text-decoration: none; +} + +.coin-flow-page .flow-txid .hash-value:hover { + color: var(--primary); +} + +.coin-flow-page .empty-state { + padding: var(--spacing-xl); + text-align: center; + color: var(--text-muted); +} + +.page-footer-note { + font-size: 0.875rem; + color: var(--text-muted); + margin-top: var(--spacing-md); +} + +.page-footer-note a { + color: var(--primary); +} + +.text-warning { + color: var(--warning); +} + +.rank-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + padding: 4px 8px; + background: var(--bg-input); + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.875rem; + color: var(--primary); +} + +.rank-badge.rank-1 { background: rgba(255, 193, 7, 0.2); color: #ffc107; } +.rank-badge.rank-2 { background: rgba(158, 158, 158, 0.25); color: #bdbdbd; } +.rank-badge.rank-3 { background: rgba(205, 127, 50, 0.25); color: #cd7f32; } + +.card-footer-note { + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--border-color); + font-size: 0.9rem; +} + +.card-footer-note a { + color: var(--primary); + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); +} + +.alert { + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-md); + margin: var(--spacing-md) 0; + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); +} + +.alert i { + flex-shrink: 0; + margin-top: 2px; +} + +.alert code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.85em; +} + +.alert-warning { + background: rgba(245, 166, 35, 0.12); + border: 1px solid rgba(245, 166, 35, 0.35); + color: var(--text-primary); +} + +/* Glossary & FAQ */ +.glossary-list.detail-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.glossary-term { + padding: var(--spacing-md) 0; + border-bottom: 1px solid var(--border-color); +} + +.glossary-term:last-child { + border-bottom: none; +} + +.term-name { + display: block; + font-weight: 600; + color: var(--primary); + margin-bottom: var(--spacing-xs); + font-size: 1rem; +} + +.term-desc { + color: var(--text-secondary); + line-height: 1.6; + font-size: 0.95rem; +} + +.faq-list { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.faq-item { + padding-bottom: var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.faq-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.faq-q { + font-size: 1rem; font-weight: 600; - letter-spacing: 0.5px; - color: var(--text-secondary); - text-transform: uppercase; - background: rgba(0, 0, 0, 0.3); - border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + margin-bottom: var(--spacing-sm); } -.data-table td { - padding: var(--spacing-md) var(--spacing-lg); - border-bottom: 1px solid var(--border-color); - font-size: 0.9rem; +.faq-a { + color: var(--text-secondary); + line-height: 1.6; + margin: 0; + font-size: 0.95rem; } -.data-table tbody tr { - transition: background var(--transition-fast); +.faq-a a, +.term-desc a { + color: var(--primary); } -.data-table tbody tr:hover { - background: var(--bg-card-hover); +.faq-a code, +.term-desc code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.9em; } -.data-table tbody tr:last-child td { - border-bottom: none; +.balance-value { + font-weight: 500; + font-variant-numeric: tabular-nums; } -.clickable-row { - cursor: pointer; +.balance-value .currency { + color: var(--text-secondary); + font-size: 0.85em; + margin-left: 4px; } -.text-muted { +.empty-state { + text-align: center; + padding: var(--spacing-xl) !important; color: var(--text-muted); } -.text-center { - text-align: center; +.empty-state i { + font-size: 2.5rem; + margin-bottom: var(--spacing-md); + opacity: 0.5; } -/* Hash Values */ -.hash-value { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; - font-size: 0.85rem; +.empty-state p { + margin: 0; } -.hash-full { - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; - font-size: 0.85rem; - word-break: break-all; +/* ============ API DOCS ============ */ +.api-page .api-docs-card { + max-width: 900px; +} + +.api-base-url { display: flex; - align-items: flex-start; + align-items: center; gap: var(--spacing-sm); + padding: var(--spacing-md); + background: var(--bg-input); + border-radius: var(--radius-md); + font-family: monospace; + font-size: 0.9rem; + word-break: break-all; } -.block-height { - color: var(--primary); - font-weight: 600; +.api-base-url code { + flex: 1; } -.copy-btn { - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - display: inline-flex; - align-items: center; - justify-content: center; +.api-section { + margin-top: var(--spacing-xl); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-color); } -.copy-btn:hover { - color: var(--primary); - background: rgba(245, 166, 35, 0.1); +.api-section:first-of-type { + margin-top: 0; + padding-top: 0; + border-top: none; } -.time-cell { +.api-section h3 { + font-size: 1.1rem; + margin-bottom: var(--spacing-md); + color: var(--primary); display: flex; - flex-direction: column; -} - -.time-ago { - color: var(--text-primary); + align-items: center; + gap: var(--spacing-sm); } -.time-full { - font-size: 0.75rem; - color: var(--text-muted); +.api-endpoint { + margin-bottom: var(--spacing-lg); + padding: var(--spacing-md); + background: var(--bg-input); + border-radius: var(--radius-md); + border-left: 3px solid var(--border-color); } -/* ============ BADGES ============ */ -.badge { - display: inline-flex; - align-items: center; - padding: 4px 10px; - border-radius: var(--radius-md); +.api-endpoint .method { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: 600; - text-transform: uppercase; + margin-right: var(--spacing-sm); } -.badge-confirmed { - background: rgba(76, 217, 100, 0.15); +.api-endpoint .method.get { + background: rgba(74, 200, 100, 0.2); color: var(--success); } -.badge-pending { - background: rgba(245, 166, 35, 0.15); +.api-endpoint .method.post { + background: rgba(90, 200, 250, 0.2); + color: var(--info); +} + +.api-endpoint code { + font-size: 0.9rem; color: var(--primary); } -/* ============ PAGINATION ============ */ -.pagination { +.api-endpoint p { + margin: var(--spacing-sm) 0; + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.api-endpoint p code { + background: var(--bg-card); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: 0.8rem; +} + +.api-example { display: flex; - justify-content: center; align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-lg); - border-top: 1px solid var(--border-color); + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); + padding: var(--spacing-sm); + background: var(--bg-card); + border-radius: var(--radius-sm); + font-size: 0.8rem; + overflow-x: auto; } -.page-btn { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - padding: 10px 16px; - background: var(--bg-input); +.api-example code { + flex: 1; + color: var(--text-primary); + word-break: break-all; +} + +.copy-btn-sm { + flex-shrink: 0; + padding: 4px 8px; + background: transparent; border: 1px solid var(--border-color); - border-radius: var(--radius-md); + border-radius: var(--radius-sm); color: var(--text-secondary); - font-size: 0.875rem; - font-weight: 500; + cursor: pointer; transition: all var(--transition-fast); } -.page-btn:hover:not(.disabled) { - background: var(--bg-card-hover); +.copy-btn-sm:hover { border-color: var(--primary); color: var(--primary); } -.page-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; +.api-footer { + margin-top: var(--spacing-xl); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-color); + font-size: 0.875rem; + color: var(--text-muted); } -.page-numbers { - display: flex; - gap: var(--spacing-xs); +.api-footer a { + color: var(--primary); } -.page-num { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; +.supply-progress { + width: 100%; + height: 10px; border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - transition: all var(--transition-fast); -} - -.page-num:hover { - background: var(--bg-card-hover); - color: var(--text-primary); + background: var(--bg-input); + overflow: hidden; + margin: 8px 0; } -.page-num.active { +.supply-progress-inner { + height: 100%; background: var(--primary); - color: #000; - font-weight: 600; + width: 0; + transition: width var(--transition-normal); } -.page-ellipsis { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); +.supply-progress-inner.secondary { + background: var(--info); } -.page-info { - font-size: 0.875rem; +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + background: var(--bg-input); color: var(--text-secondary); } +.badge-in { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + background: rgba(76, 217, 100, 0.2); + color: var(--success); +} + +.badge-out { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + background: rgba(90, 200, 250, 0.2); + color: var(--info); +} + /* ============ CHARTS ============ */ .chart-container { padding: var(--spacing-lg); @@ -835,6 +1612,79 @@ a:hover { margin-top: 4px; } +/* Transaction technical details (scripts, witness, raw) */ +.tx-technical { + margin-top: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--border-color); + font-size: 0.8rem; +} + +.tx-technical-row { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--spacing-sm); +} + +.tx-technical-row:last-child { + margin-bottom: 0; +} + +.tx-technical-label { + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.tx-technical-value { + color: var(--text-secondary); + word-break: break-all; +} + +.tx-technical-value.mono-wrap { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.78rem; + line-height: 1.4; +} + +.tx-technical-value code, +.hex-value { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.78rem; + background: var(--bg-input); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +.witness-item { + margin-bottom: 4px; + font-size: 0.78rem; +} + +.witness-index { + display: inline-block; + min-width: 20px; + color: var(--text-muted); + margin-right: 6px; +} + +.raw-hex-block { + margin: 0; + padding: var(--spacing-md); + background: var(--bg-input); + border-radius: var(--radius-md); + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.75rem; + line-height: 1.5; + word-break: break-all; + white-space: pre-wrap; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + /* ============ ADDRESS PAGE ============ */ .address-card { padding: var(--spacing-lg); @@ -1057,7 +1907,7 @@ a:hover { } @media (max-width: 768px) { - .nav-links span { + .nav-links-desktop span { display: none; } @@ -1303,9 +2153,11 @@ a:hover { background: rgba(245, 166, 35, 0.1); } -.page-btn-sm:disabled { +.page-btn-sm:disabled, +.page-btn-sm.disabled { opacity: 0.3; cursor: not-allowed; + pointer-events: none; } /* ============ INFINITE SCROLL ============ */ diff --git a/public/img/wojak-logo (2).png b/public/img/wojak-logo (2).png new file mode 100644 index 0000000..08334eb Binary files /dev/null and b/public/img/wojak-logo (2).png differ diff --git a/public/img/wojak-logo.svg b/public/img/wojak-logo.svg new file mode 100644 index 0000000..640e737 --- /dev/null +++ b/public/img/wojak-logo.svg @@ -0,0 +1,151 @@ + + diff --git a/public/js/app.js b/public/js/app.js index 4233852..6b0e2e9 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -85,6 +85,7 @@ function formatBytes(bytes) { // Format hashrate function formatHashrate(hashrate) { + if (hashrate == null || !Number.isFinite(hashrate) || hashrate < 0) return '0 H/s'; if (hashrate >= 1e18) return (hashrate / 1e18).toFixed(2) + ' EH/s'; if (hashrate >= 1e15) return (hashrate / 1e15).toFixed(2) + ' PH/s'; if (hashrate >= 1e12) return (hashrate / 1e12).toFixed(2) + ' TH/s'; @@ -149,6 +150,43 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Mobile nav toggle - hamburger modal (scrollable list) + const navToggle = document.querySelector('.nav-toggle'); + const navModal = document.getElementById('nav-links'); // .nav-modal-panel + const navBackdrop = document.getElementById('nav-backdrop'); + const navCloseBtn = document.getElementById('nav-close-btn'); + + function closeNavModal() { + if (navModal) navModal.classList.remove('open'); + if (navBackdrop) navBackdrop.classList.remove('open'); + if (navToggle) navToggle.setAttribute('aria-expanded', 'false'); + document.body.style.overflow = ''; + } + + if (navToggle && navModal) { + navToggle.addEventListener('click', () => { + const isOpen = navModal.classList.toggle('open'); + if (navBackdrop) navBackdrop.classList.toggle('open', isOpen); + navToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + document.body.style.overflow = isOpen ? 'hidden' : ''; + }); + } + + if (navBackdrop) navBackdrop.addEventListener('click', closeNavModal); + if (navCloseBtn) navCloseBtn.addEventListener('click', closeNavModal); + + if (navModal) { + navModal.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', closeNavModal); + }); + } + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && navModal && navModal.classList.contains('open')) { + closeNavModal(); + } + }); + // Start auto-refresh on dashboard startAutoRefresh(); diff --git a/server.js b/server.js index 8a1edc9..bc94d3c 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,11 @@ const app = express(); const config = { port: process.env.PORT || 3000, electrsApi: process.env.ELECTRS_API || 'http://127.0.0.1:50010', + indexerApi: process.env.INDEXER_API || 'http://127.0.0.1:3070', + // RPC for accurate hashrate (getmininginfo networkhashps) + rpcUrl: process.env.RPC_URL || '', + rpcUser: process.env.RPC_USER || '', + rpcPassword: process.env.RPC_PASSWORD || '', explorerName: process.env.EXPLORER_NAME || 'DedooExplorer', coinName: process.env.COIN_NAME || 'Coin', coinTicker: process.env.COIN_TICKER || 'COIN', @@ -38,6 +43,54 @@ app.set('views', path.join(__dirname, 'views')); // Static files app.use(express.static(path.join(__dirname, 'public'))); +// Favicon - serve logo to avoid 404 +app.get('/favicon.ico', (req, res) => res.redirect(301, '/img/wojak-logo.svg')); + +// Rich List (register early to avoid 404) +app.get('/richlist', async (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 100, 500); + let holders = []; + let totalHolders = 0; + let indexerError = null; + try { + let holdersData; + try { + holdersData = await indexerApiCall(`/holders/top/${limit}`); + } catch (e1) { + try { + holdersData = await indexerApiCall(`/holders?page=1&limit=${limit}`); + } catch (e2) { + indexerError = e2?.message || e1?.message || 'Indexer unavailable'; + } + } + if (holdersData && holdersData.holders) { + holders = holdersData.holders.map((h, i) => ({ ...h, position: i + 1 })); + totalHolders = holdersData.total != null ? holdersData.total : holders.length; + } + } catch (e) { + indexerError = e.message || 'Failed to load holders'; + } + let totalSupply = null; + try { + const supplyData = await apiCall('/blockchain/getsupply').catch(() => null); + if (supplyData) totalSupply = supplyData.total_amount_float ?? supplyData.total_amount ?? null; + } catch (_) {} + try { + return res.render('richlist', { + title: 'Rich List', + holders, + totalHolders, + totalSupply, + limit: holders.length, + indexerError, + page: 'richlist' + }); + } catch (err) { + console.error('Rich list render error:', err); + return res.status(500).render('error', { title: 'Error', message: 'Failed to render rich list', error: err.message, page: 'error' }); + } +}); + // Make config available to all views app.locals.config = config; @@ -75,6 +128,7 @@ const formatDate = (timestamp) => { }; const formatHashrate = (hashrate) => { + if (hashrate == null || !Number.isFinite(hashrate) || hashrate < 0) return '0 H/s'; if (hashrate >= 1e18) return (hashrate / 1e18).toFixed(2) + ' EH/s'; if (hashrate >= 1e15) return (hashrate / 1e15).toFixed(2) + ' PH/s'; if (hashrate >= 1e12) return (hashrate / 1e12).toFixed(2) + ' TH/s'; @@ -92,6 +146,27 @@ const formatDifficulty = (diff) => { return diff.toFixed(2); }; +const formatUptime = (seconds) => { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + const parts = []; + if (d > 0) parts.push(d + 'd'); + if (h > 0) parts.push(h + 'h'); + if (m > 0) parts.push(m + 'm'); + if (s > 0 || parts.length === 0) parts.push(s + 's'); + return parts.join(' '); +}; + +const formatRate = (bytesPerSec) => { + if (bytesPerSec == null || !Number.isFinite(bytesPerSec) || bytesPerSec < 0) return '—'; + if (bytesPerSec < 1 && bytesPerSec > 0) return bytesPerSec.toFixed(2) + ' B/s'; + if (bytesPerSec < 1024) return Math.round(bytesPerSec) + ' B/s'; + return formatBytes(bytesPerSec) + '/s'; +}; + // Make helpers available to all views app.locals.formatHash = formatHash; app.locals.formatNumber = formatNumber; @@ -100,6 +175,8 @@ app.locals.formatTimeAgo = formatTimeAgo; app.locals.formatDate = formatDate; app.locals.formatHashrate = formatHashrate; app.locals.formatDifficulty = formatDifficulty; +app.locals.formatUptime = formatUptime; +app.locals.formatRate = formatRate; // API proxy helper const apiCall = async (endpoint) => { @@ -112,36 +189,93 @@ const apiCall = async (endpoint) => { } }; +// Indexer API helper (holders, etc.) +const indexerApiCall = async (endpoint) => { + try { + const response = await axios.get(`${config.indexerApi}${endpoint}`, { timeout: 10000 }); + return response.data; + } catch (error) { + console.error(`Indexer API Error for ${endpoint}:`, error.message); + throw error; + } +}; + +// RPC call helper (for getmininginfo networkhashps - accurate hashrate) +const rpcCall = async (method, params = []) => { + if (!config.rpcUrl || !config.rpcUser || !config.rpcPassword) return null; + try { + const response = await axios.post(config.rpcUrl, { + jsonrpc: '1.0', + id: 'explorer', + method, + params + }, { + timeout: 5000, + auth: { username: config.rpcUser, password: config.rpcPassword }, + headers: { 'Content-Type': 'text/plain;' } + }); + return response.data?.error ? null : response.data?.result; + } catch (error) { + console.error(`RPC Error ${method}:`, error.message); + return null; + } +}; + +// Get hashrate: prefer RPC networkhashps, fallback to block-based estimate +const getHashrate = async (blocks) => { + const miningInfo = await rpcCall('getmininginfo'); + if (miningInfo != null && Number.isFinite(miningInfo.networkhashps) && miningInfo.networkhashps > 0) { + return miningInfo.networkhashps; + } + // Fallback: estimate from difficulty and avg block time + const blockTimeSec = blocks.length > 1 + ? Math.round((blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / (blocks.length - 1)) + : config.blockTime; + const avgBlockTime = blockTimeSec > 0 ? blockTimeSec : config.blockTime; + const latestDifficulty = blocks[0]?.difficulty || 0; + return latestDifficulty > 0 && avgBlockTime > 0 + ? (latestDifficulty * Math.pow(2, 32)) / avgBlockTime + : 0; +}; + // ============ PAGES ============ // Dashboard app.get('/', async (req, res) => { try { - const [blocks, tipHeight, mempool, supplyData] = await Promise.all([ + const [blocks, tipHeight, mempoolStats, mempoolRecent, supplyData] = await Promise.all([ apiCall('/blocks'), apiCall('/blocks/tip/height'), - apiCall('/mempool/recent').catch(() => []), - apiCall('/blockchain/getsupply').catch(() => ({ total_amount_float: 0 })) + apiCall('/mempool').catch(() => ({ count: 0, vsize: 0, total_fee: 0 })), + apiCall('/mempool/recent').catch(() => []) ]); + const supplyRes = await apiCall('/blockchain/getsupply').catch(() => ({ total_amount_float: 0 })); + const supplyDataRes = supplyRes && typeof supplyRes === 'object' ? supplyRes : { total_amount_float: 0 }; + + // Mempool count: use /mempool count (accurate), fallback to recent list length + const mempoolCount = typeof mempoolStats?.count === 'number' + ? mempoolStats.count + : (Array.isArray(mempoolRecent) ? mempoolRecent.length : 0); // Calculate stats from recent blocks - const avgBlockTime = blocks.length > 1 + const blockTimeSec = blocks.length > 1 ? Math.round((blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / (blocks.length - 1)) - : 120; - - // Estimate hashrate from difficulty (rough estimate) + : config.blockTime; + const avgBlockTime = blockTimeSec > 0 ? blockTimeSec : config.blockTime; const latestDifficulty = blocks[0]?.difficulty || 0; - const hashrate = (latestDifficulty * Math.pow(2, 32)) / avgBlockTime; + + // Hashrate: prefer RPC networkhashps (accurate), fallback to block-based estimate + const hashrate = await getHashrate(blocks); res.render('index', { title: 'Dashboard', blocks: blocks.slice(0, 15), tipHeight, - mempoolCount: mempool.length, + mempoolCount, difficulty: latestDifficulty, avgBlockTime, hashrate, - supply: supplyData.total_amount_float || 0, + supply: supplyDataRes.total_amount_float || supplyDataRes.total_amount || 0, page: 'dashboard' }); } catch (error) { @@ -247,11 +381,70 @@ app.get('/transactions', async (req, res) => { } }); +// Mempool page: stats + paginated pending txs with full detail (from electrs /mempool/txids + /tx/:id) +const MEMPOOL_PAGE_SIZE = 25; +const MEMPOOL_FETCH_CONCURRENCY = 10; + +async function fetchMempoolTxDetails(electrsApi, txids) { + const results = []; + for (let i = 0; i < txids.length; i += MEMPOOL_FETCH_CONCURRENCY) { + const chunk = txids.slice(i, i + MEMPOOL_FETCH_CONCURRENCY); + const batch = await Promise.all( + chunk.map((txid) => + axios.get(`${electrsApi}/tx/${txid}`, { timeout: 8000 }).then((r) => r.data).catch(() => null) + ) + ); + results.push(...batch); + } + return results.filter(Boolean).map((tx) => { + const value = (tx.vout || []).reduce((s, o) => s + (Number(o.value) || 0), 0); + const vsize = tx.weight != null ? Math.ceil(Number(tx.weight) / 4) : null; + return { + txid: tx.txid, + fee: tx.fee != null ? Number(tx.fee) : 0, + vsize: vsize || 0, + value, + }; + }); +} + +app.get('/mempool', async (req, res) => { + try { + const startIndex = Math.max(0, parseInt(req.query.start_index, 10) || 0); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || MEMPOOL_PAGE_SIZE)); + + const [stats, txidsPayload] = await Promise.all([ + apiCall('/mempool').catch(() => ({ count: 0, vsize: 0, total_fee: 0, fee_histogram: [] })), + apiCall(`/mempool/txids?start_index=${startIndex}&limit=${limit}`).catch(() => ({ txids: [], total: 0, start_index: 0, limit: 0 })), + ]); + + const txids = txidsPayload.txids || []; + const total = typeof txidsPayload.total === 'number' ? txidsPayload.total : txids.length; + const pending = txids.length > 0 ? await fetchMempoolTxDetails(ELECTRS_API, txids) : []; + + res.render('mempool', { + title: 'Mempool', + stats, + feeHistogram: stats.fee_histogram || [], + pending, + total, + startIndex, + limit, + page: 'mempool', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load mempool', error: error.message, page: 'error' }); + } +}); + // Transaction detail app.get('/tx/:txid', async (req, res) => { try { const { txid } = req.params; - const tx = await apiCall(`/tx/${txid}`); + const [tx, rawHex] = await Promise.all([ + apiCall(`/tx/${txid}`), + axios.get(`${ELECTRS_API}/tx/${txid}/hex`, { timeout: 5000 }).then(r => r.data).catch(() => null) + ]); // Calculate totals let totalInput = 0, totalOutput = 0; @@ -264,11 +457,24 @@ app.get('/tx/:txid', async (req, res) => { totalOutput += vout.value || 0; }); + // Derived fields for extreme detail view + const vsize = tx.weight != null ? Math.ceil(Number(tx.weight) / 4) : (tx.size || 0); + const feeRateSatPerVb = tx.fee != null && vsize ? (Number(tx.fee) / vsize).toFixed(2) : null; + const locktimeInterpretation = tx.locktime != null + ? (tx.locktime < 500000000 + ? `Block height ${formatNumber(tx.locktime)}` + : `Unix time ${tx.locktime} (${new Date(tx.locktime * 1000).toISOString()} UTC)`) + : null; + res.render('transaction', { title: `Transaction ${formatHash(txid)}`, tx, totalInput, totalOutput, + rawHex: typeof rawHex === 'string' ? rawHex : null, + vsize, + feeRateSatPerVb, + locktimeInterpretation, page: 'transactions' }); } catch (error) { @@ -333,6 +539,255 @@ app.get('/address/:address', async (req, res) => { } }); +// Nodes page +app.get('/nodes', async (req, res) => { + try { + const networkData = await indexerApiCall('/network'); + res.render('nodes', { + title: 'Nodes', + network: networkData, + page: 'nodes' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load node info', error: error.message, page: 'error' }); + } +}); + +// Chain Tips / Forks page +app.get('/chain-tips', async (req, res) => { + try { + const chainTips = await rpcCall('getchaintips'); + const tipHeight = await apiCall('/blocks/tip/height'); + + res.render('chain-tips', { + title: 'Chain Tips', + chainTips: chainTips || [], + tipHeight, + page: 'chain-tips' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load chain tips', error: error.message, page: 'error' }); + } +}); + +// Reorg History page +app.get('/reorgs', async (req, res) => { + try { + const chainTips = await rpcCall('getchaintips'); + const tipHeight = await apiCall('/blocks/tip/height'); + const blocks = await apiCall('/blocks'); + + // Analyze chain tips to identify potential reorgs + const reorgs = []; + if (chainTips && Array.isArray(chainTips)) { + chainTips.forEach(tip => { + if (tip.status === 'valid-fork' || tip.status === 'valid-headers') { + reorgs.push({ + height: tip.height, + hash: tip.hash, + branchlen: tip.branchlen || 0, + status: tip.status, + forkHeight: tip.height - (tip.branchlen || 0) + }); + } + }); + } + + res.render('reorgs', { + title: 'Reorg History', + reorgs: reorgs.sort((a, b) => b.height - a.height), + tipHeight, + page: 'reorgs' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load reorg history', error: error.message, page: 'error' }); + } +}); + +// Orphaned Blocks page +app.get('/orphans', async (req, res) => { + try { + const chainTips = await rpcCall('getchaintips'); + const tipHeight = await apiCall('/blocks/tip/height'); + + // Find orphaned blocks (valid-fork, valid-headers that aren't active) + const orphans = []; + if (chainTips && Array.isArray(chainTips)) { + chainTips.forEach(tip => { + if (tip.status === 'valid-fork' || tip.status === 'valid-headers') { + orphans.push({ + height: tip.height, + hash: tip.hash, + branchlen: tip.branchlen || 0, + status: tip.status + }); + } + }); + } + + res.render('orphans', { + title: 'Orphaned Blocks', + orphans: orphans.sort((a, b) => b.height - a.height), + tipHeight, + page: 'orphans' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load orphaned blocks', error: error.message, page: 'error' }); + } +}); + +// Chain Health page +app.get('/chain-health', async (req, res) => { + try { + const [blockchainInfo, networkInfo, miningInfo, tipHeight] = await Promise.all([ + rpcCall('getblockchaininfo'), + rpcCall('getnetworkinfo'), + rpcCall('getmininginfo'), + apiCall('/blocks/tip/height') + ]); + + const syncProgress = blockchainInfo?.verificationprogress || 0; + const blocksBehind = blockchainInfo ? (blockchainInfo.headers - blockchainInfo.blocks) : 0; + const isSynced = blocksBehind === 0 && syncProgress >= 0.999; + + res.render('chain-health', { + title: 'Chain Health', + blockchainInfo: blockchainInfo || {}, + networkInfo: networkInfo || {}, + miningInfo: miningInfo || {}, + tipHeight, + syncProgress: (syncProgress * 100).toFixed(2), + blocksBehind, + isSynced, + page: 'chain-health' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load chain health', error: error.message, page: 'error' }); + } +}); + +// Network Traffic page (detailed network: bandwidth, rates, connection breakdown, fees — no peer list; see Nodes for peers) +app.get('/network-traffic', async (req, res) => { + try { + const [netTotals, networkInfo, mempoolStats] = await Promise.all([ + rpcCall('getnettotals'), + rpcCall('getnetworkinfo'), + apiCall('/mempool').catch(() => ({ count: 0, vsize: 0, total_fee: 0 })) + ]); + + const totals = netTotals || {}; + const recv = totals.totalbytesrecv != null ? Number(totals.totalbytesrecv) : 0; + const sent = totals.totalbytessent != null ? Number(totals.totalbytessent) : 0; + let rawTime = totals.timemillis != null ? Number(totals.timemillis) : 0; + // Some nodes return timemillis in microseconds; if value is huge, treat as microseconds + const uptimeSec = rawTime >= 1e12 ? rawTime / 1e6 : (rawTime > 0 ? rawTime / 1000 : 0); + const recvRate = uptimeSec > 0 ? recv / uptimeSec : 0; + const sentRate = uptimeSec > 0 ? sent / uptimeSec : 0; + // Cap absurd uptime display (e.g. > 365 days show as-is; if calculation would show 10000+ days, treat as unknown) + const maxReasonableDays = 365 * 10; + const uptimeValid = uptimeSec > 0 && uptimeSec < maxReasonableDays * 86400; + + res.render('network-traffic', { + title: 'Network Traffic', + netTotals: totals, + networkInfo: networkInfo || {}, + recvRateFormatted: formatRate(recvRate), + sentRateFormatted: formatRate(sentRate), + uptimeSec, + uptimeValid, + mempoolStats: mempoolStats || { count: 0, vsize: 0, total_fee: 0 }, + page: 'network-traffic' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load network traffic', error: error.message, page: 'error' }); + } +}); + +// Difficulty History page +app.get('/difficulty', async (req, res) => { + try { + const tipHeight = await apiCall('/blocks/tip/height'); + const currentHeight = typeof tipHeight === 'number' ? tipHeight : parseInt(tipHeight || '0', 10); + + // Fetch blocks for difficulty history (last 100 blocks, sample every 10th) + const difficultyHistory = []; + const sampleInterval = 10; + const maxBlocks = 100; + + for (let i = 0; i < maxBlocks; i += sampleInterval) { + const height = currentHeight - i; + if (height < 0) break; + + try { + const blockHash = await apiCall(`/block-height/${height}`); + const block = await apiCall(`/block/${blockHash}`); + if (block && block.difficulty) { + difficultyHistory.push({ + height: block.height, + timestamp: block.timestamp, + difficulty: block.difficulty, + date: new Date(block.timestamp * 1000).toLocaleDateString() + }); + } + } catch (e) { + // Skip if block not found + continue; + } + } + + difficultyHistory.reverse(); // Oldest first + + res.render('difficulty', { + title: 'Difficulty History', + difficultyHistory, + tipHeight: currentHeight, + page: 'difficulty' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load difficulty history', error: error.message, page: 'error' }); + } +}); + +// API documentation page +app.get('/api-docs', (req, res) => { + const baseUrl = `${req.protocol}://${req.get('host')}/api`; + res.render('api', { + title: 'API', + baseUrl, + page: 'api' + }); +}); + +// Glossary / FAQ page (static content) +app.get('/glossary', (req, res) => { + res.render('glossary', { + title: 'Glossary & FAQ', + page: 'glossary' + }); +}); + +// Holders page +app.get('/holders', async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + + const holdersData = await indexerApiCall(`/holders?page=${page}&limit=${limit}`); + + res.render('holders', { + title: 'Holders', + holders: holdersData.holders || [], + currentPage: holdersData.page || page, + limit: holdersData.limit || limit, + total: holdersData.total || 0, + totalPages: holdersData.total_pages || 1, + page: 'holders' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load holders', error: error.message, page: 'error' }); + } +}); + // Statistics page app.get('/statistics', async (req, res) => { try { @@ -342,12 +797,14 @@ app.get('/statistics', async (req, res) => { ]); // Calculate stats - const avgBlockTime = blocks.length > 1 + const blockTimeSec = blocks.length > 1 ? Math.round((blocks[0].timestamp - blocks[blocks.length - 1].timestamp) / (blocks.length - 1)) - : 120; - + : config.blockTime; + const avgBlockTime = blockTimeSec > 0 ? blockTimeSec : config.blockTime; const latestDifficulty = blocks[0]?.difficulty || 0; - const hashrate = (latestDifficulty * Math.pow(2, 32)) / avgBlockTime; + + // Hashrate: prefer RPC networkhashps (accurate), fallback to block-based estimate + const hashrate = await getHashrate(blocks); // Get daily tx counts (simplified - from recent blocks) const dailyStats = blocks.map(b => ({ @@ -370,6 +827,307 @@ app.get('/statistics', async (req, res) => { } }); +// Emission / halving page +app.get('/emission', async (req, res) => { + try { + const [tipHeight, supplyData] = await Promise.all([ + apiCall('/blocks/tip/height'), + apiCall('/blockchain/getsupply').catch(() => null), + ]); + + const HALVING_INTERVAL = 210000; // from wojakcore chainparams + const INITIAL_SUBSIDY = 100; // WOJAK per block + const blockTimeSec = config.blockTime || 120; + + const maxEpochs = 15; + let reward = INITIAL_SUBSIDY; + let cumulative = 0; + const epochs = []; + + for (let epoch = 0; epoch < maxEpochs && reward > 0; epoch++) { + const startHeight = epoch * HALVING_INTERVAL; + const endHeight = (epoch + 1) * HALVING_INTERVAL - 1; + const blocksInEpoch = HALVING_INTERVAL; + const coinsThisEpoch = reward * blocksInEpoch; + cumulative += coinsThisEpoch; + + epochs.push({ + epoch, + reward, + startHeight, + endHeight, + blocksInEpoch, + coinsThisEpoch, + cumulativeSupply: cumulative, + }); + + reward = reward / 2; + } + + const currentHeight = typeof tipHeight === 'number' ? tipHeight : parseInt(tipHeight || '0', 10); + const currentEpochIndex = Math.floor(currentHeight / HALVING_INTERVAL); + const currentEpoch = epochs[Math.min(currentEpochIndex, epochs.length - 1)]; + const currentReward = currentEpoch ? currentEpoch.reward : 0; + + const nextHalvingHeight = (currentEpochIndex + 1) * HALVING_INTERVAL; + const blocksToHalving = nextHalvingHeight > currentHeight ? (nextHalvingHeight - currentHeight) : 0; + const secondsToHalving = blocksToHalving * blockTimeSec; + const estimatedHalvingDate = blocksToHalving > 0 + ? new Date(Date.now() + secondsToHalving * 1000).toLocaleString() + : null; + + // WojakCoin MAX_MONEY (from wojakcore src/amount.h): 44,210,526 * COIN + const theoreticalMaxSupply = 44210526; + const actualSupply = supplyData && (supplyData.total_amount_float || supplyData.total_amount); + + res.render('emission', { + title: 'Emission', + currentHeight, + currentReward, + nextHalvingHeight, + blocksToHalving, + estimatedHalvingDate, + theoreticalMaxSupply, + actualSupply, + epochs, + halvingInterval: HALVING_INTERVAL, + page: 'emission', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load emission data', error: error.message, page: 'error' }); + } +}); + +// Coin flow: money moving from address to address +const COIN_FLOW_PAGE_SIZE = 30; +const COIN_FLOW_BATCH = 8; + +app.get('/coin-flow', async (req, res) => { + try { + const page = Math.max(0, parseInt(req.query.page) || 0); + const blocks = await apiCall('/blocks'); + const txids = []; + const minTxids = (page + 1) * COIN_FLOW_PAGE_SIZE; + for (const block of blocks.slice(0, 20)) { + try { + const txs = await apiCall(`/block/${block.id}/txs/0`); + const ids = Array.isArray(txs) ? txs.map(t => t.txid || t) : []; + ids.forEach(id => txids.push({ txid: id, blockHeight: block.height, blockTime: block.timestamp })); + if (txids.length >= minTxids) break; + } catch (e) { + continue; + } + } + const start = page * COIN_FLOW_PAGE_SIZE; + const pageTxids = txids.slice(start, start + COIN_FLOW_PAGE_SIZE); + const flows = []; + for (let i = 0; i < pageTxids.length; i += COIN_FLOW_BATCH) { + const batch = pageTxids.slice(i, i + COIN_FLOW_BATCH); + const fullTxs = await Promise.all( + batch.map(({ txid }) => apiCall(`/tx/${txid}`).catch(() => null)) + ); + fullTxs.forEach((tx, idx) => { + if (!tx) return; + const meta = pageTxids[i + idx]; + const from = []; + const to = []; + (tx.vin || []).forEach(vin => { + if (vin.is_coinbase) { + from.push({ address: 'Coinbase (new coins)', value: 0, coinbase: true }); + } else if (vin.prevout) { + const addr = vin.prevout.scriptpubkey_address || null; + const val = vin.prevout.value != null ? vin.prevout.value : 0; + if (addr) from.push({ address: addr, value: val, coinbase: false }); + } + }); + (tx.vout || []).forEach(vout => { + const addr = vout.scriptpubkey_address || (vout.scriptpubkey && vout.scriptpubkey.address) || null; + const val = vout.value != null ? vout.value : 0; + if (addr) to.push({ address: addr, value: val }); + }); + flows.push({ + txid: tx.txid, + blockHeight: meta.blockHeight, + blockTime: meta.blockTime, + from, + to, + fee: tx.fee != null ? tx.fee : null, + status: tx.status + }); + }); + } + const totalPages = Math.ceil(txids.length / COIN_FLOW_PAGE_SIZE) || 1; + res.render('coin-flow', { + title: 'Coin Flow', + flows, + total: txids.length, + currentPage: page, + totalPages, + pageSize: COIN_FLOW_PAGE_SIZE, + page: 'coin-flow' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load coin flow', error: error.message, page: 'error' }); + } +}); + +// Redirect old supply-flow URL to coin-flow (address-to-address flow) +app.get('/supply-flow', (req, res) => res.redirect(301, '/coin-flow')); + +// Legacy supply-over-time page (kept for possible future use; not linked in nav) +app.get('/supply-flow-chart', async (req, res) => { + try { + const [tipHeight, supplyData, indexerSupply] = await Promise.all([ + apiCall('/blocks/tip/height'), + apiCall('/blockchain/getsupply').catch(() => ({ total_amount_float: 0 })), + indexerApiCall('/block/circulating-supply').catch(() => null) + ]); + + // Get circulating supply (prefer indexer, fallback to electrs) + const circulatingSupply = indexerSupply != null ? indexerSupply.circulatingSupply : (supplyData.total_amount_float || 0); + + // Calculate some historical points (simplified - could be enhanced) + const currentReward = config.blockTime ? config.blockTime / 60 : 0; // blocks per hour + const dailyBlocks = 24 * 60 / (config.blockTime || 120); // blocks per day + const dailySupplyIncrease = dailyBlocks * 50; // 50 WJK per block (current reward) + + // Generate projection data (next 365 days) + const projectionDays = 365; + const projectionData = []; + for (let i = 0; i < projectionDays; i += 30) { // Monthly points + const supply = circulatingSupply + (i * dailySupplyIncrease); + projectionData.push({ + days: i, + supply: supply, + date: new Date(Date.now() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + }); + } + + res.render('supply-flow', { + title: 'Supply Flow', + tipHeight, + circulatingSupply, + dailySupplyIncrease, + projectionData, + page: 'supply-flow' + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load supply flow data', error: error.message, page: 'error' }); + } +}); + +// Supply dashboard page +app.get('/supply', async (req, res) => { + try { + const [tipHeight, supplyData, circData] = await Promise.all([ + apiCall('/blocks/tip/height'), + apiCall('/blockchain/getsupply').catch(() => null), + indexerApiCall('/block/circulating-supply').catch(() => null), + ]); + + const HALVING_INTERVAL = 210000; + const INITIAL_SUBSIDY = 100; + const blockTimeSec = config.blockTime || 120; + const MAX_SUPPLY = 44210526; // from wojakcore MAX_MONEY/COIN + + const currentHeight = typeof tipHeight === 'number' ? tipHeight : parseInt(tipHeight || '0', 10); + const totalSupply = supplyData && (supplyData.total_amount_float || supplyData.total_amount) || null; + // Prefer on-chain total for circulating when available so they match + const circulatingSupply = totalSupply != null + ? totalSupply + : (circData && circData.circulatingSupply) || null; + + const epochIndex = currentHeight > 0 ? Math.floor(currentHeight / HALVING_INTERVAL) : 0; + const currentReward = INITIAL_SUBSIDY / Math.pow(2, epochIndex); + const blocksPerDay = 86400 / blockTimeSec; + const dailyIssuance = currentReward * blocksPerDay; + const yearlyIssuance = dailyIssuance * 365; + + const mintedPct = totalSupply ? (totalSupply / MAX_SUPPLY) * 100 : null; + const circPct = circulatingSupply ? (circulatingSupply / MAX_SUPPLY) * 100 : null; + + res.render('supply', { + title: 'Supply', + currentHeight, + totalSupply, + circulatingSupply, + maxSupply: MAX_SUPPLY, + mintedPct, + circPct, + currentReward, + dailyIssuance, + yearlyIssuance, + blocksPerDay, + page: 'supply', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load supply dashboard', error: error.message, page: 'error' }); + } +}); + +// Mining stats page +app.get('/mining', async (req, res) => { + try { + const [blocks, miningInfo] = await Promise.all([ + apiCall('/blocks'), + rpcCall('getmininginfo') + ]); + const recentBlocks = (blocks || []).slice(0, 15); + + const miningBlocks = []; + for (const block of recentBlocks) { + try { + const txs = await apiCall(`/block/${block.id}/txs/0`); + const cbTx = Array.isArray(txs) && txs.length > 0 ? txs[0] : null; + if (!cbTx || !cbTx.vout || !cbTx.vout.length) continue; + + const minerAddress = cbTx.vout[0].scriptpubkey_address || 'Unknown'; + const rewardSats = (cbTx.vout || []).reduce((sum, v) => sum + (v.value || 0), 0); + const reward = rewardSats / 1e8; + + miningBlocks.push({ + height: block.height, + hash: block.id, + timestamp: block.timestamp, + txCount: block.tx_count, + difficulty: block.difficulty, + minerAddress, + reward, + }); + } catch (e) { + continue; + } + } + + const minerMap = {}; + miningBlocks.forEach((b) => { + const key = b.minerAddress || 'Unknown'; + if (!minerMap[key]) { + minerMap[key] = { minerAddress: key, blocks: 0, totalReward: 0 }; + } + minerMap[key].blocks += 1; + minerMap[key].totalReward += b.reward || 0; + }); + + const miners = Object.values(minerMap).sort((a, b) => b.blocks - a.blocks); + const totalBlocks = miningBlocks.length || 1; + const hashrate = miningInfo?.networkhashps != null ? miningInfo.networkhashps : 0; + const difficulty = miningInfo?.difficulty ?? (recentBlocks[0]?.difficulty) ?? 0; + + res.render('mining', { + title: 'Mining Stats', + blocks: miningBlocks, + miners, + totalBlocks, + hashrate, + difficulty, + page: 'mining', + }); + } catch (error) { + res.render('error', { title: 'Error', message: 'Failed to load mining stats', error: error.message, page: 'error' }); + } +}); + // Search handler app.get('/search', async (req, res) => { const query = req.query.q?.trim(); diff --git a/views/api.ejs b/views/api.ejs new file mode 100644 index 0000000..4a98440 --- /dev/null +++ b/views/api.ejs @@ -0,0 +1,210 @@ +<%- include('partials/header') %> + +
Electrs REST API for <%= config.coinName %>. All amounts in satoshis.
+<%= baseUrl %>
+
+ /blocks
+ 10 newest blocks. Add /:start_height for blocks starting at height.
curl <%= baseUrl %>/blocks
+
+ /blocks/tip/height
+ Current block height.
+curl <%= baseUrl %>/blocks/tip/height
+
+ /blocks/tip/hash
+ Current block hash.
+curl <%= baseUrl %>/blocks/tip/hash
+
+ /block/:hash
+ Block info by hash.
+curl <%= baseUrl %>/block/<hash>
+
+ /block/:hash/txs/:start_index
+ Transactions in block (25 per page, start_index must be multiple of 25).
+curl <%= baseUrl %>/block/<hash>/txs/0
+
+ /block-height/:height
+ Block hash at given height.
+curl <%= baseUrl %>/block-height/15800
+
+ /tx/:txid
+ Transaction details.
+curl <%= baseUrl %>/tx/<txid>
+
+ /tx/:txid/status
+ Transaction confirmation status.
+curl <%= baseUrl %>/tx/<txid>/status
+
+ /tx/:txid/hex
+ Raw transaction hex.
+/tx/:txid/outspends
+ Spending status of all outputs.
+/tx
+ Broadcast raw transaction (hex in body).
+curl -X POST <%= baseUrl %>/tx -d "02000000..."
+
+ /address/:address
+ Address stats (chain_stats, mempool_stats).
+curl <%= baseUrl %>/address/<address>
+
+ /address/:address/txs
+ Transaction history (50 mempool + 25 confirmed). Add ?start_index=N&limit=25 for pagination.
curl "<%= baseUrl %>/address/<address>/txs?start_index=0&limit=25"
+
+ /address/:address/utxo
+ Unspent outputs. Add ?start_index=N&limit=25 for pagination.
curl "<%= baseUrl %>/address/<address>/utxo?start_index=0&limit=25"
+
+ /mempool
+ Mempool stats (count, vsize, total_fee, fee_histogram).
+curl <%= baseUrl %>/mempool
+
+ /mempool/recent
+ Last 10 transactions to enter mempool.
+curl <%= baseUrl %>/mempool/recent
+
+ /mempool/txids
+ All txids in mempool.
+/fee-estimates
+ Fee estimates by confirmation target (sat/vB).
+curl <%= baseUrl %>/fee-estimates
+
+ /blockchain/getsupply
+ Total supply (if supported by backend).
+curl <%= baseUrl %>/blockchain/getsupply
+
+ Monitor blockchain synchronization and network health
+| ID | +Version | +Status | +Found | +Required | +
|---|---|---|---|---|
| <%= fork.id %> | +<%= fork.version %> | ++ + <%= fork.enforce && fork.enforce.status ? 'Enforced' : 'Pending' %> + + | +<%= fork.enforce ? fork.enforce.found : '--' %> / <%= fork.enforce ? fork.enforce.window : '--' %> | +<%= fork.enforce ? fork.enforce.required : '--' %> | +
Monitor active chain forks and potential reorganizations
+| Height | +Hash | +Branch Length | +Status | +Fork Height | +
|---|---|---|---|---|
|
+
+ No active forks detected. Chain is healthy. + |
+ ||||
| <%= formatNumber(tip.height) %> | ++ + <%= formatHash(tip.hash, 20) %> + + | +<%= formatNumber(tip.branchlen || 0) %> | ++ + <%= tip.status %> + + | ++ <%= tip.branchlen ? formatNumber(tip.height - (tip.branchlen || 0)) : '--' %> + | +
See money moving from address to address — the flow of coins on the <%= config.coinName %> blockchain.
+No transactions to show. Try again later.
+ <% } else { %> + <% flows.forEach(function(flow) { + const totalOut = (flow.to || []).reduce(function(s, o) { return s + (o.value || 0); }, 0); + %> +Track mining difficulty adjustments over time
+| Height | +Date | +Difficulty | +Change | +
|---|---|---|---|
|
+
+ No difficulty history available. + |
+ |||
| <%= formatNumber(entry.height) %> | +<%= formatDate(entry.timestamp) %> | +<%= formatDifficulty(entry.difficulty) %> | ++ <% if (index > 0) { + const prev = difficultyHistory[difficultyHistory.length - index]; + const change = ((entry.difficulty - prev.difficulty) / prev.difficulty * 100).toFixed(2); + %> + + <%= change > 0 ? '+' : '' %><%= change %>% + + <% } else { %> + -- + <% } %> + | +
Block reward schedule and total supply for <%= config.coinName %>.
+| Epoch | +Reward (per block) | +Block Range | +Blocks | +Coins This Epoch | +Cumulative Supply | +
|---|---|---|---|---|---|
|
+
+ No halving schedule data. + |
+ |||||
| #<%= e.epoch %> | +<%= e.reward.toFixed(2) %> <%= config.coinTicker %> | +<%= formatNumber(e.startHeight) %> - <%= formatNumber(e.endHeight) %> | +<%= formatNumber(e.blocksInEpoch) %> | +<%= formatNumber(Math.round(e.coinsThisEpoch)) %> <%= config.coinTicker %> | +<%= formatNumber(Math.round(e.cumulativeSupply)) %> <%= config.coinTicker %> | +
Definitions and frequently asked questions about <%= config.coinName %> and this explorer
+Use the search bar at the top: paste your transaction ID (txid) or address. You can also go to Transactions to browse recent and mempool transactions.
+The transaction is in the mempool but not yet included in a block. After it is mined into a block, it becomes confirmed. More blocks on top of it mean more confirmations.
+The Rich List and address balances are based on the sum of UTXOs (unspent outputs) that belong to each address. They require the indexer to be running.
+The explorer gets network hashrate from the node’s getmininginfo RPC (networkhashps). It reflects the estimated total hashing power securing the chain.
Chain tips are the ends of known chains (including the main chain and any forks). A reorg happens when the main chain switches to a different tip, undoing some recent blocks. See Chain Tips and Reorgs for details.
+Yes. The explorer proxies blockchain data; see API for endpoints. Typical usage: blocks, transactions, addresses, and mempool.
+Addresses with <%= config.coinTicker %> balance on the <%= config.coinName %> blockchain
+| # | +Address | +Balance | +Last Seen | +
|---|---|---|---|
|
+
+ No holders data available. Ensure the indexer is running. + |
+ |||
| <%= holder.position || '--' %> | ++ + <%= formatHash(holder.address, 20) %> + + + | ++ + <%= (holder.balance || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 8 }) %> + <%= config.coinTicker %> + + | +
+ <% if (holder.last_seen) { %>
+
+ <%= formatTimeAgo(holder.last_seen) %>
+ <%= formatDate(holder.last_seen) %>
+
+ <% } else { %>
+ --
+ <% } %>
+ |
+
Historical and projected circulating supply trends for <%= config.coinName %>
+Historical data combined with future projections
+Detailed breakdown of supply growth over the next year
+| Days | +Date | +Projected Supply | +Monthly Increase | +
|---|---|---|---|
| <%= row.days %> | +<%= row.date %> | +<%= row.supply.toLocaleString() %> | +<%= index > 0 ? (row.supply - projectionData[index-1].supply).toLocaleString() : '0' %> | +