diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 180 | ||||
| -rw-r--r-- | src/style.css | 9 |
2 files changed, 119 insertions, 70 deletions
@@ -42,8 +42,7 @@ const ui = { function blankSend() { return { - address: '', - amount: '', + recipients: [{ address: '', amount: '' }], unit: 'btc', max: false, feeChoice: 'halfHourFee', @@ -121,6 +120,34 @@ function copyBtn(text, label = 'Copy') { return h('button', { class: 'btn-sm', onClick: () => copy(text) }, label); } +// ---------------------------------------------------------------- display unit +// Global BTC/sats preference, persisted in localStorage across refreshes and +// logouts. Every unit label on the site is clickable to toggle it. +const UNIT_KEY = 'btc-wallet-unit'; +let unit = (() => { + try { + return localStorage.getItem(UNIT_KEY) === 'sats' ? 'sats' : 'btc'; + } catch { + return 'btc'; + } +})(); + +function toggleUnit() { + unit = unit === 'btc' ? 'sats' : 'btc'; + try { + localStorage.setItem(UNIT_KEY, unit); + } catch {} + render(); +} + +const unitLabel = () => (unit === 'sats' ? 'sats' : 'BTC'); +const fmtAmount = (sats) => (unit === 'sats' ? fmtSats(sats) : fmtBtc(sats)); + +// A clickable unit label. cls lets callers inherit surrounding sizing. +function unitTag(cls = '') { + return h('button', { type: 'button', class: 'unit-tag ' + cls, title: 'Switch BTC / sats', onClick: toggleUnit }, unitLabel()); +} + // ================================================================ UNLOCK function unlockScreen() { return h( @@ -538,13 +565,13 @@ function balanceCard() { 'div', { class: 'card balance' }, h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.05em' }, 'Total balance'), - h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtBtc(wallet.total), h('span', { class: 'unit' }, 'BTC')), + h('div', { class: 'amt', style: firstLoad ? 'opacity:.3' : '' }, fmtAmount(wallet.total), ' ', unitTag('unit')), h( 'div', { class: 'split' }, - h('div', {}, h('div', { class: 'k' }, 'Confirmed'), h('div', { class: 'v' }, fmtSats(wallet.confirmed) + ' sats')), + h('div', {}, h('div', { class: 'k' }, 'Confirmed'), h('div', { class: 'v' }, fmtAmount(wallet.confirmed), ' ', unitTag())), wallet.pending > 0 && - h('div', {}, h('div', { class: 'k' }, 'Pending'), h('div', { class: 'v pending' }, fmtSats(wallet.pending) + ' sats')) + h('div', {}, h('div', { class: 'k' }, 'Pending'), h('div', { class: 'v pending' }, fmtAmount(wallet.pending), ' ', unitTag())) ) ); } @@ -597,6 +624,48 @@ function sendTab() { return sendForm(); } +// One recipient: address + amount. Max is only offered for a single recipient. +function recipientRow(s, r, i) { + const single = s.recipients.length === 1; + const maxOn = single && s.max; + return h( + 'div', + { class: 'col gap6' }, + h('div', { class: 'input-group' }, + h('input', { + type: 'text', class: 'mono-input grow', placeholder: 'bc1q…', + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address, + onInput: (e) => (r.address = e.target.value), + }), + !single && h('button', { type: 'button', class: 'btn-sm', title: 'Remove', onClick: () => { s.recipients.splice(i, 1); render(); } }, '✕') + ), + h('div', { class: 'input-group' }, + h('input', { + type: 'number', step: unit === 'sats' ? '1' : '0.00000001', min: '0', + placeholder: unit === 'sats' ? '0' : '0.00000000', + disabled: maxOn, + value: maxOn + ? (unit === 'sats' ? String(estimatedMaxSats()) : fmtBtc(estimatedMaxSats())) + : r.amount, + onInput: (e) => { + let v = e.target.value; + // Chrome emits scientific notation when stepping tiny BTC amounts. + if (v && /e/i.test(v)) { + const n = Number(v); + if (isFinite(n)) { + v = unit === 'sats' ? String(Math.round(n)) : n.toFixed(8).replace(/\.?0+$/, ''); + e.target.value = v; + } + } + r.amount = v; + }, + }), + h('button', { type: 'button', class: 'btn-sm', title: 'Switch BTC / sats', onClick: toggleUnit }, unitLabel()), + single && h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, 'Max') + ) + ); +} + function sendForm() { const s = ui.send; const feeOpts = [ @@ -609,51 +678,17 @@ function sendForm() { 'div', { class: 'card col' }, h( - 'label', - { class: 'field' }, - h('span', { class: 'lab' }, 'Recipient address'), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'bc1q…', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: s.address, - onInput: (e) => (s.address = e.target.value), - }) - ), - h( - 'label', + 'div', { class: 'field' }, - h('span', { class: 'lab' }, 'Amount'), - h( - 'div', - { class: 'input-group' }, - h('input', { - type: 'number', step: s.unit === 'sats' ? '1' : '0.00000001', min: '0', - placeholder: s.unit === 'sats' ? '0' : '0.00000000', - disabled: s.max, - value: s.max - ? (s.unit === 'sats' ? String(estimatedMaxSats()) : fmtBtc(estimatedMaxSats())) - : s.amount, - onInput: (e) => { - let v = e.target.value; - // Chrome emits scientific notation (e.g. "1e-8") when stepping tiny - // BTC amounts — normalize it back to a plain decimal string. - if (v && /e/i.test(v)) { - const n = Number(v); - if (isFinite(n)) { - v = s.unit === 'sats' ? String(Math.round(n)) : n.toFixed(8).replace(/\.?0+$/, ''); - e.target.value = v; - } - } - s.amount = v; - }, - }), - h( - 'div', - { class: 'seg' }, - h('button', { type: 'button', class: s.unit === 'btc' ? 'active' : '', onClick: () => { s.unit = 'btc'; render(); } }, 'BTC'), - h('button', { type: 'button', class: s.unit === 'sats' ? 'active' : '', onClick: () => { s.unit = 'sats'; render(); } }, 'sats') - ), - h('button', { type: 'button', class: s.max ? 'btn-primary' : '', onClick: () => { s.max = !s.max; render(); } }, 'Max') - ) + h('span', { class: 'lab' }, s.recipients.length > 1 ? 'Recipients' : 'Recipient'), + h('div', { class: 'col', style: 'gap:14px' }, + s.recipients.map((r, i) => recipientRow(s, r, i)) + ), + s.recipients.length < 10 && + h('button', { + type: 'button', class: 'linklike small mt8', + onClick: () => { s.recipients.push({ address: '', amount: '' }); s.max = false; render(); }, + }, '+ Add recipient') ), h( 'div', @@ -723,7 +758,7 @@ function coinControl() { h('div', { class: 'mono small break' }, shortAddr(u.address, 14, 10)), h('div', { class: 'path' }, `${u.chain}/${u.index} · ${shortTxid(u.txid)}:${u.vout}`) ), - h('div', { class: 'amount small' }, fmtBtc(u.value)) + h('div', { class: 'amount small' }, fmtAmount(u.value)) ); }); return h( @@ -733,7 +768,7 @@ function coinControl() { h('div', { class: 'list' }, rows), h('div', { class: 'row between small' }, h('span', { class: 'muted' }, `${s.coins.size} selected`), - h('span', { class: 'amount' }, fmtBtc(selTotal) + ' BTC') + h('span', { class: 'amount' }, fmtAmount(selTotal), ' ', unitTag()) ) ); } @@ -765,7 +800,6 @@ function reviewSend() { ui.sendError = ''; try { const s = ui.send; - if (!s.address.trim()) throw new Error('Enter a recipient address.'); const feeRate = currentFeeRate(); let coinIds = null; if (s.manual) { @@ -773,13 +807,19 @@ function reviewSend() { if (!coinIds.length) throw new Error('Select at least one coin to spend.'); } let recipients, sendMax = false; - if (s.max) { - recipients = [{ address: s.address.trim(), amount: 0 }]; + if (s.max && s.recipients.length === 1) { + const addr = s.recipients[0].address.trim(); + if (!addr) throw new Error('Enter a recipient address.'); + recipients = [{ address: addr, amount: 0 }]; sendMax = true; } else { - const sats = parseAmount(s.amount, s.unit); - if (!sats || sats <= 0) throw new Error('Enter a valid amount.'); - recipients = [{ address: s.address.trim(), amount: sats }]; + recipients = s.recipients.map((r, i) => { + const addr = r.address.trim(); + if (!addr) throw new Error(`Enter an address for recipient ${i + 1}.`); + const sats = parseAmount(r.amount, unit); + if (!sats || sats <= 0) throw new Error(`Enter a valid amount for recipient ${i + 1}.`); + return { address: addr, amount: sats }; + }); } ui.draft = wallet.buildTx({ recipients, feeRate, coinIds, sendMax }); } catch (e) { @@ -792,11 +832,7 @@ function reviewSend() { function reviewView() { const d = ui.draft; const changeAddr = wallet.freshChange().address; - const recipientOuts = d.outputs.filter((o) => o.address !== changeAddr); - const rows = recipientOuts.map((o) => - h('div', { class: 'line' }, h('span', { class: 'k mono break' }, shortAddr(o.address, 16, 10)), h('span', { class: 'v' }, fmtBtc(o.amount))) - ); - const sent = recipientOuts.reduce((s, o) => s + o.amount, 0); + const outs = d.outputs.filter((o) => o.address !== changeAddr); return h( 'div', { class: 'card col' }, @@ -804,12 +840,16 @@ function reviewView() { h( 'div', { class: 'summary col', style: 'gap:0' }, - h('div', { class: 'line' }, h('span', { class: 'k' }, 'Sending'), h('span', { class: 'v' }, fmtBtc(sent) + ' BTC')), - ...rows, - h('div', { class: 'line' }, h('span', { class: 'k' }, 'Network fee'), h('span', { class: 'v' }, fmtSats(d.fee) + ' sats')), - h('div', { class: 'line' }, h('span', { class: 'k' }, 'Inputs / vsize'), h('span', { class: 'v' }, `${d.inputsCount} in · ${d.vsize} vB`)), - d.hasChange && h('div', { class: 'line' }, h('span', { class: 'k' }, 'Change returns to'), h('span', { class: 'v mono' }, shortAddr(changeAddr, 10, 8))), - h('div', { class: 'line' }, h('span', { class: 'k' }, 'Total cost'), h('span', { class: 'v' }, fmtBtc(sent + d.fee) + ' BTC')) + ...outs.map((o) => + h('div', { class: 'line' }, + h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)), + h('span', { class: 'v' }, fmtAmount(o.amount), ' ', unitTag()) + ) + ), + h('div', { class: 'line' }, + h('span', { class: 'k' }, 'Network fee'), + h('span', { class: 'v' }, fmtAmount(d.fee), ' ', unitTag()) + ) ), ui.sendError && h('div', { class: 'notice err' }, ui.sendError), wallet.offline @@ -920,8 +960,8 @@ function historyTab() { h('div', { class: 'small faint' }, t.confirmed ? timeAgo(t.blockTime) : 'unconfirmed') ), h('div', { style: 'text-align:right' }, - h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtBtc(display)), - !incoming && t.fee ? h('div', { class: 'small faint' }, `fee ${fmtSats(t.fee)}`) : null + h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '') + fmtAmount(display)), + !incoming && t.fee ? h('div', { class: 'small faint' }, `fee ${fmtAmount(t.fee)}`) : null ) ); }) diff --git a/src/style.css b/src/style.css index 9313899..6d47d29 100644 --- a/src/style.css +++ b/src/style.css @@ -207,6 +207,15 @@ textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-si } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .words .w.masked .t { color: var(--faint); letter-spacing: 2px; } + +/* Clickable unit labels (BTC/sats) — look like text, toggle the global unit. */ +.unit-tag { + background: none; border: 0; padding: 0; margin: 0; font: inherit; cursor: pointer; + color: var(--muted); font-weight: 600; + border-bottom: 1px dotted var(--line-strong); +} +.unit-tag:hover { background: none; color: var(--accent-ink); border-bottom-color: var(--accent); } +.unit-tag:active { transform: none; } .divider { height: 1px; background: var(--line); margin: 16px 0; border: 0; } hr.divider { margin: 16px 0; } .linklike { background: none; border: 0; padding: 0; color: var(--accent-ink); font-weight: 600; cursor: pointer; } |
