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 => ({            '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'        }[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 %}