diff --git a/frontend/angular.json b/frontend/angular.json index f55c09934..46cc3f667 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -166,6 +166,7 @@ "src/resources", "src/robots.txt", "src/config.js", + "src/customize.js", "src/config.template.js" ], "styles": [ diff --git a/frontend/custom-sv-config.json b/frontend/custom-sv-config.json new file mode 100644 index 000000000..be10cbb48 --- /dev/null +++ b/frontend/custom-sv-config.json @@ -0,0 +1,42 @@ +{ + "theme": "contrast", + "enterprise": "onbtc", + "branding": { + "name": "onbtc", + "title": "Oficina Nacional del Bitcoin", + "img": "/resources/elsalvador.svg", + "rounded_corner": true + }, + "dashboard": { + "widgets": [ + { + "component": "fees" + }, + { + "component": "balance", + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + }, + { + "component": "goggles" + }, + { + "component": "address", + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo", + "period": "1m" + } + }, + { + "component": "blocks" + }, + { + "component": "addressTransactions", + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + } + ] + } +} \ No newline at end of file diff --git a/frontend/generate-config.js b/frontend/generate-config.js index c7a81a482..8f911dfe6 100644 --- a/frontend/generate-config.js +++ b/frontend/generate-config.js @@ -4,6 +4,7 @@ const { spawnSync } = require('child_process'); const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js'; const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js'; +const GENERATED_CUSTOMIZATION_FILE_NAME = 'src/resources/customize.js'; let settings = []; let configContent = {}; @@ -109,6 +110,23 @@ writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate); const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); +let customConfigJs = ''; +if (configContent && configContent.CUSTOMIZATION) { + const customConfig = readConfig(configContent.CUSTOMIZATION); + if (customConfig) { + console.log(`Customizing frontend using ${configContent.CUSTOMIZATION}`); + customConfigJs = `(function (window) { + window.__env = window.__env || {}; + window.__env.customize = ${customConfig}; + }((typeof global !== 'undefined') ? global : this)); + `; + } else { + throw new Error('Failed to load customization file'); + } +} + +writeConfig(GENERATED_CUSTOMIZATION_FILE_NAME, customConfigJs); + if (currentConfig && currentConfig === newConfig) { console.log(`No configuration updates, skipping ${GENERATED_CONFIG_FILE_NAME} file update`); return; diff --git a/frontend/src/app/components/address-graph/address-graph.component.html b/frontend/src/app/components/address-graph/address-graph.component.html index 35808cb14..df4cdf330 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.html +++ b/frontend/src/app/components/address-graph/address-graph.component.html @@ -1,14 +1,14 @@ - + -
-
+
+
Balance History
-
+
-
@@ -20,4 +20,8 @@

{{ error }}

+ +
+
+
diff --git a/frontend/src/app/components/address-graph/address-graph.component.scss b/frontend/src/app/components/address-graph/address-graph.component.scss index d23b95d8d..a118549fb 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.scss +++ b/frontend/src/app/components/address-graph/address-graph.component.scss @@ -66,7 +66,6 @@ .chart-widget { width: 100%; height: 100%; - max-height: 270px; } .disabled { diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 6ae3dd8e8..26a1bd408 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,12 +1,22 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { ChainStats } from '../../interfaces/electrs.interface'; +import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '../../services/state.service'; + +const periodSeconds = { + '1d': (60 * 60 * 24), + '3d': (60 * 60 * 24 * 3), + '1w': (60 * 60 * 24 * 7), + '1m': (60 * 60 * 24 * 30), + '6m': (60 * 60 * 24 * 180), + '1y': (60 * 60 * 24 * 365), +}; @Component({ selector: 'app-address-graph', @@ -26,8 +36,12 @@ export class AddressGraphComponent implements OnChanges { @Input() address: string; @Input() isPubkey: boolean = false; @Input() stats: ChainStats; + @Input() addressSummary$: Observable | null; + @Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all'; + @Input() height: number = 200; @Input() right: number | string = 10; @Input() left: number | string = 70; + @Input() widget: boolean = false; data: any[] = []; hoverData: any[] = []; @@ -43,6 +57,7 @@ export class AddressGraphComponent implements OnChanges { constructor( @Inject(LOCALE_ID) public locale: string, + public stateService: StateService, private electrsApiService: ElectrsApiService, private router: Router, private amountShortenerPipe: AmountShortenerPipe, @@ -52,14 +67,17 @@ export class AddressGraphComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; - (this.isPubkey + if (!this.address || !this.stats) { + return; + } + (this.addressSummary$ || (this.isPubkey ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') : this.electrsApiService.getAddressSummary$(this.address)).pipe( catchError(e => { this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; return of(null); }), - ).subscribe(addressSummary => { + )).subscribe(addressSummary => { if (addressSummary) { this.error = null; this.prepareChartOptions(addressSummary); @@ -70,14 +88,24 @@ export class AddressGraphComponent implements OnChanges { } prepareChartOptions(summary): void { - let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); // + (summary[0]?.value || 0); + let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); this.data = summary.map(d => { const balance = total; total -= d.value; return [d.time * 1000, balance, d]; }).reverse(); - const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1])), 0); + if (this.period !== 'all') { + const now = Date.now(); + const start = now - (periodSeconds[this.period] * 1000); + this.data = this.data.filter(d => d[0] >= start); + this.data.push( + {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} + ); + } + + const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] || d.value[1])), 0); + const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] || d.value[1])), maxValue); this.chartOptions = { color: [ @@ -108,6 +136,9 @@ export class AddressGraphComponent implements OnChanges { }, borderColor: '#000', formatter: function (data): string { + if (!data?.length || !data[0]?.data?.[2]?.txid) { + return ''; + } const header = data.length === 1 ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` : `${data.length} transactions`; @@ -141,13 +172,17 @@ export class AddressGraphComponent implements OnChanges { axisLabel: { color: 'rgb(110, 112, 121)', formatter: (val): string => { - if (maxValue > 1_000_000_000) { + let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); + if (valSpan > 100_000_000_000) { return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; - } else if (maxValue > 100_000_000) { + } + else if (valSpan > 1_000_000_000) { + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; + } else if (valSpan > 100_000_000) { return `${(val / 100_000_000).toFixed(1)} BTC`; - } else if (maxValue > 10_000_000) { + } else if (valSpan > 10_000_000) { return `${(val / 100_000_000).toFixed(2)} BTC`; - } else if (maxValue > 1_000_000) { + } else if (valSpan > 1_000_000) { return `${(val / 100_000_000).toFixed(3)} BTC`; } else { return `${this.amountShortenerPipe.transform(val, 0)} sats`; @@ -157,6 +192,7 @@ export class AddressGraphComponent implements OnChanges { splitLine: { show: false, }, + min: this.period === 'all' ? 0 : 'dataMin' }, ], series: [ diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html new file mode 100644 index 000000000..00b3160ed --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + +
 
+
TXIDAmount{{ currency }}Date
+ + + +
+ + + + +
+
+
+
+ + +
\ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss new file mode 100644 index 000000000..851da5996 --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss @@ -0,0 +1,50 @@ +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-date { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} \ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts new file mode 100644 index 000000000..c3fc4260e --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs'; +import { PriceService } from '../../services/price.service'; + +@Component({ + selector: 'app-address-transactions-widget', + templateUrl: './address-transactions-widget.component.html', + styleUrls: ['./address-transactions-widget.component.scss'], +}) +export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, OnDestroy { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + currencySubscription: Subscription; + currency: string; + + transactions$: Observable; + + isLoading: boolean = true; + error: any; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private priceService: PriceService, + ) { } + + ngOnInit(): void { + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + this.startAddressSubscription(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.startAddressSubscription(); + } + + startAddressSubscription(): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + this.transactions$ = (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }) + )).pipe( + map(summary => { + return summary?.slice(0, 6); + }), + switchMap(txs => { + return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, true, this.currency).pipe( + map(price => { + return { + ...tx, + price, + }; + }) + )))); + }) + ); + } + + ngOnDestroy(): void { + this.currencySubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.html b/frontend/src/app/components/balance-widget/balance-widget.component.html new file mode 100644 index 000000000..4923a2c06 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.html @@ -0,0 +1,59 @@ +
+
+
+
+
BTC Holdings
+
+ {{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (7d)
+
+ {{ delta7d > 0 ? '+' : ''}}{{ ((delta7d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (30d)
+
+ {{ delta30d > 0 ? '+' : ''}}{{ ((delta30d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
+
+ + +
+
+
BTC Holdings
+
+
+
+
+
+
+
Change (7d)
+
+
+
+
+
+
+
Change (30d)
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.scss b/frontend/src/app/components/balance-widget/balance-widget.component.scss new file mode 100644 index 000000000..a2f803c79 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.scss @@ -0,0 +1,160 @@ +.balance-container { + display: flex; + flex-direction: row; + justify-content: space-around; + height: 76px; + .shared-block { + color: var(--transparent-fg); + font-size: 12px; + } + .item { + padding: 0 5px; + width: 100%; + max-width: 150px; + &:last-child { + display: none; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + } + .card-text { + font-size: 22px; + margin-top: -9px; + position: relative; + } +} + + +.balance-skeleton { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + min-width: 120px; + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:last-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + } + .card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + margin: 14px auto 0; + max-width: 80px; + } + &:last-child { + margin: 10px auto 0; + max-width: 120px; + } + } + } +} + +.card { + background-color: var(--bg); + height: 126px; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 24px 20px; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 24px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.symbol { + font-size: 13px; + white-space: nowrap; +} \ No newline at end of file diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.ts b/frontend/src/app/components/balance-widget/balance-widget.component.ts new file mode 100644 index 000000000..c48cbc869 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, catchError, of } from 'rxjs'; + +@Component({ + selector: 'app-balance-widget', + templateUrl: './balance-widget.component.html', + styleUrls: ['./balance-widget.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BalanceWidgetComponent implements OnInit, OnChanges { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + isLoading: boolean = true; + error: any; + + delta7d: number = 0; + delta30d: number = 0; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + + } + + ngOnChanges(changes: SimpleChanges): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + )).subscribe(addressSummary => { + if (addressSummary) { + this.error = null; + this.calculateStats(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + calculateStats(summary: AddressTxSummary[]): void { + let weekTotal = 0; + let monthTotal = 0; + const weekAgo = (Date.now() / 1000) - (60 * 60 * 24 * 7); + const monthAgo = (Date.now() / 1000) - (60 * 60 * 24 * 30); + for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) { + monthTotal += summary[i].value; + if (summary[i].time >= weekAgo) { + weekTotal += summary[i].value; + } + } + this.delta7d = weekTotal; + this.delta30d = monthTotal; + } +} diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html new file mode 100644 index 000000000..9180571a0 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -0,0 +1,270 @@ + +
+
+ @for (widget of widgets; track widget.component) { + @switch (widget.component) { + @case ('fees') { +
+
Transaction Fees
+
+
+ +
+
+
+ } + @case ('difficulty') { +
+ +
+ } + @case ('goggles') { +
+
+ +
+
+ } + @case ('incoming') { +
+
+
+ +
Incoming Transactions
+
+ +
+
+
+
+ +
+
+
Minimum fee
+
Purging
+

+ < +

+
+
+
Unconfirmed
+

+ {{ mempoolInfoData.value.memPoolInfo.size | number }} TXs +

+
+
+
Memory Usage
+
+
+
 
+
/
+
+
+
+
+
+ } + @case ('replacements') { +
+
+
+ +
Recent Replacements
+   + +
+ + + + + + + + + + + + + + + +
TXIDPrevious feeNew feeStatus
+ + + + + Mined + Full RBF + RBF +
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('blocks') { +
+
+
+ +
Recent Blocks
+   + +
+ + + + + + + + + + + + + + + +
HeightMinedTXsSize
{{ block.height }}{{ block.tx_count | number }} +
+
 
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('transactions') { +
+
+
+
Recent Transactions
+ + + + + + + + + + + + + + + +
TXIDAmount{{ currency }}Fee
+ + + + Confidential
+
 
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('balance') { +
+
Treasury
+ +
+ } + @case ('address') { + + } + @case ('addressTransactions') { + + } + } + } +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss new file mode 100644 index 000000000..4a9ffe94a --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss @@ -0,0 +1,490 @@ +.dashboard-container { + text-align: center; + margin-top: 0.5rem; + .col { + margin-bottom: 1.5rem; + } +} + +.card { + background-color: var(--bg); + height: 100%; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; +} + +.info-block { + float: left; + width: 350px; + line-height: 25px; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.bg-warning { + background-color: #b58800 !important; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 18px; +} + +.graph-card { + height: 100%; + @media (min-width: 768px) { + height: 415px; + } + @media (min-width: 992px) { + height: 510px; + } +} + +.mempool-info-data { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + &.lbtc-pegs-stats { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + margin: 0px auto 20px; + display: inline-block; + @media (min-width: 485px) { + margin: 0px auto 10px; + } + @media (min-width: 768px) { + margin: 0px auto 0px; + } + &:last-child { + margin: 0px auto 0px; + } + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + .bitcoin-color { + color: var(--orange); + } + } + .progress { + width: 90%; + @media (min-width: 768px) { + width: 100%; + } + } + } + .bar { + width: 93%; + margin: 0px 5px 20px; + @media (min-width: 485px) { + max-width: 200px; + margin: 0px auto 0px; + } + } + .skeleton-loader { + width: 100%; + max-width: 100px; + display: block; + margin: 18px auto 0; + } + .skeleton-loader-big { + max-width: 180px; + } +} + +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-fees { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} + +.lastest-blocks-table { + width: 100%; + text-align: left; + tr, td, th { + border: 0px; + padding-top: 0.65rem !important; + padding-bottom: 0.7rem !important; + } + .table-cell-height { + width: 15%; + } + .table-cell-mined { + width: 35%; + text-align: left; + } + .table-cell-transaction-count { + display: none; + text-align: right; + width: 20%; + display: table-cell; + } + .table-cell-size { + display: none; + text-align: center; + width: 30%; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } +} + +.lastest-replacements-table { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-txid { + width: 25%; + text-align: start; + } + .table-cell-old-fee { + width: 25%; + text-align: end; + + @media(max-width: 1080px) { + display: none; + } + } + .table-cell-new-fee { + width: 20%; + text-align: end; + } + .table-cell-badges { + width: 23%; + padding-right: 0; + padding-left: 5px; + text-align: end; + + .badge { + margin-left: 5px; + } + } +} + +.mempool-graph { + height: 255px; + @media (min-width: 768px) { + height: 285px; + } + @media (min-width: 992px) { + height: 370px; + } +} +.loadingGraphs{ + height: 250px; + display: grid; + place-items: center; +} + +.inc-tx-progress-bar { + max-width: 250px; + .progress-bar { + padding: 4px; + } +} + +.terms-of-service { + margin-top: 1rem; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 22px 20px; + &.liquid { + height: 124.5px; + } + } + .less-padding { + padding: 20px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.assetIcon { + width: 40px; + height: 40px; +} + +.asset-title { + text-align: left; + vertical-align: middle; +} + +.asset-icon { + width: 65px; + height: 65px; + vertical-align: middle; +} + +.circulating-amount { + text-align: right; + width: 100%; + vertical-align: middle; +} + +.clear-link { + color: white; +} + +.pool-name { + display: inline-block; + vertical-align: text-top; + padding-left: 10px; +} + +.title-link, .title-link:hover, .title-link:focus, .title-link:active { + display: block; + margin-bottom: 10px; + text-decoration: none; + color: inherit; +} + +.mempool-block-wrapper { + max-height: 410px; + max-width: 410px; + margin: auto; + + @media (min-width: 768px) { + max-height: 344px; + max-width: 344px; + } + @media (min-width: 992px) { + max-height: 410px; + max-width: 410px; + } +} + +.goggle-badge { + margin: 6px 5px 8px; + background: none; + border: solid 2px var(--primary); + cursor: pointer; + + &.active { + background: var(--primary); + } +} + +.btn-xs { + padding: 0.35rem 0.5rem; + font-size: 12px; +} + +.quick-filter { + margin-top: 5px; + margin-bottom: 6px; +} + +.card-liquid { + background-color: var(--bg); + height: 418px; + @media (min-width: 992px) { + height: 512px; + } + &.smaller { + height: 408px; + } +} + +.card-title-liquid { + padding-top: 20px; + margin-left: 10px; +} + +.in-progress-message { + position: relative; + color: #ffffff91; + margin-top: 20px; + text-align: center; + padding-bottom: 3px; + font-weight: 500; +} + +.stats-card { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: var(--title-fg); + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts new file mode 100644 index 000000000..2847b6586 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -0,0 +1,372 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { combineLatest, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { catchError, filter, map, scan, share, shareReplay, startWith, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, OptimizedMempoolStats, TransactionStripped } from '../../interfaces/node-api.interface'; +import { MempoolInfo, ReplacementInfo } from '../../interfaces/websocket.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; +import { SeoService } from '../../services/seo.service'; +import { ActiveFilter, FilterMode, GradientMode, toFlags } from '../../shared/filters.utils'; +import { detectWebGL } from '../../shared/graphs.utils'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; + +interface MempoolBlocksData { + blocks: number; + size: number; +} + +interface MempoolInfoData { + memPoolInfo: MempoolInfo; + vBytesPerSecond: number; + progressWidth: string; + progressColor: string; +} + +interface MempoolStatsData { + mempool: OptimizedMempoolStats[]; + weightPerSecond: any; +} + +@Component({ + selector: 'app-custom-dashboard', + templateUrl: './custom-dashboard.component.html', + styleUrls: ['./custom-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewInit { + network$: Observable; + mempoolBlocksData$: Observable; + mempoolInfoData$: Observable; + mempoolLoadingStatus$: Observable; + vBytesPerSecondLimit = 1667; + transactions$: Observable; + blocks$: Observable; + replacements$: Observable; + latestBlockHeight: number; + mempoolTransactionsWeightPerSecondData: any; + mempoolStats$: Observable; + transactionsWeightPerSecondOptions: any; + isLoadingWebSocket$: Observable; + isLoad: boolean = true; + filterSubscription: Subscription; + mempoolInfoSubscription: Subscription; + currencySubscription: Subscription; + currency: string; + incomingGraphHeight: number = 300; + graphHeight: number = 300; + webGlEnabled = true; + + widgets; + + addressSubscription: Subscription; + blockTxSubscription: Subscription; + addressSummary$: Observable; + address: Address; + + goggleResolution = 82; + goggleCycle: { index: number, name: string, mode: FilterMode, filters: string[], gradient: GradientMode }[] = [ + { index: 0, name: $localize`:@@dfc3c34e182ea73c5d784ff7c8135f087992dac1:All`, mode: 'and', filters: [], gradient: 'age' }, + { index: 1, name: $localize`Consolidation`, mode: 'and', filters: ['consolidation'], gradient: 'fee' }, + { index: 2, name: $localize`Coinjoin`, mode: 'and', filters: ['coinjoin'], gradient: 'fee' }, + { index: 3, name: $localize`Data`, mode: 'or', filters: ['inscription', 'fake_pubkey', 'op_return'], gradient: 'fee' }, + ]; + goggleFlags = 0n; + goggleMode: FilterMode = 'and'; + gradientMode: GradientMode = 'age'; + goggleIndex = 0; + + private destroy$ = new Subject(); + + constructor( + public stateService: StateService, + private apiService: ApiService, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private seoService: SeoService, + @Inject(PLATFORM_ID) private platformId: Object, + ) { + this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); + this.widgets = this.stateService.env.customize?.dashboard.widgets || []; + } + + ngAfterViewInit(): void { + this.stateService.focusSearchInputDesktop(); + } + + ngOnDestroy(): void { + this.filterSubscription.unsubscribe(); + this.mempoolInfoSubscription.unsubscribe(); + this.currencySubscription.unsubscribe(); + this.websocketService.stopTrackRbfSummary(); + if (this.addressSubscription) { + this.addressSubscription.unsubscribe(); + this.websocketService.stopTrackingAddress(); + this.address = null; + } + this.destroy$.next(1); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.onResize(); + this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; + this.seoService.resetTitle(); + this.seoService.resetDescription(); + this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']); + this.websocketService.startTrackRbfSummary(); + this.network$ = merge(of(''), this.stateService.networkChanged$); + this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$ + .pipe( + map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100) + ); + + this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => { + const activeFilters = active.filters.sort().join(','); + for (const goggle of this.goggleCycle) { + if (goggle.mode === active.mode) { + const goggleFilters = goggle.filters.sort().join(','); + if (goggleFilters === activeFilters) { + this.goggleIndex = goggle.index; + this.goggleFlags = toFlags(goggle.filters); + this.goggleMode = goggle.mode; + this.gradientMode = active.gradient; + return; + } + } + } + this.goggleCycle.push({ + index: this.goggleCycle.length, + name: 'Custom', + mode: active.mode, + filters: active.filters, + gradient: active.gradient, + }); + this.goggleIndex = this.goggleCycle.length - 1; + this.goggleFlags = toFlags(active.filters); + this.goggleMode = active.mode; + }); + + this.mempoolInfoData$ = combineLatest([ + this.stateService.mempoolInfo$, + this.stateService.vbytesPerSecond$ + ]).pipe( + map(([mempoolInfo, vbytesPerSecond]) => { + const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); + + let progressColor = 'bg-success'; + if (vbytesPerSecond > 1667) { + progressColor = 'bg-warning'; + } + if (vbytesPerSecond > 3000) { + progressColor = 'bg-danger'; + } + + const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); + let mempoolSizeProgress = 'bg-danger'; + if (mempoolSizePercentage <= 50) { + mempoolSizeProgress = 'bg-success'; + } else if (mempoolSizePercentage <= 75) { + mempoolSizeProgress = 'bg-warning'; + } + + return { + memPoolInfo: mempoolInfo, + vBytesPerSecond: vbytesPerSecond, + progressWidth: percent + '%', + progressColor: progressColor, + mempoolSizeProgress: mempoolSizeProgress, + }; + }) + ); + + this.mempoolInfoSubscription = this.mempoolInfoData$.subscribe(); + + this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ + .pipe( + map((mempoolBlocks) => { + const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b, 0); + const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b, 0); + + return { + size: size, + blocks: Math.ceil(vsize / this.stateService.blockVSize) + }; + }) + ); + + this.transactions$ = this.stateService.transactions$; + + this.blocks$ = this.stateService.blocks$ + .pipe( + tap((blocks) => { + this.latestBlockHeight = blocks[0].height; + }), + switchMap((blocks) => { + if (this.stateService.env.MINING_DASHBOARD === true) { + for (const block of blocks) { + // @ts-ignore: Need to add an extra field for the template + block.extras.pool.logo = `/resources/mining-pools/` + + block.extras.pool.slug + '.svg'; + } + } + return of(blocks.slice(0, 6)); + }) + ); + + this.replacements$ = this.stateService.rbfLatestSummary$; + + this.mempoolStats$ = this.stateService.connectionState$ + .pipe( + filter((state) => state === 2), + switchMap(() => this.apiService.list2HStatistics$().pipe( + catchError((e) => { + return of(null); + }) + )), + switchMap((mempoolStats) => { + return merge( + this.stateService.live2Chart$ + .pipe( + scan((acc, stats) => { + acc.unshift(stats); + acc = acc.slice(0, 120); + return acc; + }, (mempoolStats || [])) + ), + of(mempoolStats) + ); + }), + map((mempoolStats) => { + if (mempoolStats) { + return { + mempool: mempoolStats, + weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), + }; + } else { + return null; + } + }), + shareReplay(1), + ); + + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + + this.startAddressSubscription(); + } + + handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { + mempoolStats.reverse(); + const labels = mempoolStats.map(stats => stats.added); + + return { + labels: labels, + series: [mempoolStats.map((stats) => [stats.added * 1000, stats.vbytes_per_second])], + }; + } + + trackByBlock(index: number, block: BlockExtended) { + return block.height; + } + + getArrayFromNumber(num: number): number[] { + return Array.from({ length: num }, (_, i) => i + 1); + } + + setFilter(index): void { + const selected = this.goggleCycle[index]; + this.stateService.activeGoggles$.next(selected); + } + + startAddressSubscription(): void { + if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.address)) { + const address = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.address).props.address; + const addressString = (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) ? address.toLowerCase() : address; + + this.addressSubscription = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getPubKeyAddress$(addressString) + : this.electrsApiService.getAddress$(addressString) + ).pipe( + catchError((err) => { + console.log(err); + return of(null); + }), + filter((address) => !!address), + ).subscribe((address: Address) => { + this.websocketService.startTrackAddress(address.address); + this.address = address; + }); + + this.addressSummary$ = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getScriptHashSummary$((addressString.length === 66 ? '21' : '41') + addressString + 'ac') + : this.electrsApiService.getAddressSummary$(addressString)).pipe( + catchError(e => { + return of(null); + }), + switchMap(initial => this.stateService.blockTransactions$.pipe( + startWith(null), + scan((summary, tx) => { + if (tx && !summary.some(t => t.txid === tx.txid)) { + let value = 0; + let funded = 0; + let fundedCount = 0; + let spent = 0; + let spentCount = 0; + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === addressString) { + value += vout.value; + funded += vout.value; + fundedCount++; + } + } + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === addressString) { + value -= vin.prevout?.value; + spent += vin.prevout?.value; + spentCount++; + } + } + if (this.address && this.address.address === addressString) { + this.address.chain_stats.tx_count++; + this.address.chain_stats.funded_txo_sum += funded; + this.address.chain_stats.funded_txo_count += fundedCount; + this.address.chain_stats.spent_txo_sum += spent; + this.address.chain_stats.spent_txo_count += spentCount; + } + summary.unshift({ + txid: tx.txid, + time: tx.status?.block_time, + height: tx.status?.block_height, + value + }); + } + return summary; + }, initial) + )), + share(), + ); + } + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (window.innerWidth >= 992) { + this.incomingGraphHeight = 300; + this.goggleResolution = 82; + this.graphHeight = 400; + } else if (window.innerWidth >= 768) { + this.incomingGraphHeight = 215; + this.goggleResolution = 80; + this.graphHeight = 310; + } else { + this.incomingGraphHeight = 180; + this.goggleResolution = 86; + this.graphHeight = 310; + } + } +} diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index f55a05fac..4a92fcdf1 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -19,7 +19,7 @@
- +
@@ -36,7 +36,7 @@
- +
diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 761bd8e1f..83aebed73 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -27,6 +27,7 @@ import { PoolRankingComponent } from '../components/pool-ranking/pool-ranking.co import { PoolComponent } from '../components/pool/pool.component'; import { TelevisionComponent } from '../components/television/television.component'; import { DashboardComponent } from '../dashboard/dashboard.component'; +import { CustomDashboardComponent } from '../components/custom-dashboard/custom-dashboard.component'; import { MiningDashboardComponent } from '../components/mining-dashboard/mining-dashboard.component'; import { AcceleratorDashboardComponent } from '../components/acceleration/accelerator-dashboard/accelerator-dashboard.component'; import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-chart.component'; @@ -39,6 +40,7 @@ import { CommonModule } from '@angular/common'; @NgModule({ declarations: [ DashboardComponent, + CustomDashboardComponent, MempoolBlockComponent, AddressComponent, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index e069022cd..9c7d55930 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -17,10 +17,16 @@ import { StartComponent } from '../components/start/start.component'; import { StatisticsComponent } from '../components/statistics/statistics.component'; import { TelevisionComponent } from '../components/television/television.component'; import { DashboardComponent } from '../dashboard/dashboard.component'; +import { CustomDashboardComponent } from '../components/custom-dashboard/custom-dashboard.component'; import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; import { AddressComponent } from '../components/address/address.component'; +const browserWindow = window || {}; +// @ts-ignore +const browserWindowEnv = browserWindow.__env || {}; +const isCustomized = browserWindowEnv?.customize; + const routes: Routes = [ { path: '', @@ -149,7 +155,7 @@ const routes: Routes = [ component: StartComponent, children: [{ path: '', - component: DashboardComponent, + component: isCustomized ? CustomDashboardComponent : DashboardComponent, }] }, ] diff --git a/frontend/src/app/services/enterprise.service.ts b/frontend/src/app/services/enterprise.service.ts index 4ad31bd9f..1201c0c75 100644 --- a/frontend/src/app/services/enterprise.service.ts +++ b/frontend/src/app/services/enterprise.service.ts @@ -23,7 +23,7 @@ export class EnterpriseService { private stateService: StateService, private activatedRoute: ActivatedRoute, ) { - const subdomain = this.document.location.hostname.indexOf(this.exclusiveHostName) > -1 + const subdomain = this.stateService.env.customize?.enterprise || this.document.location.hostname.indexOf(this.exclusiveHostName) > -1 && this.document.location.hostname.split(this.exclusiveHostName)[0] || false; if (subdomain && subdomain.match(/^[A-z0-9-_]+$/)) { this.subdomain = subdomain; @@ -47,16 +47,23 @@ export class EnterpriseService { } fetchSubdomainInfo(): void { - this.apiService.getEnterpriseInfo$(this.subdomain).subscribe((info) => { + if (this.stateService.env.customize?.branding) { + const info = this.stateService.env.customize?.branding; this.insertMatomo(info.site_id); this.seoService.setEnterpriseTitle(info.title); this.info$.next(info); - }, - (error) => { - if (error.status === 404) { - window.location.href = 'https://mempool.space' + window.location.pathname; - } - }); + } else { + this.apiService.getEnterpriseInfo$(this.subdomain).subscribe((info) => { + this.insertMatomo(info.site_id); + this.seoService.setEnterpriseTitle(info.title); + this.info$.next(info); + }, + (error) => { + if (error.status === 404) { + window.location.href = 'https://mempool.space' + window.location.pathname; + } + }); + } } insertMatomo(siteId?: number): void { diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index a083c538d..286ae5e48 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -20,6 +20,24 @@ export interface MarkBlockState { export interface ILoadingIndicators { [name: string]: number; } +export interface Customization { + theme: string; + enterprise?: string; + branding: { + name: string; + site_id?: number; + title: string; + img: string; + rounded_corner: boolean; + }, + dashboard: { + widgets: { + component: string; + props: { [key: string]: any }; + }[]; + }; +} + export interface Env { TESTNET_ENABLED: boolean; SIGNET_ENABLED: boolean; @@ -50,6 +68,7 @@ export interface Env { ADDITIONAL_CURRENCIES: boolean; GIT_COMMIT_HASH_MEMPOOL_SPACE?: string; PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string; + customize?: Customization; } const defaultEnv: Env = { diff --git a/frontend/src/app/services/theme.service.ts b/frontend/src/app/services/theme.service.ts index 098089597..7981f37a3 100644 --- a/frontend/src/app/services/theme.service.ts +++ b/frontend/src/app/services/theme.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; import { defaultMempoolFeeColors, contrastMempoolFeeColors } from '../app.constants'; import { StorageService } from './storage.service'; +import { StateService } from './state.service'; @Injectable({ providedIn: 'root' @@ -14,8 +15,9 @@ export class ThemeService { constructor( private storageService: StorageService, + private stateService: StateService, ) { - const theme = this.storageService.getValue('theme-preference') || 'default'; + const theme = this.storageService.getValue('theme-preference') || this.stateService.env.customize?.theme || 'default'; this.apply(theme); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 50268029b..80d6ca3cd 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -65,6 +65,8 @@ import { FeesBoxComponent } from '../components/fees-box/fees-box.component'; import { DifficultyComponent } from '../components/difficulty/difficulty.component'; import { DifficultyTooltipComponent } from '../components/difficulty/difficulty-tooltip.component'; import { DifficultyMiningComponent } from '../components/difficulty-mining/difficulty-mining.component'; +import { BalanceWidgetComponent } from '../components/balance-widget/balance-widget.component'; +import { AddressTransactionsWidgetComponent } from '../components/address-transactions-widget/address-transactions-widget.component'; import { RbfTimelineComponent } from '../components/rbf-timeline/rbf-timeline.component'; import { RbfTimelineTooltipComponent } from '../components/rbf-timeline/rbf-timeline-tooltip.component'; import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component'; @@ -173,6 +175,8 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir DifficultyComponent, DifficultyMiningComponent, DifficultyTooltipComponent, + BalanceWidgetComponent, + AddressTransactionsWidgetComponent, RbfTimelineComponent, RbfTimelineTooltipComponent, PushTransactionComponent, @@ -309,6 +313,8 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir DifficultyComponent, DifficultyMiningComponent, DifficultyTooltipComponent, + BalanceWidgetComponent, + AddressTransactionsWidgetComponent, RbfTimelineComponent, RbfTimelineTooltipComponent, PushTransactionComponent, diff --git a/frontend/src/index.mempool.html b/frontend/src/index.mempool.html index 838af21d0..ed5f7e0b4 100644 --- a/frontend/src/index.mempool.html +++ b/frontend/src/index.mempool.html @@ -5,6 +5,7 @@ mempool - Bitcoin Explorer + diff --git a/frontend/src/resources/elsalvador.svg b/frontend/src/resources/elsalvador.svg new file mode 100644 index 000000000..5b19fd4ca --- /dev/null +++ b/frontend/src/resources/elsalvador.svg @@ -0,0 +1,1131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +