git / moonbite-network / templates/explorer.html · raw
{% extends "base.html" %}{# No mb-v2 body class: its legacy layer recolours every .mono gold and sets every h2/h3 in the display face, both against d03/d04. The shell (body.mbs-page) and the ds- kit carry this page. #}{% block title %}Block explorer — MoonBite{% endblock %}{% block meta_description %}Browse the live MoonBite chain. Search by block height, block hash or transaction ID. Confirmations as a count, times in UTC, every hash copyable.{% endblock %}{% block nav_active %}explorer{% endblock %}{% block head_extra %}<style nonce="{{ csp_nonce }}"> /* Explorer (d19): composes the ds- kit; only the rules below are page-specific. */ .ds .hidden { display: none !important; } /* guards against the legacy base layers (moonbite-v2.css headings, shell input font + focus ring) */ body .ds h2.ds-h2 { font-family: var(--ds-sans); font-weight: 800; letter-spacing: -.02em; } body .ds h3.ds-h3 { font-family: var(--ds-sans); font-weight: 700; letter-spacing: -.01em; } body.mbs-page main .ds .ds-input.mono { font-family: var(--ds-mono); } body.mbs-page main .ds .ds-input:focus { border-color: var(--ds-gold); box-shadow: none; } .ex-detail .ds-h2:focus { outline: none; } .ex-head { padding: 48px 0 64px; } .ex-head .ds-title { font-size: clamp(36px, 4.8vw, 60px); } .ex-search { margin-top: 32px; max-width: 720px; } .ex-search .ds-btn { min-width: 128px; } .ex-hint { margin: 8px 0 0; font-size: 13px; color: var(--ds-ash); } .ex-art { display: block; width: 100%; height: auto; } .ex-status { display: flex; align-items: center; gap: 12px; margin-top: 16px; max-width: 720px; padding: 12px 16px; border-radius: 6px; border: 1px solid var(--ds-line-2); background: var(--ds-slate); color: var(--ds-bone); font-size: 14px; } .ex-status::before { content: ""; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--ds-ash); } .ex-status.error::before { background: var(--ds-loss); } .ex-status.success::before { background: var(--ds-gain); } .ex-rules { border-top: 0; } .ex-rules .v { overflow: hidden; text-overflow: ellipsis; } .ex-browse, .ex-detail { padding-top: 64px; } .ex-table td { vertical-align: middle; white-space: nowrap; } .ex-table td.wrap { white-space: normal; overflow-wrap: anywhere; min-width: 22ch; } .ex-table tbody tr[data-block], .ex-table tbody tr[data-tx] { cursor: pointer; } .ex-table .tag-cell .ds-tag { vertical-align: middle; } .ds .ex-link { color: var(--ds-bone); border-bottom: 1px solid var(--ds-line-2); transition: border-color var(--ds-t1) var(--ds-ease); } .ds .ex-link:hover { border-color: var(--ds-bone); } .ex-hv { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } .ex-hv > :first-child { min-width: 0; overflow-wrap: anywhere; } .ds .ex-copy { min-height: 44px; min-width: 64px; } .ex-more { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-top: 24px; } .ex-more .ds-note { margin-top: 0; } .ex-back { margin-bottom: 32px; } .ex-kv { margin-top: 32px; } .ex-nav { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; } .ex-sub { margin-top: 64px; } .ex-sub + .ds-table-wrap { margin-top: 16px; } .ex-callout { margin-top: 32px; } @media (max-width: 1023px) { .ex-art-wrap { display: none; } } @media (max-width: 767px) { .ex-hide-sm { display: none; } .ex-search .ds-btn { width: 100%; } }</style>{% endblock %}{% block content %}<div class="ds"> <!-- ================= HEAD: search is the hero ================= --> <header class="ds-pagehead split ex-head"> <div class="ds-wrap"> <div> <ol class="ds-crumbs"> <li><a href="/">MoonBite</a></li> <li><span aria-current="page">Explorer</span></li> </ol> <p class="ds-eyebrow">Block explorer</p> <h1 class="ds-title">The live chain,<br><span class="g">block by block.</span></h1> <p class="ds-lede">Browse the live chain, or search by block height, block hash or transaction ID. A new block is mined about every <span class="mono">{{ consensus.block_time_min }}</span> minutes.</p> <form id="searchForm" class="ex-search" role="search"> <div class="ds-field"> <label for="searchInput">Block height, block hash or transaction ID</label> <div class="ds-search"> <input type="text" id="searchInput" class="ds-input mono" placeholder="e.g. 0, or a full 64-character hash" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="searchHint"> <button type="submit" class="ds-btn gold"> <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M20 20l-4-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Search </button> </div> <p class="ex-hint" id="searchHint">A height is a whole number (<span class="mono">0</span> is genesis). Hashes and transaction IDs are 64 hex characters.</p> </div> </form> <div id="statusMessage" class="ex-status hidden" role="status" aria-live="polite"></div> <ul class="ds-checks"> <li>Confirmations as a count</li> <li>Times in UTC</li> <li>Every hash copyable</li> </ul> </div> <div class="ds-stage ex-art-wrap" aria-hidden="true"> <svg class="ex-art" viewBox="0 0 400 300" aria-hidden="true"> <g fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <rect x="20" y="64" width="96" height="120" rx="10" stroke="#8A8580"/> <rect x="152" y="64" width="96" height="120" rx="10" stroke="#8A8580"/> <rect x="284" y="64" width="96" height="120" rx="10" stroke="#F5F2EA" fill="#0B0D12"/> <path d="M116 124h36M248 124h36" stroke="#8A8580"/> <path d="M144 118l8 6-8 6M276 118l8 6-8 6" stroke="#8A8580"/> </g> <g fill="#8A8580"> <rect x="36" y="84" width="56" height="8" rx="4"/><rect x="36" y="100" width="40" height="6" rx="3"/> <rect x="168" y="84" width="56" height="8" rx="4"/><rect x="168" y="100" width="40" height="6" rx="3"/> </g> <g fill="#F5F2EA"><rect x="300" y="84" width="56" height="8" rx="4"/><rect x="300" y="100" width="40" height="6" rx="3"/></g> <g stroke="#8A8580" stroke-width="1"><path d="M36 124h64M36 140h64M36 156h48M168 124h64M168 140h64M168 156h48M300 124h64M300 140h64M300 156h48"/></g> <g font-family="ui-monospace,Menlo,Consolas,monospace" font-size="10" letter-spacing="1.5" fill="#8A8580"> <text x="20" y="52">PREV</text><text x="152" y="52">BLOCK</text><text x="284" y="52">TIP</text> </g> <rect x="20" y="224" width="360" height="44" rx="6" fill="#0F1218" stroke="#8A8580" stroke-width="1"/> <g fill="none" stroke="#F5F2EA" stroke-width="2" stroke-linecap="round"><circle cx="44" cy="245" r="7"/><path d="M50 251l5 5"/></g> <g fill="#8A8580"><rect x="68" y="242" width="120" height="6" rx="3"/></g> </svg> </div> </div> </header> <!-- ================= LIVE STATS ================= --> <section class="ds-rules ex-rules" aria-label="Chain status"> <div class="ds-wrap"> <div class="ds-rule"> <div class="n">Chain height</div> <div class="v" id="statHeight">--</div> <p>Latest block</p> </div> <div class="ds-rule"> <div class="n">Total blocks</div> <div class="v" id="statBlocks">--</div> <p>Including genesis</p> </div> <div class="ds-rule"> <div class="n">Mempool</div> <div class="v" id="statMempool">--</div> <p>Pending transactions</p> </div> <div class="ds-rule"> <div class="n">Last block</div> <div class="v" id="statLast">--</div> <p id="statLastDate">Block timestamp, UTC</p> </div> </div> </section> <!-- ================= BROWSE: latest blocks ================= --> <section id="browseView" class="ds-section ex-browse" aria-labelledby="browseTitle"> <div class="ds-wrap"> <div class="ds-head"> <div> <p class="ds-eyebrow">Browse</p> <h2 class="ds-h2" id="browseTitle">Latest blocks, <span class="dim">newest first.</span></h2> </div> <p class="ds-chip"><span class="tag"><span class="ds-dot" id="liveDot"></span>Live</span>Refreshes every <span class="mono">5</span> s</p> </div> <div class="ds-table-wrap"> <table class="ds-table ex-table"> <caption class="sr">Latest blocks, newest first. Times in UTC.</caption> <thead> <tr> <th scope="col">Height</th> <th scope="col">Block hash</th> <th scope="col" class="num">Confirmations</th> <th scope="col" class="num">Transactions</th> <th scope="col" class="num ex-hide-sm">Nonce</th> <th scope="col">Time (UTC)</th> </tr> </thead> <tbody id="blocksList"> <tr><td colspan="6" class="dim">Loading blocks…</td></tr> </tbody> </table> </div> <div class="ex-more"> <p class="ds-note">This list follows the node's active chain. Orphaned blocks are not listed here yet.</p> <button type="button" id="loadMoreBtn" class="ds-btn sm hidden" data-act="load-more">Load more blocks</button> </div> </div> </section> <!-- ================= DETAIL: one block or one transaction ================= --> <section id="detailView" class="ds-section ex-detail hidden" aria-labelledby="detailTitle"> <div class="ds-wrap"> <button type="button" id="backBtn" class="ds-btn sm ex-back hidden" data-act="browse"> <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Back to latest blocks </button> <p class="ds-eyebrow" id="detailKind">Details</p> <h2 class="ds-h2" id="detailTitle" tabindex="-1">Details</h2> <div id="detailBody"></div> </div> </section> <!-- ================= LEGEND ================= --> <section class="ds-section alt" aria-labelledby="readTitle"> <div class="ds-wrap"> <p class="ds-eyebrow">Reading the explorer</p> <h2 class="ds-h2" id="readTitle">What the numbers mean.</h2> <div class="ds-grid c4"> <div class="ds-card"><div class="body"> <p class="n">01</p> <p class="t">Confirmations</p> <p class="d">A count, never a status word. A block's own confirmation plus every block mined on top of it.</p> </div></div> <div class="ds-card"><div class="body"> <p class="n">02</p> <p class="t">Times</p> <p class="d">Every time is the block header's timestamp, shown in UTC and labelled as UTC.</p> </div></div> <div class="ds-card"><div class="body"> <p class="n">03</p> <p class="t">Amounts</p> <p class="d">Shown in MBITE to <span class="mono">8</span> decimals, trailing zeros trimmed. Never rounded down.</p> </div></div> <div class="ds-card"><div class="body"> <p class="n">04</p> <p class="t">Links</p> <p class="d">Every block and transaction opens at its own link. Copy the page URL to share exactly what you see.</p> </div></div> </div> </div> </section></div>{% endblock %}{% block scripts_extra %}<script nonce="{{ csp_nonce }}"> let currentOffset = 0; const PAGE_SIZE = 15; const ZERO_HASH = '0'.repeat(64); let statusTimer = null; let hashUid = 0; function showStatus(message, type = 'info') { const el = document.getElementById('statusMessage'); el.textContent = message; el.className = `ex-status ${type}`; el.classList.remove('hidden'); clearTimeout(statusTimer); statusTimer = setTimeout(() => el.classList.add('hidden'), 5000); } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // Missing values render as "--", never as "undefined". function val(x) { return (x === null || x === undefined || x === '') ? '--' : esc(x); } function shortHash(h) { if (!h) return '--'; return h.length > 20 ? `${h.substring(0, 10)}…${h.substring(h.length - 8)}` : h; } // d17: hashes and addresses are read by screen readers in groups of four, // not character by character. function chunk(h) { return String(h).match(/.{1,4}/g).join(' '); } // Base units per coin: 100 on the educational chain, 1e8 on MoonBite Core. // /api/blockchain/info reports it; every payload also carries units_per_coin. // Default matches the real chain (1 MBITE = 1e8 units) rather than a value // off by 1,000,000x, in case anything renders before the first stats fetch. let UNITS_PER_COIN = 100000000; // d18: never round a balance down for display. Math.floor(sats/UNITS) used // to discard every fractional MBITE, so any amount under one whole coin // rendered as "0 MBITE" - on the one page whose entire value is honesty // about what the chain actually did (d19). 8dp per d11, trimmed of // trailing zeros, same convention the wallet uses. function fmtCoins(sats) { const coins = (sats || 0) / UNITS_PER_COIN; const s = coins.toFixed(8).replace(/0+$/, '').replace(/\.$/, ''); return `${s || '0'} MBITE`; } // d11: times are UTC, always labelled - not the viewer's local zone with // no indication of which zone it is. function fmtTime(ts) { try { const d = new Date(ts * 1000); const pad = (n) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`; } catch (e) { return ts; } } // ---- Links: every block and transaction has a URL (#block/<id>, #tx/<txid>) ---- function hrefFor(kind, id) { return `#${kind}/${encodeURIComponent(id)}`; } // A hash (or address) in full, mono, with a one-click copy button and an // optional link to its own view. The copy target holds only the raw value. function hashVal(h, link, label) { if (!h) return '<span class="dim">--</span>'; const id = `exh-${++hashUid}`; const txt = `<span id="${id}" aria-hidden="true">${esc(h)}</span><span class="sr">${esc(chunk(h))}</span>`; const body = link ? `<a class="ex-link" href="${hrefFor(link.kind, link.id)}" data-${link.kind}="${esc(link.id)}">${txt}</a>` : `<span>${txt}</span>`; return `<span class="ex-hv">${body}<button type="button" class="ds-copy ex-copy" data-copy="#${id}" aria-label="Copy ${esc(label)}">Copy</button></span>`; } // Short form for table rows: visible prefix…suffix, readable label for AT. function shortLink(kind, h, label) { if (!h) return '<span class="dim">--</span>'; const sr = h.length > 20 ? `${label} ${chunk(h.substring(0, 8))}, ending ${chunk(h.substring(h.length - 8))}` : `${label} ${chunk(h)}`; return `<a class="ex-link mono" href="${hrefFor(kind, h)}" data-${kind}="${esc(h)}"><span aria-hidden="true">${esc(shortHash(h))}</span><span class="sr">${esc(sr)}</span></a>`; } function confHTML(n) { const c = Number(n) || 0; return c > 0 ? `${esc(c)} <span class="ds-tag ok">confirmed</span>` : `0 <span class="ds-tag">in mempool</span>`; } // ---- Chain stats ---- async function loadStats() { const dot = document.getElementById('liveDot'); try { const r = await fetch('/api/blockchain/info'); const d = await r.json(); if (d.status === 'success') { if (d.units_per_coin) { UNITS_PER_COIN = d.units_per_coin; } document.getElementById('statHeight').textContent = d.height; document.getElementById('statMempool').textContent = d.mempool_size; dot.classList.add('on'); } else { dot.classList.remove('on'); } } catch (e) { console.error(e); dot.classList.remove('on'); } } // ---- Browse: latest blocks ---- function blockRow(b) { const h = esc(b.height); const size = b.size ? ` <span class="dim">· ${esc(b.size)} bytes</span>` : ''; return ` <tr data-block="${h}"> <td class="mono"><a class="ex-link" href="${hrefFor('block', b.height)}" data-block="${h}"><span class="sr">Block </span>${h}</a></td> <td class="mono dim" title="${esc(b.hash || '')}"><span aria-hidden="true">${esc(shortHash(b.hash))}</span></td> <td class="num">${val(b.confirmations)}</td> <td class="num">${val(b.tx_count)}${size}</td> <td class="num ex-hide-sm">${val(b.nonce)}</td> <td class="mono">${esc(fmtTime(b.timestamp))}</td> </tr>`; } async function loadBlocks(reset = true) { if (reset) { currentOffset = 0; } try { const r = await fetch(`/api/explorer/blocks?limit=${PAGE_SIZE}&offset=${currentOffset}`); const d = await r.json(); if (d.status !== 'success') { showStatus(d.message || 'Failed to load blocks', 'error'); return; } document.getElementById('statBlocks').textContent = d.total; if (reset && d.blocks.length) { // Tip timestamp: clock in the stat, date + zone label beneath it. const t = fmtTime(d.blocks[0].timestamp); const parts = String(t).split(' '); document.getElementById('statLast').textContent = parts[1] || t; document.getElementById('statLastDate').textContent = parts[0] ? `${parts[0]}, UTC` : 'Block timestamp, UTC'; } const rows = d.blocks.map(blockRow).join(''); const list = document.getElementById('blocksList'); if (reset) { list.innerHTML = rows || '<tr><td colspan="6" class="dim">No blocks yet.</td></tr>'; } else { list.insertAdjacentHTML('beforeend', rows); } currentOffset += d.blocks.length; const more = document.getElementById('loadMoreBtn'); more.classList.toggle('hidden', currentOffset >= d.total); } catch (e) { console.error(e); showStatus('Network error loading blocks', 'error'); } } function loadMoreBlocks() { loadBlocks(false); } function scrollTop() { document.body.scrollTop = 0; document.documentElement.scrollTop = 0; window.scrollTo({ top: 0, behavior: 'smooth' }); } function showBrowse(push = true) { document.getElementById('browseView').classList.remove('hidden'); document.getElementById('detailView').classList.add('hidden'); document.getElementById('backBtn').classList.add('hidden'); if (push && location.hash) { history.pushState(null, '', location.pathname + location.search); } scrollTop(); } function showDetail(title, html, kind = 'Details', route = null) { document.getElementById('detailKind').textContent = kind; document.getElementById('detailTitle').innerHTML = title; document.getElementById('detailBody').innerHTML = html; document.getElementById('browseView').classList.add('hidden'); document.getElementById('detailView').classList.remove('hidden'); document.getElementById('backBtn').classList.remove('hidden'); if (route && location.hash !== route) { history.pushState(null, '', route); } try { document.getElementById('detailTitle').focus({ preventScroll: true }); } catch (e) { /* older browsers */ } // This layout scrolls <body> itself (mobile-sheet pattern), not the // window/viewport, so window.scrollTo() alone was a no-op: clicking a // transaction from partway down a block's list landed on the new // page still scrolled to that same depth, with its own header and // search box scrolled out of view above. Reset both, since which one // is the real scroller can vary by browser/layout. scrollTop(); } function kv(label, value) { return `<dt>${esc(label)}</dt><dd>${value}</dd>`; } function isCoinbaseInput(i) { return i.coinbase === true || i.prev_txid === ZERO_HASH || i.prev_txid === null || i.prev_txid === undefined; } function txRow(tx, n) { return ` <tr data-tx="${esc(tx.txid)}"> <td class="mono dim">${n}</td> <td>${shortLink('tx', tx.txid, 'Transaction')}</td> <td class="tag-cell">${tx.is_coinbase ? '<span class="ds-tag">coinbase</span>' : '<span class="dim">--</span>'}</td> <td class="num">${val(tx.input_count)} → ${val(tx.output_count)}</td> <td class="num">${esc(fmtCoins(tx.total_out))}</td> </tr>`; } async function openBlock(id, push = true) { try { const r = await fetch(`/api/explorer/block/${encodeURIComponent(id)}`); const d = await r.json(); if (d.status !== 'success') { showStatus(d.message || 'Block not found', 'error'); return; } const b = d.block; const txs = b.transactions || []; const height = Number(b.height); const prevIsReal = b.prev_hash && b.prev_hash !== ZERO_HASH; const meta = `<dl class="ds-kv ex-kv"> ${kv('Height', `#${val(b.height)}`)} ${kv('Confirmations', confHTML(b.confirmations))} ${kv('Transactions', val(b.tx_count))} ${kv('Size', b.size ? `${esc(b.size)} bytes` : '--')} ${kv('Timestamp', esc(fmtTime(b.timestamp)))} ${kv('Nonce', val(b.nonce))} ${kv('Bits', val(b.bits))} ${b.difficulty !== undefined && b.difficulty !== null ? kv('Difficulty', val(b.difficulty)) : ''} ${b.subsidy !== undefined && b.subsidy !== null ? kv('Subsidy', esc(fmtCoins(b.subsidy))) : ''} ${kv('Hash', hashVal(b.hash, null, 'block hash'))} ${kv('Prev Hash', prevIsReal ? hashVal(b.prev_hash, { kind: 'block', id: b.prev_hash }, 'previous block hash') : '<span class="dim">-- (genesis)</span>')} ${kv('Merkle Root', hashVal(b.merkle_root, null, 'merkle root'))} </dl>`; const nav = []; if (height > 0) { nav.push(`<a class="ds-btn sm" href="${hrefFor('block', height - 1)}" data-block="${height - 1}">← Block <span class="mono">${height - 1}</span></a>`); } if (Number(b.confirmations) > 1) { nav.push(`<a class="ds-btn sm" href="${hrefFor('block', height + 1)}" data-block="${height + 1}">Block <span class="mono">${height + 1}</span> →</a>`); } const navHTML = nav.length ? `<nav class="ex-nav" aria-label="Neighbouring blocks">${nav.join('')}</nav>` : ''; const err = b.transactions_error ? `<div class="ds-callout warn ex-callout"><p class="label">Transactions unavailable</p><p>The node could not return this block's transaction list just now. The header above is complete. Try again in a moment.</p></div>` : ''; const table = txs.length ? `<div class="ds-table-wrap"><table class="ds-table ex-table"> <caption class="sr">Transactions in block ${esc(b.height)}</caption> <thead><tr><th scope="col">#</th><th scope="col">Transaction ID</th><th scope="col">Type</th><th scope="col" class="num">In → Out</th><th scope="col" class="num">Total out</th></tr></thead> <tbody>${txs.map((t, i) => txRow(t, i)).join('')}</tbody></table></div>` : ''; const body = meta + navHTML + `<h3 class="ds-h3 ex-sub">Transactions <span class="mono dim">(${txs.length})</span></h3>` + err + table; showDetail(`Block <span class="mono">#${esc(b.height)}</span>`, body, 'Block', push ? hrefFor('block', id) : null); } catch (e) { console.error(e); showStatus('Network error loading block', 'error'); } } async function openTx(txid, push = true) { try { const r = await fetch(`/api/explorer/tx/${encodeURIComponent(txid)}`); const d = await r.json(); if (d.status !== 'success') { showStatus(d.message || 'Transaction not found', 'error'); return; } const t = d.transaction; const statusTag = t.status === 'confirmed' ? '<span class="ds-tag ok">confirmed</span>' : `<span class="ds-tag">${val(t.status)}</span>`; const meta = `<dl class="ds-kv ex-kv"> ${kv('Status', statusTag)} ${kv('Confirmations', esc(Number(t.confirmations) || 0))} ${kv('Coinbase', t.is_coinbase ? 'Yes' : 'No')} ${kv('Total Out', esc(fmtCoins(t.total_out)))} ${t.block_height !== undefined ? kv('In Block', `<a class="ex-link" href="${hrefFor('block', t.block_height)}" data-block="${esc(t.block_height)}">#${esc(t.block_height)}</a>`) : ''} ${t.block_hash ? kv('Block Hash', hashVal(t.block_hash, { kind: 'block', id: t.block_hash }, 'block hash')) : ''} ${t.timestamp ? kv('Timestamp', esc(fmtTime(t.timestamp))) : ''} ${t.size ? kv('Size', `${esc(t.size)} bytes`) : ''} ${kv('TXID', hashVal(t.txid, null, 'transaction ID'))} </dl>`; const ins = t.inputs.map((i, n) => isCoinbaseInput(i) ? `<tr><td class="mono dim">${n}</td><td>Coinbase <span class="dim">(newly mined coins)</span></td></tr>` : `<tr><td class="mono dim">${n}</td><td class="mono">${shortLink('tx', i.prev_txid, 'Transaction')}<span class="dim">:${val(i.output_index)}</span></td></tr>` ).join(''); const outs = t.outputs.map((o, n) => ` <tr> <td class="mono dim">${n}</td> <td class="wrap mono">${o.address ? hashVal(o.address, null, 'address') : `<span class="dim" title="${esc(o.pubkey_hash || '')}">${esc(shortHash(o.pubkey_hash))}</span>`}</td> <td class="mono dim ex-hide-sm">${val(o.type)}</td> <td class="num">${esc(fmtCoins(o.amount))}</td> </tr>`).join(''); const body = meta + `<h3 class="ds-h3 ex-sub">Inputs <span class="mono dim">(${val(t.input_count)})</span></h3>` + `<div class="ds-table-wrap"><table class="ds-table ex-table"><caption class="sr">Inputs</caption>` + `<thead><tr><th scope="col">#</th><th scope="col">Previous output</th></tr></thead><tbody>${ins}</tbody></table></div>` + `<h3 class="ds-h3 ex-sub">Outputs <span class="mono dim">(${val(t.output_count)})</span></h3>` + `<div class="ds-table-wrap"><table class="ds-table ex-table"><caption class="sr">Outputs</caption>` + `<thead><tr><th scope="col">#</th><th scope="col">Address</th><th scope="col" class="ex-hide-sm">Type</th><th scope="col" class="num">Amount</th></tr></thead><tbody>${outs}</tbody></table></div>`; showDetail('Transaction', body, 'Transaction', push ? hrefFor('tx', txid) : null); } catch (e) { console.error(e); showStatus('Network error loading transaction', 'error'); } } // ---- Routing: #block/<height|hash> and #tx/<txid> open that view ---- function route() { const m = /^#(block|tx)\/(.+)$/.exec(location.hash); if (m) { let id; try { id = decodeURIComponent(m[2]); } catch (e) { showStatus('Not a valid link', 'error'); return; } if (m[1] === 'block') { openBlock(id, false); } else { openTx(id, false); } } else if (!document.getElementById('detailView').classList.contains('hidden')) { showBrowse(false); } } window.addEventListener('popstate', route); // ---- Search ---- document.getElementById('searchForm').addEventListener('submit', async (e) => { e.preventDefault(); const q = document.getElementById('searchInput').value.trim(); if (!q) { showStatus('Enter a height, hash, or txid', 'error'); return; } try { const r = await fetch(`/api/explorer/search?q=${encodeURIComponent(q)}`); const d = await r.json(); if (d.status !== 'success') { showStatus(d.message || 'Nothing found', 'error'); return; } if (d.kind === 'block') { openBlock(d.id); } else if (d.kind === 'tx') { openTx(d.id); } } catch (err) { console.error(err); showStatus('Network error during search', 'error'); } }); document.addEventListener('DOMContentLoaded', () => { loadStats(); loadBlocks(true); if (location.hash) { route(); } setInterval(() => { loadStats(); if (!document.getElementById('detailView').classList.contains('hidden')) return; // Do not wipe pages the reader loaded with "Load more". if (currentOffset > PAGE_SIZE) return; loadBlocks(true); }, 5000); }); document.addEventListener('click', function (e) { // Covers rows rendered later from template literals too. var t = e.target; if (!t.closest) return; if (t.closest('.ds-copy')) return; // copy buttons: moonbite-ds.js // Let modified clicks on real links open a new tab at that URL. var a = t.closest('a[href]'); var modified = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1; if (a && modified) return; var b = t.closest('[data-block]'); if (b) { e.preventDefault(); openBlock(b.getAttribute('data-block')); return; } var x = t.closest('[data-tx]'); if (x) { e.preventDefault(); openTx(x.getAttribute('data-tx')); return; } if (t.closest('[data-act="browse"]')) { showBrowse(); return; } if (t.closest('[data-act="load-more"]')) { loadMoreBlocks(); return; } });</script>{% endblock %}