diff --git a/client/nginx/bitfeed.conf.template b/client/nginx/bitfeed.conf.template index bc6bf75..352cf1f 100644 --- a/client/nginx/bitfeed.conf.template +++ b/client/nginx/bitfeed.conf.template @@ -1,6 +1,7 @@ map $sent_http_content_type $expires { default off; text/css max; + text/json max; application/javascript max; } @@ -14,15 +15,10 @@ server { server_name client; - location = / { + location ~* \.(html)$ { add_header Cache-Control 'no-cache'; } - location / { - try_files $uri $uri/ =404; - expires $expires; - } - location /api { proxy_cache bitfeed; proxy_cache_revalidate on; @@ -42,6 +38,11 @@ server { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; } + + location / { + try_files $uri $uri/ /index.html; + expires $expires; + } } upstream wsmonobackend { diff --git a/client/package.json b/client/package.json index bd277f3..5319f61 100644 --- a/client/package.json +++ b/client/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "rollup -c", "dev": "rollup -c -w", - "start": "sirv public" + "start": "sirv public --single" }, "dependencies": { "@babel/core": "^7.16.5", diff --git a/client/src/App.svelte b/client/src/App.svelte index f1f4537..98b0d7a 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -2,9 +2,12 @@ import TxViz from './components/TxViz.svelte' import analytics from './utils/analytics.js' import config from './config.js' - import { settings } from './stores.js' + import Router from './controllers/Router.js' + import {settings} from './stores.js' if (!$settings.noTrack && config.public) analytics.init() + + const router = new Router(window.location.pathname)
diff --git a/client/src/components/BlockInfo.svelte b/client/src/components/BlockInfo.svelte index 9fbd2df..c91c1bd 100644 --- a/client/src/components/BlockInfo.svelte +++ b/client/src/components/BlockInfo.svelte @@ -62,18 +62,24 @@ } } + let transitionDirection let flyIn let flyOut $: { - if ($blockTransitionDirection && $blockTransitionDirection === 'right') { + if (!$blockTransitionDirection || !visible || !block || !$blocksEnabled) { + transitionDirection = 'up' + flyIn = { y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) } + flyOut = { y: -50, duration: 2000, easing: linear } + } else if ($blockTransitionDirection && $blockTransitionDirection === 'right') { + transitionDirection = 'right' flyIn = { x: 100, easing: linear, delay: 1000, duration: 1000 } flyOut = { x: -100, easing: linear, delay: 0, duration: 1000 } } else if ($blockTransitionDirection && $blockTransitionDirection === 'left') { + transitionDirection = 'left' flyIn = { x: -100, easing: linear, delay: 1000, duration: 1000 } flyOut = { x: 100, easing: linear, delay: 0, duration: 1000 } } else { - flyIn = { y: (restoring ? -50 : 50), duration: (restoring ? 500 : 1000), easing: linear, delay: (restoring ? 0 : newBlockDelay) } - flyOut = { y: -50, duration: 2000, easing: linear } + transitionDirection = 'down' } } @@ -115,9 +121,9 @@ async function explorePrevBlock (e) { e.preventDefault() if (!$loading && block) { - $loading = true + loading.increment() await searchBlockHeight(block.height - 1) - $loading = false + loading.decrement() } } @@ -125,9 +131,9 @@ e.preventDefault() if (!$loading && block) { if (block.height + 1 < $latestBlockHeight) { - $loading = true + loading.increment() await searchBlockHeight(block.height + 1) - $loading = false + loading.decrement() } else { dispatch('quitExploring') } @@ -155,6 +161,14 @@ } } + .block-info-container { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + } + .block-info { position: absolute; bottom: calc(100% + 0.25rem); @@ -319,68 +333,71 @@ } -{#key block} - {#if block != null && visible && $blocksEnabled } -
- -
-
- {#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) } - -
-
- { formatDateTime(block.time) } - { formattedBlockValue } -
-
- { formatBytes(block.bytes) } - { formatCount(block.txnCount) } transactions -
-
 
-
- Avg fee rate - {#if block.fees != null} - { formatFee(block.avgFeerate) } sats/vbyte - {:else} - unavailable - {/if} -
-
-
-
- Latest Block: { numberFormat.format(block.height) } - -
-
- { formatDateTime(block.time) } - { formattedBlockValue } -
-
- { formatCount(block.txnCount) } transactions - {#if block.fees != null} - { formatFee(block.avgFeerate) } sats/vb - {:else} +{#key transitionDirection} + {#each ((block != null && visible && $blocksEnabled) ? [block] : []) as block (block.id)} +
+
+ +
+
+ {#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) } + +
+
+ { formatDateTime(block.time) } + { formattedBlockValue } +
+
{ formatBytes(block.bytes) } - {/if} + { formatCount(block.txnCount) } transaction{block.txnCount == 1 ? '' : 's'} +
+
 
+
+ Avg fee rate + {#if block.fees != null} + { formatFee(block.avgFeerate) } sats/vbyte + {:else} + unavailable + {/if} +
-
+
+
+ Latest Block: { numberFormat.format(block.height) } + +
+
+ { formatDateTime(block.time) } + { formattedBlockValue } +
+
+ { formatCount(block.txnCount) } transactions + {#if block.fees != null} + { formatFee(block.avgFeerate) } sats/vb + {:else} + { formatBytes(block.bytes) } + {/if} +
+
+
+ + {#if hasPrevBlock } + + {/if} + {#if hasNextBlock } + + {/if} +
- {#if hasPrevBlock } - - {/if} - {#if hasNextBlock } - - {/if} - - {/if} + {/each} {/key} diff --git a/client/src/components/DonationOverlay.svelte b/client/src/components/DonationOverlay.svelte index 0dc913b..d82ab1e 100644 --- a/client/src/components/DonationOverlay.svelte +++ b/client/src/components/DonationOverlay.svelte @@ -17,7 +17,7 @@ import clipboardIcon from '../assets/icon/cil-clipboard.svg' import twitterIcon from '../assets/icon/cib-twitter.svg' import { fade, fly } from 'svelte/transition' import { durationFormat } from '../utils/format.js' -import { overlay, tiers } from '../stores.js' +import { overlay, tiers, urlPath } from '../stores.js' import QRCode from 'qrcode' let tab = 'form' // form | invoice | success @@ -164,6 +164,7 @@ $: { } $: { if ($overlay === 'donation') { + $urlPath = '/donate' startExpiryTimer() stopPollingInvoice() pollingEnabled = true @@ -416,9 +417,13 @@ async function copyInvoice () { } analytics.trackEvent('donations', 'invoice', 'copy') } + +function onClose () { + $urlPath = "/" +} - +
diff --git a/client/src/components/SearchBar.svelte b/client/src/components/SearchBar.svelte index 71f8061..cd5cee7 100644 --- a/client/src/components/SearchBar.svelte +++ b/client/src/components/SearchBar.svelte @@ -54,7 +54,7 @@ async function searchSubmit (e) { e.preventDefault() if (matchedQuery && matchedQuery.query !== 'address') { - $loading++ + loading.increment() let searchErr switch(matchedQuery.query) { case 'txid': @@ -79,7 +79,7 @@ async function searchSubmit (e) { } if (searchErr == null) errorMessage = null else handleSearchError(searchErr) - $loading-- + loading.decrement() } else { errorMessage = 'enter a transaction id, block hash or block height' } @@ -219,7 +219,7 @@ async function searchSubmit (e) {
- +
diff --git a/client/src/components/TransactionOverlay.svelte b/client/src/components/TransactionOverlay.svelte index 379e57b..4d6db95 100644 --- a/client/src/components/TransactionOverlay.svelte +++ b/client/src/components/TransactionOverlay.svelte @@ -3,17 +3,18 @@ import Overlay from '../components/Overlay.svelte' import Icon from './Icon.svelte' import BookmarkIcon from '../assets/icon/cil-bookmark.svg' import { longBtcFormat, numberFormat, feeRateFormat, dateFormat } from '../utils/format.js' -import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull, detailTx, pageWidth, latestBlockHeight, highlightInOut, loading } from '../stores.js' +import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull, detailTx, pageWidth, latestBlockHeight, highlightInOut, loading, urlPath, currentBlock, overlay, explorerBlockData } from '../stores.js' import { formatCurrency } from '../utils/fx.js' import { hlToHex, mixColor, teal, purple } from '../utils/color.js' import { SPKToAddress } from '../utils/encodings.js' import api from '../utils/api.js' -import { searchTx } from '../utils/search.js' +import { searchTx, searchBlockHash, searchBlockHeight } from '../utils/search.js' import { fade } from 'svelte/transition' function onClose () { $detailTx = null $highlightInOut = null + $urlPath = "/" } function formatBTC (sats) { @@ -258,29 +259,41 @@ function getMiterOffset (weight, dy, dx) { async function clickItem (item) { if (item.rest) { truncate = false - } else if (item.prev_txid && item.prev_vout != null) { - // $loading++ - // await searchTx(item.prev_txid, null, item.prev_vout) - // $loading-- - } else if (item.spend) { - // $loading++ - // await searchTx(item.spend.txid, item.spend.vin) - // $loading-- } } async function goToInput(e, input) { e.preventDefault() - $loading++ + loading.increment() await searchTx(input.prev_txid, null, input.prev_vout) - $loading-- + loading.decrement() } async function goToOutput(e, output) { e.preventDefault() - $loading++ + loading.increment() await searchTx(output.spend.txid, output.spend.vin) - $loading-- + loading.decrement() +} + +async function goToBlock(e) { + e.preventDefault() + + // ignore click if it was triggered while selecting text, or if we don't have a block to go to + if (!$detailTx || !$detailTx.block || !!window.getSelection().toString()) return + + let hash = $detailTx.block.hash || $detailTx.block.id + let height = $detailTx.block.height + if (hash === $currentBlock.id) { + $overlay = null + } else if (height == $latestBlockHeight) { + $explorerBlockData = null + $overlay = null + } else if (hash) { + loading.increment() + await searchBlockHash($detailTx.block.hash || $detailTx.block.id) + loading.decrement() + } } @@ -361,6 +374,14 @@ async function goToOutput(e, output) { word-break: break-all; } } + + &.clickable { + cursor: pointer; + user-select: text; + &:hover { + background: var(--palette-a); + } + } } .fields { @@ -561,7 +582,7 @@ async function goToOutput(e, output) { {/if}

{#if $detailTx.isCoinbase }Coinbase{:else}Transaction{/if} { $detailTx.id }

{#if $detailTx.block} - + {/if} {#if $detailTx.isCoinbase}
diff --git a/client/src/controllers/Router.js b/client/src/controllers/Router.js new file mode 100644 index 0000000..cddbec9 --- /dev/null +++ b/client/src/controllers/Router.js @@ -0,0 +1,104 @@ +import { urlPath, settings, loading, detailTx, highlightInOut, explorerBlockData, overlay } from '../stores.js' +import { searchTx, searchBlockHash, searchBlockHeight } from '../utils/search.js' + +export default class Router { + constructor (initialPath = '/') { + this.path = initialPath + this.apply(initialPath) + urlPath.subscribe(val => { + if (val != null) { + this.pushHistory(val) + this.path = val + } + }) + window.addEventListener('popstate', e => { + if (e && e.state && e.state.path) { + this.path = e.state.path + this.apply(e.state.path) + } + }) + } + + pushHistory (path, replace = false) { + if (replace) { + window.history.replaceState({path}, "", path, window.history.state) + } else if (path !== this.path) { + window.history.pushState({path}, "", path) + } + } + + clearPath () { + urlPath.set("/") + } + + apply (path) { + const parts = path.split("/") + if (path === '/') { + detailTx.set(null) + highlightInOut.set(null) + urlPath.set("/") + explorerBlockData.set(null) + overlay.set(null) + } else { + switch (parts[1]) { + case 'block': + if (parts[2] === "height") { + try { + const height = parseInt(parts[3]) + this.goToBlockHeight(height) + } catch (err) { + // ?? + } + } else if (parts[2]) { + this.goToBlock(parts[2]) + } + break; + + case 'tx': + if (parts[2]) { + this.goToTransaction(parts[2]) + } + break; + + case 'donate': + overlay.set('donation') + break; + } + } + } + + async goToBlock (blockhash) { + loading.increment() + await searchBlockHash(blockhash) + loading.decrement() + } + + async goToBlockHeight (height) { + loading.increment() + await searchBlockHeight(height) + loading.decrement() + } + + async goToTransaction (q) { + loading.increment() + const parts = q.split(":") + let txid, input, output + if (parts.length) { + if (parts[0].length == 64) { + txid = parts[0] + output = parseInt(parts[1]) + if (isNaN(output)) output = null + } else if (parts[1].length == 64) { + txid = parts[1] + input = parseInt(parts[0]) + if (isNaN(input)) input = null + } else { + // invalid + } + } else { + txid = q + } + await searchTx(txid, input, output) + loading.decrement() + } +} diff --git a/client/src/controllers/TxController.js b/client/src/controllers/TxController.js index e495caf..ff018c7 100644 --- a/client/src/controllers/TxController.js +++ b/client/src/controllers/TxController.js @@ -6,8 +6,9 @@ import BitcoinBlock from '../models/BitcoinBlock.js' import TxSprite from '../models/TxSprite.js' import { FastVertexArray } from '../utils/memory.js' import { searchTx, fetchSpends, addSpends } from '../utils/search.js' -import { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode, blocksEnabled, latestBlockHeight, explorerBlockData, blockTransitionDirection, loading } from '../stores.js' +import { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode, blocksEnabled, latestBlockHeight, explorerBlockData, blockTransitionDirection, loading, urlPath } from '../stores.js' import config from "../config.js" +import { tick } from 'svelte'; export default class TxController { constructor ({ width, height }) { @@ -188,6 +189,22 @@ export default class TxController { this.expiredTxs = {} + if (this.explorerBlockScene && this.explorerBlock && this.explorerBlock.id === block.id) { + this.block = this.explorerBlock + this.blockScene = this.explorerBlockScene + this.explorerBlockScene = null + this.explorerBlock = null + urlPath.set("/") + + for (let i = 0; i < block.txns.length; i++) { + this.txs[block.txns[i].id].setData(block.txns[i]) + this.poolScene.remove(block.txns[i].id) + } + this.poolScene.layoutAll() + + return + } + if (!this.explorerBlockScene) this.clearBlock() if (this.blocksEnabled) { @@ -268,8 +285,14 @@ export default class TxController { return block } - exploreBlock (blockData) { + async exploreBlock (blockData) { const block = blockData.isBlock ? blockData : new BitcoinBlock(blockData) + + if (this.block && this.block.id === block.id) { + this.showBlock() + return + } + let enterFromRight = false // clean up previous block @@ -311,10 +334,11 @@ export default class TxController { } blockVisible.set(true) + await tick() currentBlock.set(block) } - resumeLatest () { + async resumeLatest () { if (this.explorerBlock && this.explorerBlockScene) { const prevBlock = this.explorerBlock const prevBlockScene = this.explorerBlockScene @@ -322,17 +346,20 @@ export default class TxController { prevBlockScene.expire(2000) this.explorerBlockScene = null this.explorerBlock = null + urlPath.set("/") } if (this.blockScene && this.block) { blockTransitionDirection.set('right') + await tick() this.blockScene.enterRight() currentBlock.set(this.block) } } - hideBlock () { + async hideBlock () { if (this.blockScene && !this.explorerBlockScene) { blockTransitionDirection.set(null) + await tick() this.blockScene.hide() } } @@ -397,6 +424,7 @@ export default class TxController { } else { const spendResult = await fetchSpends(selected.id) if (spendResult) selected = addSpends(selected, spendResult) + urlPath.set(`/tx/${selected.id}`) detailTx.set(selected) overlay.set('tx') } diff --git a/client/src/models/TxBlockScene.js b/client/src/models/TxBlockScene.js index cea3593..c82f6b8 100644 --- a/client/src/models/TxBlockScene.js +++ b/client/src/models/TxBlockScene.js @@ -77,7 +77,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { }) } - this.savePixelsToScreenPosition(tx, 0, this.hidden ? 50 : 0) + this.savePixelsToScreenPosition(tx, 0, (this.hidden && !this.exited) ? 50 : 0) if (this.hidden) { tx.view.update({ display: { @@ -164,6 +164,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { enter (right) { this.hidden = false + this.exited = false const ids = this.getActiveTxList() for (let i = 0; i < ids.length; i++) { this.enterTx(this.txs[ids[i]], right) @@ -198,6 +199,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { exit (right) { this.hidden = true + this.exited = true const ids = this.getActiveTxList() for (let i = 0; i < ids.length; i++) { this.exitTx(this.txs[ids[i]], right) diff --git a/client/src/stores.js b/client/src/stores.js index e91b985..9e73683 100644 --- a/client/src/stores.js +++ b/client/src/stores.js @@ -170,3 +170,5 @@ export const highlightInOut = writable(null) export const loading = createCounter() export const explorerBlockData = writable(null) export const blockTransitionDirection = writable(null) + +export const urlPath = writable(null) diff --git a/client/src/utils/search.js b/client/src/utils/search.js index fd2fc96..1027c22 100644 --- a/client/src/utils/search.js +++ b/client/src/utils/search.js @@ -1,7 +1,7 @@ import api from './api.js' import BitcoinTx from '../models/BitcoinTx.js' import BitcoinBlock from '../models/BitcoinBlock.js' -import { detailTx, selectedTx, currentBlock, explorerBlockData, overlay, highlightInOut } from '../stores.js' +import { detailTx, selectedTx, currentBlock, explorerBlockData, overlay, highlightInOut, urlPath } from '../stores.js' import { addressToSPK } from './encodings.js' // Quick heuristic matching to guess what kind of search a query is for @@ -198,6 +198,13 @@ function addSpends(tx, spends) { export {addSpends as addSpends} export async function searchTx (txid, input, output) { + if (input != null) { + urlPath.set(`/tx/${input}:${txid}`) + } else if (output != null) { + urlPath.set(`/tx/${txid}:${output}`) + } else { + urlPath.set(`/tx/${txid}`) + } try { let searchResult = await fetchTx(txid) const spendResult = await fetchSpends(txid) @@ -218,6 +225,8 @@ export async function searchTx (txid, input, output) { } export async function searchBlockHash (hash) { + urlPath.set(`/block/${hash}`) + overlay.set(null) try { const searchResult = await fetchBlockByHash(hash) if (searchResult) { @@ -235,6 +244,8 @@ export async function searchBlockHash (hash) { } export async function searchBlockHeight (height) { + urlPath.set(`/block/height/${height}`) + overlay.set(null) try { const searchResult = await fetchBlockByHeight(height) if (searchResult) { diff --git a/server/lib/spend_index.ex b/server/lib/spend_index.ex index 87db187..0b49bab 100644 --- a/server/lib/spend_index.ex +++ b/server/lib/spend_index.ex @@ -85,6 +85,7 @@ defmodule BitcoinStream.Index.Spend do @impl true def handle_cast({:block_disconnected, hash}, [dbref, indexed, done]) do + Logger.info("block disconnected: #{hash}"); if (indexed != nil and done) do block_disconnected(dbref, hash) end