const $ = (selector) => document.querySelector(selector);
const view = $('#view');
const content = $('#content');
const number = new Intl.NumberFormat('en-GB');
const atomic = 100_000_000_000;
let chainInfo = null;
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
const shortHash = (hash, size = 12) => hash ? `${hash.slice(0, size)}…${hash.slice(-8)}` : '—';
const xmz = (value) => `${(Number(value || 0) / atomic).toLocaleString('en-GB', {maximumFractionDigits: 11})} XMZ`;
const date = (timestamp) => timestamp ? new Date(timestamp * 1000).toLocaleString() : '—';
const age = (timestamp) => {
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - timestamp));
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
};
async function api(action, data = {}) {
const response = await fetch('api.php', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action, ...data})});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.error) throw new Error(result.error || `HTTP ${response.status}`);
return result;
}
function loading(label = 'Loading chain data…') { view.innerHTML = `
${escapeHtml(label)}
`; }
function showError(message = '') {
const fragment = $('#error-template').content.cloneNode(true);
if (message) fragment.querySelector('p').textContent = message;
view.replaceChildren(fragment);
}
function updateStats(info) {
chainInfo = info;
$('#stat-height').textContent = number.format(info.height || 0);
$('#stat-difficulty').textContent = number.format(info.difficulty || 0);
$('#stat-hashrate').textContent = `${number.format(Math.round((info.difficulty || 0) / (info.target || 120)))} H/s estimated`;
$('#stat-transactions').textContent = number.format(info.tx_count || 0);
$('#stat-connections').textContent = number.format((info.incoming_connections_count || 0) + (info.outgoing_connections_count || 0));
$('#node-label').textContent = info.status === 'OK' ? 'Node online' : info.status;
$('#node-dot').className = info.status === 'OK' ? 'online' : 'offline';
}
function headerTitle(kicker, title) {
$('.section-title').innerHTML = `${escapeHtml(kicker)}
${title}
← Recent blocks`;
}
async function showBlocks() {
loading();
$('.section-title').innerHTML = ``;
try {
const result = await api('blocks', {limit: 15});
updateStats(result.info);
view.innerHTML = `| Height | Age | Block hash | Transactions | Difficulty | Reward | Size |
${result.headers.map((block) => `
| ${number.format(block.height)} | ${age(block.timestamp)} | ${shortHash(block.hash)} | ${number.format(block.num_txes || 0)} | ${number.format(block.difficulty || 0)} | ${xmz(block.reward)} | ${number.format(block.block_size || block.block_weight || 0)} B |
`).join('')}
`;
$('#refresh').addEventListener('click', showBlocks);
} catch (error) { showError(error.message); setOffline(); }
}
async function showBlock(value) {
loading('Loading block…');
try {
const input = /^\d+$/.test(value) ? {height:Number(value)} : {hash:value};
const block = await api('block', input);
const h = block.block_header || {};
headerTitle('Block', `#${number.format(h.height ?? input.height)}`);
const txs = block.tx_hashes || [];
view.innerHTML = `
${datum('Block hash', `
${escapeHtml(h.hash)}`)}
${datum('Timestamp', `
${escapeHtml(date(h.timestamp))}`)}
${datum('Age', `
${escapeHtml(age(h.timestamp))}`)}
${datum('Reward', `
${xmz(h.reward)}`)}
${datum('Difficulty', `
${number.format(h.difficulty || 0)}`)}
${datum('Transactions', `
${number.format(h.num_txes || txs.length)}`)}
${datum('Block size', `
${number.format(h.block_size || h.block_weight || 0)} bytes`)}
${datum('Nonce', `
${number.format(h.nonce || 0)}`)}
${datum('Version', `
v${h.major_version ?? '—'}.${h.minor_version ?? '—'}`)}
${datum('Previous block', h.prev_hash ? `
${shortHash(h.prev_hash)}` : '
Genesis')}
${datum('Proof-of-work hash', `
${escapeHtml(h.pow_hash || 'Not requested')}`)}
${datum('Confirmations', `
${chainInfo ? number.format(Math.max(0, chainInfo.height - h.height)) : '—'}`)}
Transactions in this block
${txs.length ? `| # | Transaction hash |
${txs.map((hash, i) => `| ${i + 1} | ${hash} |
`).join('')}
` : 'Coinbase onlyThis block contains no regular transactions.
'}
Raw block JSON
${escapeHtml(JSON.stringify(block, null, 2))} `;
} catch (error) { await tryTransaction(value, error); }
}
async function tryTransaction(value, originalError) {
if (!/^[0-9a-f]{64}$/i.test(value)) return showError(originalError.message);
try { await showTransaction(value); } catch { showError('No block or transaction matched this hash.'); }
}
async function showTransaction(hash) {
loading('Loading transaction…');
const result = await api('transaction', {hash});
const tx = result.txs?.[0] || {};
let decoded = {};
try { decoded = JSON.parse(tx.as_json || result.txs_as_json?.[0] || '{}'); } catch {}
headerTitle('Transaction', `${escapeHtml(shortHash(hash, 16))}`);
const vin = decoded.vin || [];
const vout = decoded.vout || [];
view.innerHTML = `
${datum('Transaction hash', `
${escapeHtml(hash)}`)}
${datum('Block height', tx.block_height != null ? `
${number.format(tx.block_height)}` : '
Unconfirmed')}
${datum('Confirmations', `
${number.format(tx.confirmations || 0)}`)}
${datum('Fee', `
${xmz(tx.fee || decoded.rct_signatures?.txnFee || 0)}`)}
${datum('Size', `
${number.format(tx.size || tx.weight || 0)} bytes`)}
${datum('Version', `
${decoded.version ?? '—'}`)}
${datum('Inputs', `
${number.format(vin.length)}`)}
${datum('Outputs', `
${number.format(vout.length)}`)}
${datum('In pool', `
${tx.in_pool ? 'Yes' : 'No'}`)}
Decoded transaction JSON
${escapeHtml(JSON.stringify(decoded, null, 2))} `;
}
function datum(label, value) { return `${escapeHtml(label)}${value}
`; }
function setOffline() { $('#node-dot').className = 'offline'; $('#node-label').textContent = 'Node unavailable'; }
async function route() {
const route = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean);
if (location.hash) content.scrollIntoView({behavior:'smooth', block:'start'});
if (!route.length) return showBlocks();
if (route[0] === 'block' && route[1]) return showBlock(route[1]);
if (route[0] === 'tx' && route[1]) {
try { return await showTransaction(route[1]); } catch (error) { return showError(error.message); }
}
showError();
}
$('#search-form').addEventListener('submit', (event) => {
event.preventDefault();
const value = $('#search-input').value.trim();
if (/^\d+$/.test(value) || /^[0-9a-f]{64}$/i.test(value)) location.hash = `#/block/${value}`;
else showError('Enter a numeric block height or a 64-character hexadecimal hash.');
});
$('#latest-search').addEventListener('click', () => { if (chainInfo?.height) location.hash = `#/block/${chainInfo.height - 1}`; });
window.addEventListener('hashchange', route);
api('info').then(updateStats).catch(setOffline);
route();
setInterval(() => api('info').then(updateStats).catch(setOffline), 30000);