mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 06:39:58 -07:00
global: snapshot
This commit is contained in:
@@ -22,6 +22,20 @@ pub fn generate_base_client(output: &mut String) {
|
||||
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
|
||||
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
|
||||
const _defaultCacheName = '__BRK_CLIENT__';
|
||||
/** @param {{*}} v */
|
||||
const _addCamelGetters = (v) => {{
|
||||
if (Array.isArray(v)) {{ v.forEach(_addCamelGetters); return v; }}
|
||||
if (v && typeof v === 'object' && v.constructor === Object) {{
|
||||
for (const k in v) {{
|
||||
if (k.includes('_')) {{
|
||||
const c = k.replace(/_([a-z])/g, (_, l) => l.toUpperCase());
|
||||
if (!(c in v)) Object.defineProperty(v, c, {{ get() {{ return this[k]; }} }});
|
||||
}}
|
||||
_addCamelGetters(v[k]);
|
||||
}}
|
||||
}}
|
||||
return v;
|
||||
}};
|
||||
|
||||
/**
|
||||
* @param {{string|boolean|undefined}} cache
|
||||
@@ -418,7 +432,7 @@ class BrkClientBase {{
|
||||
const cachePromise = cache?.match(url).then(async (res) => {{
|
||||
cachedRes = res ?? null;
|
||||
if (!res) return null;
|
||||
const json = await res.json();
|
||||
const json = _addCamelGetters(await res.json());
|
||||
if (!resolved && onUpdate) {{
|
||||
resolved = true;
|
||||
onUpdate(json);
|
||||
@@ -428,7 +442,7 @@ class BrkClientBase {{
|
||||
|
||||
const networkPromise = this.get(path).then(async (res) => {{
|
||||
const cloned = res.clone();
|
||||
const json = await res.json();
|
||||
const json = _addCamelGetters(await res.json());
|
||||
// Skip update if ETag matches and cache already delivered
|
||||
if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) {{
|
||||
if (!resolved && onUpdate) {{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{BlockHash, BlockHashPrefix, BlockInfo, Height, TxIndex};
|
||||
use brk_types::{
|
||||
BlockExtras, BlockHash, BlockHashPrefix, BlockInfo, BlockPool, Height, TxIndex, pools,
|
||||
};
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BlockHash, Height, Timestamp, Weight};
|
||||
use crate::{BlockHash, Height, PoolSlug, Timestamp, Weight};
|
||||
|
||||
/// Block information returned by the API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -26,4 +26,40 @@ pub struct BlockInfo {
|
||||
|
||||
/// Block difficulty as a floating point number
|
||||
pub difficulty: f64,
|
||||
|
||||
/// Extra block data (pool info, fee stats)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extras: Option<BlockExtras>,
|
||||
}
|
||||
|
||||
/// Extra block data including pool identification and fee statistics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct BlockExtras {
|
||||
/// Mining pool that mined this block
|
||||
pub pool: BlockPool,
|
||||
|
||||
/// Total fees in satoshis
|
||||
pub total_fees: u64,
|
||||
|
||||
/// Average fee per transaction in satoshis
|
||||
pub avg_fee: u64,
|
||||
|
||||
/// Average fee rate in sat/vB
|
||||
pub avg_fee_rate: u64,
|
||||
|
||||
/// Total block reward (subsidy + fees) in satoshis
|
||||
pub reward: u64,
|
||||
}
|
||||
|
||||
/// Mining pool identification for a block
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct BlockPool {
|
||||
/// Unique pool identifier
|
||||
pub id: u8,
|
||||
|
||||
/// Pool name
|
||||
pub name: String,
|
||||
|
||||
/// URL-friendly pool identifier
|
||||
pub slug: PoolSlug,
|
||||
}
|
||||
|
||||
@@ -982,6 +982,20 @@
|
||||
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
|
||||
const _runIdle = (/** @type {VoidFunction} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
|
||||
const _defaultCacheName = '__BRK_CLIENT__';
|
||||
/** @param {*} v */
|
||||
const _addCamelGetters = (v) => {
|
||||
if (Array.isArray(v)) { v.forEach(_addCamelGetters); return v; }
|
||||
if (v && typeof v === 'object' && v.constructor === Object) {
|
||||
for (const k in v) {
|
||||
if (k.includes('_')) {
|
||||
const c = k.replace(/_([a-z])/g, (_, l) => l.toUpperCase());
|
||||
if (!(c in v)) Object.defineProperty(v, c, { get() { return this[k]; } });
|
||||
}
|
||||
_addCamelGetters(v[k]);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string|boolean|undefined} cache
|
||||
@@ -1378,7 +1392,7 @@ class BrkClientBase {
|
||||
const cachePromise = cache?.match(url).then(async (res) => {
|
||||
cachedRes = res ?? null;
|
||||
if (!res) return null;
|
||||
const json = await res.json();
|
||||
const json = _addCamelGetters(await res.json());
|
||||
if (!resolved && onUpdate) {
|
||||
resolved = true;
|
||||
onUpdate(json);
|
||||
@@ -1388,7 +1402,7 @@ class BrkClientBase {
|
||||
|
||||
const networkPromise = this.get(path).then(async (res) => {
|
||||
const cloned = res.clone();
|
||||
const json = await res.json();
|
||||
const json = _addCamelGetters(await res.json());
|
||||
// Skip update if ETag matches and cache already delivered
|
||||
if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) {
|
||||
if (!resolved && onUpdate) {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<link rel="stylesheet" href="/styles/search.css" />
|
||||
<link rel="stylesheet" href="/styles/chart.css" />
|
||||
<link rel="stylesheet" href="/styles/panes/chart.css" />
|
||||
<link rel="stylesheet" href="/styles/panes/explorer.css" />
|
||||
<!-- /IMPORTMAP -->
|
||||
|
||||
<!-- ------- -->
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
* @typedef {Brk.ActivityOutputsRealizedSupplyUnrealizedPattern3} EmptyPattern
|
||||
* @typedef {Brk._0sdM0M1M1sdM2M2sdM3sdP0P1P1sdP2P2sdP3sdSdZscorePattern} Ratio1ySdPattern
|
||||
* @typedef {Brk.Dollars} Dollars
|
||||
* @typedef {Brk.BlockInfo} BlockInfo
|
||||
* ActivePriceRatioPattern: ratio pattern with price (extended)
|
||||
* @typedef {Brk.BpsPriceRatioPattern} ActivePriceRatioPattern
|
||||
* PriceRatioPercentilesPattern: price pattern with ratio + percentiles (no SMAs/stdDev)
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
init as initChart,
|
||||
setOption as setChartOption,
|
||||
} from "./panes/chart.js";
|
||||
import { initSearch } from "./panes/search.js";
|
||||
import { init as initExplorer } from "./panes/explorer.js";
|
||||
import { init as initSearch } from "./panes/search.js";
|
||||
import { replaceHistory } from "./utils/url.js";
|
||||
import { idle } from "./utils/timing.js";
|
||||
import { removeStored, writeToStorage } from "./utils/storage.js";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
asideLabelElement,
|
||||
bodyElement,
|
||||
chartElement,
|
||||
explorerElement,
|
||||
frameSelectorsElement,
|
||||
mainElement,
|
||||
navElement,
|
||||
@@ -145,12 +147,23 @@ function initSelected() {
|
||||
|
||||
let previousElement = /** @type {HTMLElement | undefined} */ (undefined);
|
||||
let firstTimeLoadingChart = true;
|
||||
let firstTimeLoadingExplorer = true;
|
||||
|
||||
options.selected.onChange((option) => {
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let element;
|
||||
|
||||
switch (option.kind) {
|
||||
case "explorer": {
|
||||
element = explorerElement;
|
||||
|
||||
if (firstTimeLoadingExplorer) {
|
||||
initExplorer();
|
||||
}
|
||||
firstTimeLoadingExplorer = false;
|
||||
|
||||
break;
|
||||
}
|
||||
case "chart": {
|
||||
element = chartElement;
|
||||
|
||||
@@ -224,7 +237,6 @@ function initDesktopResizeBar() {
|
||||
* @param {number | null} width
|
||||
*/
|
||||
function setBarWidth(width) {
|
||||
// TODO: Check if should be a signal ??
|
||||
try {
|
||||
if (typeof width === "number") {
|
||||
mainElement.style.width = `${width}px`;
|
||||
|
||||
@@ -57,6 +57,11 @@ export function createPartialOptions() {
|
||||
} = buildCohortData();
|
||||
|
||||
return [
|
||||
{
|
||||
name: "Explorer",
|
||||
kind: "explorer",
|
||||
title: "Explorer",
|
||||
},
|
||||
{
|
||||
name: "Charts",
|
||||
tree: [
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// import { randomFromArray } from "../utils/array.js";
|
||||
// import { explorerElement } from "../utils/elements.js";
|
||||
|
||||
// export function init() {
|
||||
// const chain = window.document.createElement("div");
|
||||
// chain.id = "chain";
|
||||
// explorerElement.append(chain);
|
||||
|
||||
// // vecsResources.getOrCreate(/** @satisfies {Height}*/ (5), "height");
|
||||
// //
|
||||
// const miners = [
|
||||
// { name: "Foundry USA", color: "orange" },
|
||||
// { name: "Via BTC", color: "teal" },
|
||||
// { name: "Ant Pool", color: "emerald" },
|
||||
// { name: "F2Pool", color: "indigo" },
|
||||
// { name: "Spider Pool", color: "yellow" },
|
||||
// { name: "Mara Pool", color: "amber" },
|
||||
// { name: "SEC Pool", color: "violet" },
|
||||
// { name: "Luxor", color: "orange" },
|
||||
// { name: "Brains Pool", color: "cyan" },
|
||||
// ];
|
||||
|
||||
// for (let i = 0; i <= 10; i++) {
|
||||
// const { name, color: _color } = randomFromArray(miners);
|
||||
// const { cubeElement, leftFaceElement, rightFaceElement, topFaceElement } =
|
||||
// createCube();
|
||||
|
||||
// // cubeElement.style.setProperty("--color", `var(--${color})`);
|
||||
|
||||
// const heightElement = window.document.createElement("p");
|
||||
// const height = (1_000_002 - i).toString();
|
||||
// const prefixLength = 7 - height.length;
|
||||
// const spanPrefix = window.document.createElement("span");
|
||||
// spanPrefix.style.opacity = "0.5";
|
||||
// spanPrefix.style.userSelect = "none";
|
||||
// heightElement.append(spanPrefix);
|
||||
// spanPrefix.innerHTML = "#" + "0".repeat(prefixLength);
|
||||
// const spanHeight = window.document.createElement("span");
|
||||
// heightElement.append(spanHeight);
|
||||
// spanHeight.innerHTML = height;
|
||||
// rightFaceElement.append(heightElement);
|
||||
|
||||
// const feesElement = window.document.createElement("div");
|
||||
// feesElement.classList.add("fees");
|
||||
// leftFaceElement.append(feesElement);
|
||||
// const averageFeeElement = window.document.createElement("p");
|
||||
// feesElement.append(averageFeeElement);
|
||||
// averageFeeElement.innerHTML = `~1.41`;
|
||||
// const feeRangeElement = window.document.createElement("p");
|
||||
// feesElement.append(feeRangeElement);
|
||||
// const minFeeElement = window.document.createElement("span");
|
||||
// minFeeElement.innerHTML = `0.11`;
|
||||
// feeRangeElement.append(minFeeElement);
|
||||
// const dashElement = window.document.createElement("span");
|
||||
// dashElement.style.opacity = "0.5";
|
||||
// dashElement.innerHTML = `-`;
|
||||
// feeRangeElement.append(dashElement);
|
||||
// const maxFeeElement = window.document.createElement("span");
|
||||
// maxFeeElement.innerHTML = `12.1`;
|
||||
// feeRangeElement.append(maxFeeElement);
|
||||
// const feeUnitElement = window.document.createElement("p");
|
||||
// feesElement.append(feeUnitElement);
|
||||
// feeUnitElement.style.opacity = "0.5";
|
||||
// feeUnitElement.innerHTML = `sat/vB`;
|
||||
|
||||
// const spanMiner = window.document.createElement("span");
|
||||
// spanMiner.innerHTML = name;
|
||||
// topFaceElement.append(spanMiner);
|
||||
|
||||
// chain.prepend(cubeElement);
|
||||
// }
|
||||
// }
|
||||
|
||||
// function createCube() {
|
||||
// const cubeElement = window.document.createElement("div");
|
||||
// cubeElement.classList.add("cube");
|
||||
|
||||
// const rightFaceElement = window.document.createElement("div");
|
||||
// rightFaceElement.classList.add("face");
|
||||
// rightFaceElement.classList.add("right");
|
||||
// cubeElement.append(rightFaceElement);
|
||||
|
||||
// const leftFaceElement = window.document.createElement("div");
|
||||
// leftFaceElement.classList.add("face");
|
||||
// leftFaceElement.classList.add("left");
|
||||
// cubeElement.append(leftFaceElement);
|
||||
|
||||
// const topFaceElement = window.document.createElement("div");
|
||||
// topFaceElement.classList.add("face");
|
||||
// topFaceElement.classList.add("top");
|
||||
// cubeElement.append(topFaceElement);
|
||||
|
||||
// return {
|
||||
// cubeElement,
|
||||
// leftFaceElement,
|
||||
// rightFaceElement,
|
||||
// topFaceElement,
|
||||
// };
|
||||
// }
|
||||
183
website/scripts/panes/explorer.js
Normal file
183
website/scripts/panes/explorer.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { explorerElement } from "../utils/elements.js";
|
||||
import { brk } from "../client.js";
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let chain;
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let sentinel;
|
||||
|
||||
let newestHeight = -1;
|
||||
let oldestHeight = Infinity;
|
||||
let loading = false;
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let pollInterval;
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
loadLatest();
|
||||
pollInterval = setInterval(loadLatest, 15_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval !== undefined) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function init() {
|
||||
chain = window.document.createElement("div");
|
||||
chain.id = "chain";
|
||||
explorerElement.append(chain);
|
||||
|
||||
sentinel = window.document.createElement("div");
|
||||
sentinel.classList.add("sentinel");
|
||||
chain.append(sentinel);
|
||||
|
||||
// Infinite scroll: load older blocks when sentinel becomes visible
|
||||
new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadOlder();
|
||||
}
|
||||
}).observe(sentinel);
|
||||
|
||||
// Self-contained lifecycle: poll when visible, stop when hidden
|
||||
new MutationObserver(() => {
|
||||
if (explorerElement.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
}).observe(explorerElement, { attributes: true, attributeFilter: ["hidden"] });
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !explorerElement.hidden) {
|
||||
loadLatest();
|
||||
}
|
||||
});
|
||||
|
||||
loadLatest();
|
||||
}
|
||||
|
||||
async function loadLatest() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const blocks = await brk.getBlocks();
|
||||
|
||||
// First load: insert all blocks before sentinel
|
||||
if (newestHeight === -1) {
|
||||
for (const block of blocks) {
|
||||
sentinel.before(createBlockCube(block));
|
||||
}
|
||||
newestHeight = blocks[0].height;
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
} else {
|
||||
// Subsequent polls: prepend only new blocks
|
||||
const newBlocks = blocks.filter((b) => b.height > newestHeight);
|
||||
if (newBlocks.length) {
|
||||
chain.prepend(...newBlocks.map((b) => createBlockCube(b)));
|
||||
newestHeight = newBlocks[0].height;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("explorer poll:", e);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loading || oldestHeight <= 0) return;
|
||||
loading = true;
|
||||
try {
|
||||
const blocks = await brk.getBlocksFromHeight(oldestHeight - 1);
|
||||
for (const block of blocks) {
|
||||
sentinel.before(createBlockCube(block));
|
||||
}
|
||||
if (blocks.length) {
|
||||
oldestHeight = blocks[blocks.length - 1].height;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("explorer loadOlder:", e);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
/** @param {BlockInfo} block */
|
||||
function createBlockCube(block) {
|
||||
const { cubeElement, leftFaceElement, rightFaceElement, topFaceElement } =
|
||||
createCube();
|
||||
|
||||
// cubeElement.style.setProperty("--face-color", `var(--${color})`);
|
||||
|
||||
const heightElement = window.document.createElement("p");
|
||||
const height = block.height.toString();
|
||||
const prefixLength = 7 - height.length;
|
||||
const spanPrefix = window.document.createElement("span");
|
||||
spanPrefix.style.opacity = "0.5";
|
||||
spanPrefix.style.userSelect = "none";
|
||||
heightElement.append(spanPrefix);
|
||||
spanPrefix.innerHTML = "#" + "0".repeat(prefixLength);
|
||||
const spanHeight = window.document.createElement("span");
|
||||
heightElement.append(spanHeight);
|
||||
spanHeight.innerHTML = height;
|
||||
rightFaceElement.append(heightElement);
|
||||
|
||||
const feesElement = window.document.createElement("div");
|
||||
feesElement.classList.add("fees");
|
||||
leftFaceElement.append(feesElement);
|
||||
const averageFeeElement = window.document.createElement("p");
|
||||
feesElement.append(averageFeeElement);
|
||||
averageFeeElement.innerHTML = `~1.41`;
|
||||
const feeRangeElement = window.document.createElement("p");
|
||||
feesElement.append(feeRangeElement);
|
||||
const minFeeElement = window.document.createElement("span");
|
||||
minFeeElement.innerHTML = `0.11`;
|
||||
feeRangeElement.append(minFeeElement);
|
||||
const dashElement = window.document.createElement("span");
|
||||
dashElement.style.opacity = "0.5";
|
||||
dashElement.innerHTML = `-`;
|
||||
feeRangeElement.append(dashElement);
|
||||
const maxFeeElement = window.document.createElement("span");
|
||||
maxFeeElement.innerHTML = `12.1`;
|
||||
feeRangeElement.append(maxFeeElement);
|
||||
const feeUnitElement = window.document.createElement("p");
|
||||
feesElement.append(feeUnitElement);
|
||||
feeUnitElement.style.opacity = "0.5";
|
||||
feeUnitElement.innerHTML = `sat/vB`;
|
||||
|
||||
const spanMiner = window.document.createElement("span");
|
||||
spanMiner.innerHTML = "TODO";
|
||||
topFaceElement.append(spanMiner);
|
||||
|
||||
return cubeElement;
|
||||
}
|
||||
|
||||
function createCube() {
|
||||
const cubeElement = window.document.createElement("div");
|
||||
cubeElement.classList.add("cube");
|
||||
|
||||
const rightFaceElement = window.document.createElement("div");
|
||||
rightFaceElement.classList.add("face");
|
||||
rightFaceElement.classList.add("right");
|
||||
cubeElement.append(rightFaceElement);
|
||||
|
||||
const leftFaceElement = window.document.createElement("div");
|
||||
leftFaceElement.classList.add("face");
|
||||
leftFaceElement.classList.add("left");
|
||||
cubeElement.append(leftFaceElement);
|
||||
|
||||
const topFaceElement = window.document.createElement("div");
|
||||
topFaceElement.classList.add("face");
|
||||
topFaceElement.classList.add("top");
|
||||
cubeElement.append(topFaceElement);
|
||||
|
||||
return {
|
||||
cubeElement,
|
||||
leftFaceElement,
|
||||
rightFaceElement,
|
||||
topFaceElement,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { QuickMatch } from "../modules/quickmatch-js/0.4.1/src/index.js";
|
||||
/**
|
||||
* @param {Options} options
|
||||
*/
|
||||
export function initSearch(options) {
|
||||
export function init(options) {
|
||||
console.log("search: init");
|
||||
|
||||
const haystack = options.list.map((option) => option.title.toLowerCase());
|
||||
@@ -65,9 +65,11 @@ export function initSearch(options) {
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const el = document.activeElement;
|
||||
|
||||
const isTextInput =
|
||||
el?.tagName === "INPUT" &&
|
||||
/** @type {HTMLInputElement} */ (el).type === "text";
|
||||
|
||||
if (e.key === "/" && !isTextInput) {
|
||||
e.preventDefault();
|
||||
searchLabelElement.click();
|
||||
|
||||
@@ -23,3 +23,12 @@ export const fromEntries = (pairs) => /** @type {Record<K, V>} */ (Object.fromEn
|
||||
* @returns {value is T}
|
||||
*/
|
||||
export const includes = (arr, value) => arr.includes(/** @type {T} */ (value));
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {readonly T[]} arr
|
||||
* @returns {T}
|
||||
*/
|
||||
export function randomFromArray(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const asideElement = getElementById("aside");
|
||||
export const searchElement = getElementById("search");
|
||||
export const navElement = getElementById("nav");
|
||||
export const chartElement = getElementById("chart");
|
||||
export const explorerElement = getElementById("explorer");
|
||||
|
||||
export const asideLabelElement = getElementById("aside-selector-label");
|
||||
export const navLabelElement = getElementById(`nav-selector-label`);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#chain {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--cube) * 0.66);
|
||||
padding: 2rem;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user