mirror of
https://github.com/Retropex/bitfeed.git
synced 2025-05-28 13:02:28 +02:00
Color-coded watchlist for multiple addresses/txs
This commit is contained in:
parent
0cce3ca92f
commit
d03467b895
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bitfeed-client",
|
"name": "bitfeed-client",
|
||||||
"version": "2.1.4",
|
"version": "2.1.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
"dev": "rollup -c -w",
|
"dev": "rollup -c -w",
|
||||||
|
@ -1,37 +1,268 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { tick } from 'svelte'
|
||||||
|
import { fade } from 'svelte/transition'
|
||||||
|
import { flip } from 'svelte/animate'
|
||||||
|
import Icon from './Icon.svelte'
|
||||||
import SearchIcon from '../assets/icon/cil-search.svg'
|
import SearchIcon from '../assets/icon/cil-search.svg'
|
||||||
|
import PlusIcon from '../assets/icon/cil-plus.svg'
|
||||||
|
import CrossIcon from '../assets/icon/cil-x.svg'
|
||||||
|
import AddressIcon from '../assets/icon/cil-wallet.svg'
|
||||||
|
import TxIcon from '../assets/icon/cil-arrow-circle-right.svg'
|
||||||
import { matchQuery } from '../utils/search.js'
|
import { matchQuery } from '../utils/search.js'
|
||||||
import { highlight } from '../stores.js'
|
import { highlight, newHighlightQuery, highlightingFull } from '../stores.js'
|
||||||
|
import { hcl } from 'd3-color'
|
||||||
|
|
||||||
|
const highlightColors = [
|
||||||
|
{ h: 0.03, l: 0.35 },
|
||||||
|
{ h: 0.40, l: 0.35 },
|
||||||
|
{ h: 0.65, l: 0.35 },
|
||||||
|
{ h: 0.85, l: 0.35 },
|
||||||
|
{ h: 0.12, l: 0.35 },
|
||||||
|
]
|
||||||
|
const highlightHexColors = highlightColors.map(c => hclToHex(c))
|
||||||
|
const usedColors = [false, false, false, false, false]
|
||||||
|
|
||||||
|
const queryIcons = {
|
||||||
|
txid: TxIcon,
|
||||||
|
address: AddressIcon
|
||||||
|
}
|
||||||
|
const queryType = {
|
||||||
|
txid: 'transaction',
|
||||||
|
address: 'address'
|
||||||
|
}
|
||||||
|
|
||||||
|
export let tab
|
||||||
|
|
||||||
let query
|
let query
|
||||||
|
let matchedQuery
|
||||||
|
let queryAddress
|
||||||
|
let queryColor
|
||||||
|
let queryColorHex
|
||||||
|
let watchlist = []
|
||||||
|
|
||||||
|
init ()
|
||||||
|
|
||||||
|
setNextColor()
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (query) {
|
if ($newHighlightQuery) {
|
||||||
const matchedQuery = matchQuery(query.trim())
|
matchedQuery = matchQuery($newHighlightQuery)
|
||||||
if (matchedQuery) {
|
if (matchedQuery) {
|
||||||
$highlight = [matchedQuery]
|
matchedQuery.color = queryColor
|
||||||
} else $highlight = []
|
matchedQuery.colorHex = queryColorHex
|
||||||
} else $highlight = []
|
add()
|
||||||
|
query = null
|
||||||
|
}
|
||||||
|
$newHighlightQuery = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$: {
|
||||||
|
$highlight = matchedQuery ? [ ...watchlist, matchedQuery ] : watchlist
|
||||||
|
}
|
||||||
|
$: {
|
||||||
|
localStorage.setItem('highlight', JSON.stringify(watchlist))
|
||||||
|
}
|
||||||
|
$: {
|
||||||
|
if (query) {
|
||||||
|
matchedQuery = matchQuery(query.trim())
|
||||||
|
if (matchedQuery) {
|
||||||
|
matchedQuery.color = queryColor
|
||||||
|
matchedQuery.colorHex = queryColorHex
|
||||||
|
}
|
||||||
|
} else matchedQuery = null
|
||||||
|
}
|
||||||
|
$: {
|
||||||
|
$highlightingFull = watchlist.length >= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
function init () {
|
||||||
|
const val = localStorage.getItem('highlight')
|
||||||
|
if (val != null) {
|
||||||
|
try {
|
||||||
|
watchlist = JSON.parse(val)
|
||||||
|
watchlist.forEach(q => {
|
||||||
|
const i = highlightHexColors.findIndex(c => c === q.colorHex)
|
||||||
|
if (i >= 0) usedColors[i] = q.colorHex
|
||||||
|
else console.log('unknown color')
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log('failed to parse cached highlight queries')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNextColor () {
|
||||||
|
const nextIndex = usedColors.findIndex(used => !used)
|
||||||
|
if (nextIndex >= 0) {
|
||||||
|
queryColor = highlightColors[nextIndex]
|
||||||
|
queryColorHex = highlightHexColors[nextIndex]
|
||||||
|
usedColors[nextIndex] = queryColorHex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearUsedColor (hex) {
|
||||||
|
const clearIndex = usedColors.findIndex(used => used === hex)
|
||||||
|
usedColors[clearIndex] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function hclToHex (color) {
|
||||||
|
return hcl(color.h * 360, 78.225, color.l * 150).hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add () {
|
||||||
|
if (matchedQuery && !$highlightingFull) {
|
||||||
|
watchlist.push({
|
||||||
|
...matchedQuery
|
||||||
|
})
|
||||||
|
watchlist = watchlist
|
||||||
|
query = null
|
||||||
|
setNextColor()
|
||||||
|
if (tab) {
|
||||||
|
await tick()
|
||||||
|
tab.updateContentHeight(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove (index) {
|
||||||
|
const wasFull = $highlightingFull
|
||||||
|
const removed = watchlist.splice(index,1)
|
||||||
|
if (removed.length) {
|
||||||
|
clearUsedColor(removed[0].colorHex)
|
||||||
|
watchlist = watchlist
|
||||||
|
if (tab) {
|
||||||
|
await tick()
|
||||||
|
tab.updateContentHeight(true)
|
||||||
|
}
|
||||||
|
if (wasFull) setNextColor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchSubmit (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
add()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style type="text/scss">
|
<style type="text/scss">
|
||||||
.search {
|
.search {
|
||||||
.search-input {
|
width: 274px;
|
||||||
color: var(--palette-a);
|
|
||||||
border: solid 3px var(--grey);
|
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:active {
|
.watched, .input-wrapper {
|
||||||
border-color: var(--palette-good);
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
--input-color: var(--bold-a);
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.search-submit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--input-color);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: var(--palette-e);
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.query {
|
||||||
|
width: 100%;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 7px;
|
||||||
|
transition: opacity 300ms, color 300ms, background 300ms;
|
||||||
|
color: var(--input-color);
|
||||||
|
|
||||||
|
&.add-query {
|
||||||
|
color: var(--light-good);
|
||||||
|
}
|
||||||
|
&.remove-query {
|
||||||
|
color: var(--light-bad);
|
||||||
|
}
|
||||||
|
&.icon-button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.icon-button {
|
||||||
|
background: var(--palette-d);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
&:hover {
|
||||||
|
background: var(--palette-e);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: var(--palette-e);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
border-bottom: solid 3px var(--input-color);
|
||||||
|
|
||||||
|
&.full {
|
||||||
|
border-color: var(--palette-e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="search tab-content">
|
<div class="search tab-content">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper" class:full={$highlightingFull} style="--input-color: {queryColorHex};">
|
||||||
<input class="search-input" type="text" bind:value={query} placeholder="Enter an address or txid...">
|
{#if !$highlightingFull }
|
||||||
|
<form class="search-form" action="" on:submit={searchSubmit}>
|
||||||
|
<input class="search-input" type="text" bind:value={query} placeholder="Enter an address or txid...">
|
||||||
|
<button type="submit" class="search-submit" />
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<input class="search-input disabled" type="text" placeholder="Watchlist is full">
|
||||||
|
{/if}
|
||||||
|
<div class="input-icon add-query icon-button" class:disabled={matchedQuery == null || $highlightingFull} on:click={add} title="Add to watchlist">
|
||||||
|
<Icon icon={PlusIcon}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="watchlist">
|
||||||
|
{#each watchlist as watched, index (watched.colorHex)}
|
||||||
|
<div
|
||||||
|
class="watched"
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
animate:flip={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
<div class="input-icon query-type" style="color: {watched.colorHex};" title={queryType[watched.query]}>
|
||||||
|
<Icon icon={queryIcons[watched.query]} />
|
||||||
|
</div>
|
||||||
|
<span class="query" style="color: {watched.colorHex};">{ watched.value }</span>
|
||||||
|
<div class="input-icon remove-query icon-button" on:click={() => remove(index)} title="Remove from watchlist">
|
||||||
|
<Icon icon={CrossIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,6 +22,8 @@ import SearchTab from '../components/SearchTab.svelte'
|
|||||||
|
|
||||||
import { sidebarToggle, overlay, currentBlock, blockVisible, haveSupporters } from '../stores.js'
|
import { sidebarToggle, overlay, currentBlock, blockVisible, haveSupporters } from '../stores.js'
|
||||||
|
|
||||||
|
let searchTabComponent
|
||||||
|
|
||||||
let blockHidden = false
|
let blockHidden = false
|
||||||
$: blockHidden = ($currentBlock && !$blockVisible)
|
$: blockHidden = ($currentBlock && !$blockVisible)
|
||||||
|
|
||||||
@ -114,12 +116,12 @@ function showBlock () {
|
|||||||
<MempoolLegend />
|
<MempoolLegend />
|
||||||
</div>
|
</div>
|
||||||
</SidebarTab>
|
</SidebarTab>
|
||||||
<SidebarTab open={$sidebarToggle === 'search'} on:click={() => {settings('search')}} tooltip="Search & Highlight">
|
<SidebarTab open={$sidebarToggle === 'search'} on:click={() => {settings('search')}} tooltip="Search & Highlight" bind:this={searchTabComponent}>
|
||||||
<span slot="tab" title="Search & Highlight">
|
<span slot="tab" title="Search & Highlight">
|
||||||
<Icon icon={searchIcon} color="var(--bold-a)" />
|
<Icon icon={searchIcon} color="var(--bold-a)" />
|
||||||
</span>
|
</span>
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<SearchTab />
|
<SearchTab tab={searchTabComponent} />
|
||||||
</div>
|
</div>
|
||||||
</SidebarTab>
|
</SidebarTab>
|
||||||
<SidebarTab open={$sidebarToggle === 'settings'} on:click={() => {settings('settings')}} tooltip="Settings">
|
<SidebarTab open={$sidebarToggle === 'settings'} on:click={() => {settings('settings')}} tooltip="Settings">
|
||||||
|
@ -9,7 +9,7 @@ let entered = false
|
|||||||
let contentElement
|
let contentElement
|
||||||
let contentSlotElement
|
let contentSlotElement
|
||||||
|
|
||||||
async function updateContentHeight (isOpen) {
|
export async function updateContentHeight (isOpen) {
|
||||||
if (contentElement && contentSlotElement) {
|
if (contentElement && contentSlotElement) {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
contentElement.style.height = `${contentSlotElement.clientHeight}px`
|
contentElement.style.height = `${contentSlotElement.clientHeight}px`
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import Icon from './Icon.svelte'
|
||||||
|
import BookmarkIcon from '../assets/icon/cil-bookmark.svg'
|
||||||
import { longBtcFormat, integerFormat } from '../utils/format.js'
|
import { longBtcFormat, integerFormat } from '../utils/format.js'
|
||||||
import { exchangeRates, settings } from '../stores.js'
|
import { exchangeRates, settings, sidebarToggle, newHighlightQuery, highlightingFull } from '../stores.js'
|
||||||
import { formatCurrency } from '../utils/fx.js'
|
import { formatCurrency } from '../utils/fx.js'
|
||||||
|
|
||||||
export let tx
|
export let tx
|
||||||
@ -32,6 +34,13 @@ $: {
|
|||||||
function formatBTC (sats) {
|
function formatBTC (sats) {
|
||||||
return `₿ ${longBtcFormat.format(sats/100000000)}`
|
return `₿ ${longBtcFormat.format(sats/100000000)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function highlight () {
|
||||||
|
if (!$highlightingFull && tx && tx.id) {
|
||||||
|
$newHighlightQuery = tx.id
|
||||||
|
$sidebarToggle = 'search'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style type="text/scss">
|
<style type="text/scss">
|
||||||
@ -81,10 +90,32 @@ function formatBTC (sats) {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
float: right;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
transition: opacity 300ms, color 300ms, background 300ms;
|
||||||
|
background: var(--palette-c);
|
||||||
|
color: var(--bold-a);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
&:hover {
|
||||||
|
background: var(--palette-e);
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
color: var(--palette-e);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="tx-info" class:above style="left: {clampedX}px; top: {clampedY}px">
|
<div class="tx-info" class:above style="left: {clampedX}px; top: {clampedY}px">
|
||||||
|
<div class="icon-button" class:disabled={$highlightingFull} on:click={highlight} title="Add to watchlist">
|
||||||
|
<Icon icon={BookmarkIcon}/>
|
||||||
|
</div>
|
||||||
<p class="field hash">
|
<p class="field hash">
|
||||||
TxID: { tx.id }
|
TxID: { tx.id }
|
||||||
</p>
|
</p>
|
||||||
|
@ -88,19 +88,22 @@ export default class BitcoinTx {
|
|||||||
this.highlight = false
|
this.highlight = false
|
||||||
}
|
}
|
||||||
|
|
||||||
applyHighlighting (criteria, color = highlightColor) {
|
applyHighlighting (criteria) {
|
||||||
|
let color
|
||||||
this.highlight = false
|
this.highlight = false
|
||||||
criteria.forEach(criterion => {
|
criteria.forEach(criterion => {
|
||||||
if (criterion.txid === this.id) {
|
if (criterion.txid === this.id) {
|
||||||
this.highlight = true
|
this.highlight = true
|
||||||
|
color = criterion.color
|
||||||
} else if (criterion.address && criterion.scriptPubKey) {
|
} else if (criterion.address && criterion.scriptPubKey) {
|
||||||
this.outputs.forEach(output => {
|
this.outputs.forEach(output => {
|
||||||
if (output.script_pub_key === criterion.scriptPubKey) {
|
if (output.script_pub_key === criterion.scriptPubKey) {
|
||||||
this.highlight = true
|
this.highlight = true
|
||||||
|
color = criterion.color
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.view.setHighlight(this.highlight, color)
|
this.view.setHighlight(this.highlight, color || highlightColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,3 +121,5 @@ const newVisitor = !localStorage.getItem('seen-welcome-msg')
|
|||||||
export const overlay = writable(null)
|
export const overlay = writable(null)
|
||||||
|
|
||||||
export const highlight = writable([])
|
export const highlight = writable([])
|
||||||
|
export const newHighlightQuery = writable(null)
|
||||||
|
export const highlightingFull = writable(false)
|
||||||
|
@ -11,25 +11,28 @@ export function matchQuery (query) {
|
|||||||
const asInt = parseInt(q)
|
const asInt = parseInt(q)
|
||||||
// Remember to update the bounds in
|
// Remember to update the bounds in
|
||||||
if (!isNaN(asInt) && asInt >= 0 && `${asInt}` === q) {
|
if (!isNaN(asInt) && asInt >= 0 && `${asInt}` === q) {
|
||||||
return {
|
return null /*{
|
||||||
query: 'blockheight',
|
query: 'blockheight',
|
||||||
height: asInt
|
height: asInt,
|
||||||
}
|
value: asInt
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// Looks like a block hash?
|
// Looks like a block hash?
|
||||||
if (/^0{8}[a-f0-9]{56}$/.test(q)) {
|
if (/^0{8}[a-f0-9]{56}$/.test(q)) {
|
||||||
return {
|
return null /* {
|
||||||
query: 'blockhash',
|
query: 'blockhash',
|
||||||
hash: q
|
hash: query,
|
||||||
}
|
value: query,
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// Looks like a transaction id?
|
// Looks like a transaction id?
|
||||||
if (/^[a-f0-9]{64}$/.test(q)) {
|
if (/^[a-f0-9]{64}$/.test(q)) {
|
||||||
return {
|
return {
|
||||||
query: 'txid',
|
query: 'txid',
|
||||||
txid: q
|
txid: q,
|
||||||
|
value: q
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +50,7 @@ export function matchQuery (query) {
|
|||||||
encoding: 'base58',
|
encoding: 'base58',
|
||||||
addressType,
|
addressType,
|
||||||
address: query,
|
address: query,
|
||||||
|
value: query,
|
||||||
scriptPubKey: addressToSPK(query)
|
scriptPubKey: addressToSPK(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,6 +71,7 @@ export function matchQuery (query) {
|
|||||||
encoding: 'bech32',
|
encoding: 'bech32',
|
||||||
addressType,
|
addressType,
|
||||||
address: query,
|
address: query,
|
||||||
|
value: query,
|
||||||
scriptPubKey: addressToSPK(query)
|
scriptPubKey: addressToSPK(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user