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 @@
-
+
-
-
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 @@
+
+
+ TXID |
+ Amount |
+ {{ 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
+
+
+
+
+
+
+
+
+
+
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') {
+
+ }
+ @case ('difficulty') {
+
+ }
+ @case ('goggles') {
+
+ }
+ @case ('incoming') {
+
+
+
+
+
Incoming Transactions
+
+
+
+
+
+
+
+
Minimum fee
+
Purging
+
+ <
+
+
+
+
Unconfirmed
+
+ {{ mempoolInfoData.value.memPoolInfo.size | number }} TXs
+
+
+
+
+
+ }
+ @case ('replacements') {
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ }
+ @case ('blocks') {
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ }
+ @case ('transactions') {
+
+
+
+
Recent Transactions
+
+
+ TXID |
+ Amount |
+ {{ currency }} |
+ Fee |
+
+
+
+
+
+
+
+ |
+ Confidential |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ }
+ @case ('balance') {
+
+ }
+ @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 @@
+
+
+
+