mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-23 22:29:59 -07:00
website: fixes
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { createMapCache } from "../utils/cache.js";
|
||||
import { latestPrice } from "../utils/price.js";
|
||||
import { formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
import { createRow, formatBtc, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
|
||||
/** @type {HTMLDivElement} */ let el;
|
||||
/** @type {HTMLSpanElement[]} */ let valueEls;
|
||||
/** @type {HTMLElement[]} */ let valueEls;
|
||||
/** @type {HTMLDivElement} */ let txSection;
|
||||
/** @type {string} */ let currentAddr = "";
|
||||
|
||||
@@ -36,14 +36,7 @@ export function initAddrDetails(parent, linkHandler) {
|
||||
el.append(title);
|
||||
|
||||
valueEls = ROW_LABELS.map((label) => {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.classList.add("label");
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement("span");
|
||||
valueEl.classList.add("value");
|
||||
row.append(labelEl, valueEl);
|
||||
const { row, valueEl } = createRow(label);
|
||||
el.append(row);
|
||||
return valueEl;
|
||||
});
|
||||
@@ -70,9 +63,9 @@ export async function update(address, signal) {
|
||||
while (txSection.children.length > 1) txSection.lastChild?.remove();
|
||||
|
||||
try {
|
||||
const cached = statsCache.get(address);
|
||||
const stats = cached ?? (await brk.getAddress(address, { signal }));
|
||||
if (!cached) statsCache.set(address, stats);
|
||||
const stats = await statsCache.fetch(address, () =>
|
||||
brk.getAddress(address, { signal }),
|
||||
);
|
||||
if (signal.aborted || currentAddr !== address) return;
|
||||
|
||||
const chain = stats.chainStats;
|
||||
@@ -97,9 +90,7 @@ export async function update(address, signal) {
|
||||
pendingUtxos.toLocaleString(),
|
||||
`${formatBtc(chain.fundedTxoSum)} BTC`,
|
||||
chain.txCount.toLocaleString(),
|
||||
(/** @type {any} */ (stats).addrType ?? "unknown")
|
||||
.replace(/^v\d+_/, "")
|
||||
.toUpperCase(),
|
||||
stats.addrType.replace(/^v\d+_/, "").toUpperCase(),
|
||||
chain.realizedPrice
|
||||
? `$${Number(chain.realizedPrice).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
: "N/A",
|
||||
@@ -129,10 +120,9 @@ export async function update(address, signal) {
|
||||
loading = true;
|
||||
const key = `${address}:${pageIndex}`;
|
||||
try {
|
||||
const cached = txCache.get(key);
|
||||
const txs =
|
||||
cached ?? (await brk.getAddressTxs(address, afterTxid, { signal }));
|
||||
if (!cached) txCache.set(key, txs);
|
||||
const txs = await txCache.fetch(key, () =>
|
||||
brk.getAddressTxs(address, afterTxid, { signal }),
|
||||
);
|
||||
if (currentAddr !== address) return;
|
||||
for (const tx of txs) txSection.append(renderTx(tx));
|
||||
pageIndex++;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { brk } from "../utils/client.js";
|
||||
import { createMapCache } from "../utils/cache.js";
|
||||
import { createPersistedValue } from "../utils/persisted.js";
|
||||
import { formatFeeRate, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
import { createRow, formatFeeRate, formatHeightPrefix, renderTx, showPanel, hidePanel, TX_PAGE_SIZE } from "./render.js";
|
||||
|
||||
/** @typedef {[string, (b: BlockInfoV1) => string | null, ((b: BlockInfoV1) => string | null)?]} RowDef */
|
||||
|
||||
/** @param {(x: NonNullable<BlockInfoV1["extras"]>) => string | null} fn @returns {(b: BlockInfoV1) => string | null} */
|
||||
const ext = (fn) => (b) => (b.extras ? fn(b.extras) : null);
|
||||
/** @param {(x: BlockInfoV1["extras"]) => string | null} fn @returns {(b: BlockInfoV1) => string | null} */
|
||||
const ext = (fn) => (b) => fn(b.extras);
|
||||
|
||||
/** @type {RowDef[]} */
|
||||
const ROW_DEFS = [
|
||||
@@ -33,7 +33,7 @@ const ROW_DEFS = [
|
||||
["Avg Fee Rate", ext((x) => `${formatFeeRate(x.avgFeeRate)} sat/vB`)],
|
||||
["Avg Fee", ext((x) => `${x.avgFee.toLocaleString()} sat`)],
|
||||
["Median Fee", ext((x) => `${x.medianFeeAmt.toLocaleString()} sat`)],
|
||||
["Fee Range", ext((x) => x.feeRange.map((f) => formatFeeRate(f)).join(", ") + " sat/vB")],
|
||||
["Fee Range", ext((x) => x.feeRange.map(formatFeeRate).join(", ") + " sat/vB")],
|
||||
["Fee Percentiles", ext((x) => x.feePercentiles.map((f) => f.toLocaleString()).join(", ") + " sat")],
|
||||
["Avg Tx Size", ext((x) => `${x.avgTxSize.toLocaleString()} B`)],
|
||||
["Virtual Size", ext((x) => `${x.virtualSize.toLocaleString()} vB`)],
|
||||
@@ -59,11 +59,11 @@ const ROW_DEFS = [
|
||||
/** @type {HTMLDivElement} */ let el;
|
||||
/** @type {HTMLSpanElement} */ let heightPrefix;
|
||||
/** @type {HTMLSpanElement} */ let heightNum;
|
||||
/** @type {{ row: HTMLDivElement, valueEl: HTMLSpanElement }[]} */ let detailRows;
|
||||
/** @type {{ row: HTMLDivElement, valueEl: HTMLElement }[]} */ let detailRows;
|
||||
/** @type {HTMLDivElement} */ let txList;
|
||||
/** @type {HTMLDivElement} */ let txSection;
|
||||
/** @type {IntersectionObserver} */ let txObserver;
|
||||
/** @type {TxNav[]} */ let txNavs = [];
|
||||
/** @type {TxNav[]} */ const txNavs = [];
|
||||
/** @type {BlockInfoV1 | null} */ let txBlock = null;
|
||||
let txTotalPages = 0;
|
||||
let txLoading = false;
|
||||
@@ -99,14 +99,7 @@ export function initBlockDetails(parent, linkHandler) {
|
||||
el.addEventListener("click", linkHandler);
|
||||
|
||||
detailRows = ROW_DEFS.map(([label, , linkFn]) => {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.classList.add("label");
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement(linkFn ? "a" : "span");
|
||||
valueEl.classList.add("value");
|
||||
row.append(labelEl, valueEl);
|
||||
const { row, valueEl } = createRow(label, Boolean(linkFn));
|
||||
el.append(row);
|
||||
return { row, valueEl };
|
||||
});
|
||||
@@ -170,9 +163,8 @@ function updateTxNavs(page) {
|
||||
|
||||
/** @param {BlockInfoV1} block */
|
||||
export function update(block) {
|
||||
const str = block.height.toString();
|
||||
heightPrefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||
heightNum.textContent = str;
|
||||
heightPrefix.textContent = formatHeightPrefix(block.height);
|
||||
heightNum.textContent = block.height.toString();
|
||||
|
||||
ROW_DEFS.forEach(([, getter, linkFn], i) => {
|
||||
const value = getter(block);
|
||||
@@ -202,18 +194,19 @@ export function hide() { hidePanel(el); }
|
||||
|
||||
/** @param {number} page @param {boolean} [pushUrl] */
|
||||
async function loadTxPage(page, pushUrl = true) {
|
||||
if (txLoading || !txBlock || page < 0 || page >= txTotalPages) return;
|
||||
const block = txBlock;
|
||||
if (txLoading || !block || page < 0 || page >= txTotalPages) return;
|
||||
txLoading = true;
|
||||
txLoaded = true;
|
||||
if (pushUrl) txPageParam.setImmediate(page);
|
||||
updateTxNavs(page);
|
||||
const key = `${txBlock.id}:${page}`;
|
||||
const key = `${block.id}:${page}`;
|
||||
try {
|
||||
const cached = txPageCache.get(key);
|
||||
const txs = cached ?? await brk.getBlockTxsFromIndex(txBlock.id, page * TX_PAGE_SIZE);
|
||||
if (!cached) txPageCache.set(key, txs);
|
||||
const txs = await txPageCache.fetch(key, () =>
|
||||
brk.getBlockTxsFromIndex(block.id, page * TX_PAGE_SIZE),
|
||||
);
|
||||
txList.innerHTML = "";
|
||||
const ascii = txBlock.extras?.coinbaseSignatureAscii;
|
||||
const ascii = block.extras.coinbaseSignatureAscii;
|
||||
for (const tx of txs) txList.append(renderTx(tx, ascii));
|
||||
} catch (e) {
|
||||
console.error("explorer txs:", e);
|
||||
|
||||
@@ -38,7 +38,6 @@ export function initChain(parent, callbacks) {
|
||||
|
||||
olderObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
return; // edge fetching disabled for layout debugging
|
||||
if (entries[0].isIntersecting) loadOlder();
|
||||
},
|
||||
{ root: chainEl },
|
||||
@@ -47,13 +46,8 @@ export function initChain(parent, callbacks) {
|
||||
chainEl.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
return; // edge fetching disabled for layout debugging
|
||||
const nearStart =
|
||||
(chainEl.scrollHeight > chainEl.clientHeight &&
|
||||
chainEl.scrollTop <= 50) ||
|
||||
(chainEl.scrollWidth > chainEl.clientWidth &&
|
||||
chainEl.scrollLeft <= 50);
|
||||
if (nearStart && !reachedTip && !loadingNewer) loadNewer();
|
||||
if (reachedTip || loadingNewer) return;
|
||||
if (chainEl.scrollTop <= 50 && chainEl.scrollLeft <= 50) loadNewer();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -85,7 +79,9 @@ export function selectCube(cube, { scroll, silent } = {}) {
|
||||
selectedCube = cube;
|
||||
cube.classList.add("selected");
|
||||
}
|
||||
if (scroll) cube.scrollIntoView({ behavior: scroll });
|
||||
if (scroll) {
|
||||
cube.scrollIntoView({ behavior: scroll, block: "center", inline: "center" });
|
||||
}
|
||||
if (!silent) {
|
||||
const hash = cube.dataset.hash;
|
||||
if (hash) {
|
||||
@@ -218,8 +214,11 @@ async function loadNewer() {
|
||||
if (loadingNewer || newestHeight === -1 || reachedTip) return;
|
||||
loadingNewer = true;
|
||||
try {
|
||||
const prevNewest = newestHeight;
|
||||
const blocks = await brk.getBlocksV1FromHeight(newestHeight + LOOKAHEAD);
|
||||
if (!appendNewerBlocks(blocks)) reachedTip = true;
|
||||
if (!appendNewerBlocks(blocks) || newestHeight === prevNewest) {
|
||||
reachedTip = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("explorer loadNewer:", e);
|
||||
}
|
||||
@@ -260,8 +259,8 @@ function createBlockCube(block) {
|
||||
cubeElement.dataset.height = String(block.height);
|
||||
cubeElement.dataset.timestamp = String(block.timestamp);
|
||||
|
||||
const vsize = block.extras?.virtualSize ?? block.weight / 4;
|
||||
const fill = Math.min(1, vsize / 1_000_000);
|
||||
const { pool, medianFee, feeRange, virtualSize } = block.extras;
|
||||
const fill = Math.min(1, virtualSize / 1_000_000);
|
||||
const { topFace, rightFace, leftFace } = createCube(cubeElement, fill);
|
||||
blocksByHash.set(block.id, block);
|
||||
// Intercept plain left-clicks for SPA nav; let modified clicks
|
||||
@@ -273,10 +272,7 @@ function createBlockCube(block) {
|
||||
onCubeClick(cubeElement);
|
||||
});
|
||||
|
||||
const extras = block.extras;
|
||||
const minerName = extras ? extras.pool.name : "Unknown";
|
||||
const medianFee = extras ? extras.medianFee : 0;
|
||||
const feeRange = extras ? extras.feeRange : [0, 0, 0, 0, 0, 0, 0];
|
||||
const minerName = pool.name;
|
||||
|
||||
// Top: short date / HH:MM (colon dimmed).
|
||||
const dateP = document.createElement("p");
|
||||
@@ -308,7 +304,7 @@ function createBlockCube(block) {
|
||||
const feesEl = document.createElement("div");
|
||||
feesEl.classList.add("fees");
|
||||
const avg = document.createElement("p");
|
||||
avg.textContent = `~${formatFeeRate(medianFee)}`;
|
||||
avg.append(span("~", "dim"), formatFeeRate(medianFee));
|
||||
const range = document.createElement("p");
|
||||
range.append(
|
||||
formatFeeRate(feeRange[0]),
|
||||
|
||||
@@ -28,11 +28,6 @@ import {
|
||||
hide as hideAddr,
|
||||
} from "./address.js";
|
||||
|
||||
/** @returns {string[]} */
|
||||
function pathSegments() {
|
||||
return window.location.pathname.split("/").filter((v) => v);
|
||||
}
|
||||
|
||||
/** @type {number | undefined} */ let pollInterval;
|
||||
let navController = new AbortController();
|
||||
const txCache = createMapCache(50);
|
||||
@@ -111,7 +106,7 @@ export function init(selected) {
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
poll();
|
||||
pollInterval = setInterval(poll, 15_000);
|
||||
pollInterval = setInterval(poll, 5_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
@@ -124,14 +119,13 @@ function stopPolling() {
|
||||
async function load() {
|
||||
const signal = navigate();
|
||||
try {
|
||||
const [kind, value] = pathSegments();
|
||||
const [kind, value] = window.location.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (kind === "tx" && value) {
|
||||
const txid = await resolveTxid(value, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
|
||||
const tx = await txCache.fetch(txid, () => brk.getTx(txid, { signal }));
|
||||
if (signal.aborted) return;
|
||||
txCache.set(txid, tx);
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
showPanel("tx");
|
||||
@@ -155,9 +149,8 @@ async function load() {
|
||||
|
||||
/** @param {string} hashOrHeight */
|
||||
async function navigateToBlock(hashOrHeight) {
|
||||
const signal = navigate();
|
||||
navigate();
|
||||
await goToCube(hashOrHeight);
|
||||
if (!signal.aborted) showPanel("block");
|
||||
}
|
||||
|
||||
/** @param {Txid | TxIndex} value @param {{ signal?: AbortSignal }} [options] */
|
||||
@@ -175,9 +168,8 @@ async function navigateToTx(txidOrIndex) {
|
||||
try {
|
||||
const txid = await resolveTxid(txidOrIndex, { signal });
|
||||
if (signal.aborted) return;
|
||||
const tx = txCache.get(txid) ?? (await brk.getTx(txid, { signal }));
|
||||
const tx = await txCache.fetch(txid, () => brk.getTx(txid, { signal }));
|
||||
if (signal.aborted) return;
|
||||
txCache.set(txid, tx);
|
||||
await goToCube(tx.status?.blockHash ?? tx.status?.blockHeight ?? null, { silent: true });
|
||||
updateTx(tx);
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,6 +18,9 @@ export function formatBtc(sats) {
|
||||
|
||||
/** @param {number} rate */
|
||||
export function formatFeeRate(rate) {
|
||||
if (rate >= 1_000_000) return `${(rate / 1_000_000).toFixed(1)}M`;
|
||||
if (rate >= 100_000) return `${Math.round(rate / 1_000)}k`;
|
||||
if (rate >= 1_000) return `${(rate / 1_000).toFixed(1)}k`;
|
||||
if (rate >= 100) return Math.round(rate).toLocaleString();
|
||||
if (rate >= 10) return rate.toFixed(1);
|
||||
return rate.toFixed(2);
|
||||
@@ -39,6 +42,11 @@ export function setAddrContent(text, el) {
|
||||
el.append(head, tail);
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
export function formatHeightPrefix(height) {
|
||||
return "#" + "0".repeat(Math.max(0, 7 - String(height).length));
|
||||
}
|
||||
|
||||
/** @param {number} height */
|
||||
export function createHeightElement(height) {
|
||||
const container = document.createElement("span");
|
||||
@@ -46,29 +54,39 @@ export function createHeightElement(height) {
|
||||
const prefix = document.createElement("span");
|
||||
prefix.classList.add("dim");
|
||||
prefix.style.userSelect = "none";
|
||||
prefix.textContent = "#" + "0".repeat(7 - str.length);
|
||||
prefix.textContent = formatHeightPrefix(height);
|
||||
const num = document.createElement("span");
|
||||
num.textContent = str;
|
||||
container.append(prefix, num);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {boolean} [isLink]
|
||||
* @returns {{ row: HTMLDivElement, valueEl: HTMLElement }}
|
||||
*/
|
||||
export function createRow(label, isLink = false) {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.classList.add("label");
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement(isLink ? "a" : "span");
|
||||
valueEl.classList.add("value");
|
||||
row.append(labelEl, valueEl);
|
||||
return { row, valueEl };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {[string, string, (string | null)?][]} rows
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
export function renderRows(rows, parent) {
|
||||
for (const [label, value, href] of rows) {
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("row");
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.classList.add("label");
|
||||
labelEl.textContent = label;
|
||||
const valueEl = document.createElement(href ? "a" : "span");
|
||||
valueEl.classList.add("value");
|
||||
const { row, valueEl } = createRow(label, Boolean(href));
|
||||
valueEl.textContent = value;
|
||||
if (href) /** @type {HTMLAnchorElement} */ (valueEl).href = href;
|
||||
row.append(labelEl, valueEl);
|
||||
parent.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,33 @@ export function createMapCache(maxSize = 100) {
|
||||
/** @type {Map<string, V>} */
|
||||
const map = new Map();
|
||||
|
||||
/** @param {string} key @param {V} value */
|
||||
const set = (key, value) => {
|
||||
if (map.size >= maxSize && !map.has(key)) {
|
||||
const first = map.keys().next().value;
|
||||
if (first !== undefined) map.delete(first);
|
||||
}
|
||||
map.set(key, value);
|
||||
};
|
||||
|
||||
return {
|
||||
/** @param {string} key @returns {V | undefined} */
|
||||
get(key) {
|
||||
return map.get(key);
|
||||
},
|
||||
get: (key) => map.get(key),
|
||||
/** @param {string} key @returns {boolean} */
|
||||
has(key) {
|
||||
return map.has(key);
|
||||
},
|
||||
/** @param {string} key @param {V} value */
|
||||
set(key, value) {
|
||||
if (map.size >= maxSize && !map.has(key)) {
|
||||
const first = map.keys().next().value;
|
||||
if (first !== undefined) map.delete(first);
|
||||
}
|
||||
map.set(key, value);
|
||||
has: (key) => map.has(key),
|
||||
set,
|
||||
/** @param {string} key @param {() => Promise<V>} fetcher @returns {Promise<V>} */
|
||||
async fetch(key, fetcher) {
|
||||
const hit = map.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const value = await fetcher();
|
||||
set(key, value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template V
|
||||
* @typedef {{ get: (key: string) => V | undefined, has: (key: string) => boolean, set: (key: string, value: V) => void }} MapCache
|
||||
* @typedef {ReturnType<typeof createMapCache<V>>} MapCache
|
||||
*/
|
||||
|
||||
@@ -74,6 +74,8 @@ const lineWidth = /** @type {1} */ (/** @type {unknown} */ (1.5));
|
||||
|
||||
const MAX_SIZE = 10_000;
|
||||
|
||||
let hintShown = false;
|
||||
|
||||
/** @typedef {{ label: string, index: IndexLabel, from: number }} RangePreset */
|
||||
|
||||
/** @returns {RangePreset[]} */
|
||||
@@ -1677,6 +1679,20 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
});
|
||||
chartEl.append(captureButton);
|
||||
|
||||
if (!hintShown) {
|
||||
hintShown = true;
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "chart-hint";
|
||||
hint.textContent = matchMedia("(pointer: coarse)").matches
|
||||
? "pinch to zoom · swipe to pan"
|
||||
: "scroll to zoom · drag to pan";
|
||||
root.append(hint);
|
||||
|
||||
const dismiss = () => hint.classList.add("done");
|
||||
chartEl.addEventListener("wheel", dismiss, { once: true, passive: true });
|
||||
chartEl.addEventListener("pointerdown", dismiss, { once: true });
|
||||
}
|
||||
|
||||
return chart;
|
||||
}
|
||||
|
||||
|
||||
@@ -279,4 +279,36 @@
|
||||
color: var(--off-color);
|
||||
}
|
||||
}
|
||||
|
||||
> div.chart-hint {
|
||||
position: absolute;
|
||||
bottom: 4rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: auto;
|
||||
margin: 0;
|
||||
z-index: 40;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
color: var(--off-color);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
animation: chart-hint 4s 0.2s ease-in-out both;
|
||||
|
||||
&.done {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chart-hint {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
15%, 85% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
@container aside (max-width: 767px) {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
@container aside (min-width: 768px) {
|
||||
|
||||
Reference in New Issue
Block a user