mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-30 22:09:00 -07:00
website: redesign part 1
This commit is contained in:
@@ -1,167 +0,0 @@
|
||||
import { createHeader } from "../utils/dom.js";
|
||||
import { chartElement } from "../utils/elements.js";
|
||||
import { INDEX_FROM_LABEL } from "../utils/serde.js";
|
||||
import { Unit } from "../utils/units.js";
|
||||
import { createChart } from "../utils/chart/index.js";
|
||||
import { colors } from "../utils/colors.js";
|
||||
import { latestPrice, onPrice } from "../utils/price.js";
|
||||
import { brk } from "../utils/client.js";
|
||||
|
||||
const ONE_BTC_IN_SATS = 100_000_000;
|
||||
|
||||
/** @type {((opt: ChartOption) => void) | null} */
|
||||
let _setOption = null;
|
||||
|
||||
/**
|
||||
* @param {ChartOption} opt
|
||||
*/
|
||||
export function setOption(opt) {
|
||||
if (!_setOption) throw new Error("Chart not initialized");
|
||||
_setOption(opt);
|
||||
}
|
||||
|
||||
export function init() {
|
||||
const { headerElement, headingElement } = createHeader();
|
||||
chartElement.append(headerElement);
|
||||
|
||||
const chart = createChart({
|
||||
parent: chartElement,
|
||||
brk,
|
||||
});
|
||||
|
||||
const setChoices = chart.setIndexChoices;
|
||||
|
||||
/**
|
||||
* Build top blueprints with price series prepended for each unit
|
||||
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} optionTop
|
||||
* @returns {Map<Unit, AnyFetchedSeriesBlueprint[]>}
|
||||
*/
|
||||
function buildTopBlueprints(optionTop) {
|
||||
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
|
||||
const result = new Map();
|
||||
|
||||
const { ohlc, spot } = brk.series.prices;
|
||||
|
||||
result.set(Unit.usd, [
|
||||
/** @type {AnyFetchedSeriesBlueprint} */ ({
|
||||
type: "Price",
|
||||
title: "Price",
|
||||
series: spot.usd,
|
||||
ohlcSeries: ohlc.usd,
|
||||
}),
|
||||
...(optionTop.get(Unit.usd) ?? []),
|
||||
]);
|
||||
|
||||
result.set(Unit.sats, [
|
||||
/** @type {AnyFetchedSeriesBlueprint} */ ({
|
||||
type: "Price",
|
||||
title: "Price",
|
||||
series: spot.sats,
|
||||
ohlcSeries: ohlc.sats,
|
||||
colors: /** @type {const} */ ([colors.default, colors.background]),
|
||||
}),
|
||||
...(optionTop.get(Unit.sats) ?? []),
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function updatePriceWithLatest() {
|
||||
const latest = latestPrice();
|
||||
if (latest === null) return;
|
||||
|
||||
const priceSeries = chart.panes[0].series[0];
|
||||
const unit = chart.panes[0].unit;
|
||||
if (!priceSeries?.hasData() || !unit) return;
|
||||
|
||||
const last = priceSeries.getData().at(-1);
|
||||
if (!last) return;
|
||||
|
||||
// Convert to sats if needed
|
||||
const close =
|
||||
unit === Unit.sats ? Math.floor(ONE_BTC_IN_SATS / latest) : latest;
|
||||
|
||||
if ("close" in last) {
|
||||
// Candlestick data
|
||||
priceSeries.update({ ...last, close });
|
||||
} else {
|
||||
// Line data
|
||||
priceSeries.update({ ...last, value: close });
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the setOption function
|
||||
_setOption = (opt) => {
|
||||
headingElement.innerHTML = opt.title;
|
||||
|
||||
// Set blueprints first so storageId is correct before any index change
|
||||
chart.setBlueprints({
|
||||
name: opt.title,
|
||||
top: buildTopBlueprints(opt.top()),
|
||||
bottom: opt.bottom(),
|
||||
onDataLoaded: updatePriceWithLatest,
|
||||
});
|
||||
|
||||
// Update index choices (may trigger rebuild if index changes)
|
||||
setChoices(computeChoices(opt));
|
||||
};
|
||||
|
||||
// Live price update listener
|
||||
onPrice(updatePriceWithLatest);
|
||||
}
|
||||
|
||||
/** @type {{ label: string, items: IndexLabel[] }[]} */
|
||||
const ALL_GROUPS = [
|
||||
{
|
||||
label: "Time",
|
||||
items: [
|
||||
"10mn",
|
||||
"30mn",
|
||||
"1h",
|
||||
"4h",
|
||||
"12h",
|
||||
"1d",
|
||||
"3d",
|
||||
"1w",
|
||||
"1m",
|
||||
"3m",
|
||||
"6m",
|
||||
"1y",
|
||||
"10y",
|
||||
],
|
||||
},
|
||||
{ label: "Block", items: ["blk", "epch", "halv"] },
|
||||
];
|
||||
|
||||
const ALL_CHOICES = /** @satisfies {IndexLabel[]} */ (
|
||||
ALL_GROUPS.flatMap((g) => g.items)
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {ChartOption} opt
|
||||
* @returns {{ choices: IndexLabel[], groups: { label: string, items: IndexLabel[] }[] }}
|
||||
*/
|
||||
function computeChoices(opt) {
|
||||
if (!opt.top().size && !opt.bottom().size) {
|
||||
return { choices: [...ALL_CHOICES], groups: ALL_GROUPS };
|
||||
}
|
||||
const rawIndexes = new Set(
|
||||
[Array.from(opt.top().values()), Array.from(opt.bottom().values())]
|
||||
.flat(2)
|
||||
.filter((blueprint) => {
|
||||
const path = Object.values(blueprint.series.by)[0]?.path ?? "";
|
||||
return !path.includes("constant_");
|
||||
})
|
||||
.flatMap((blueprint) => blueprint.series.indexes()),
|
||||
);
|
||||
|
||||
const groups = ALL_GROUPS.map(({ label, items }) => ({
|
||||
label,
|
||||
items: items.filter((choice) => rawIndexes.has(INDEX_FROM_LABEL[choice])),
|
||||
})).filter(({ items }) => items.length > 0);
|
||||
|
||||
return {
|
||||
choices: groups.flatMap((g) => g.items),
|
||||
groups,
|
||||
};
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import {
|
||||
searchInput,
|
||||
searchLabelElement,
|
||||
searchResultsElement,
|
||||
} from "../utils/elements.js";
|
||||
import { QuickMatch } from "../modules/quickmatch-js/0.5.0/src/index.js";
|
||||
import { brk } from "../utils/client.js";
|
||||
|
||||
/**
|
||||
* @param {Options} options
|
||||
*/
|
||||
export function init(options) {
|
||||
console.log("search: init");
|
||||
|
||||
const haystack = options.list.map((option) => option.title.toLowerCase());
|
||||
const titleToOption = new Map(
|
||||
options.list.map((option) => [option.title.toLowerCase(), option]),
|
||||
);
|
||||
|
||||
const matcher = new QuickMatch(haystack);
|
||||
|
||||
/** @type {HTMLLIElement | undefined} */
|
||||
let highlighted;
|
||||
|
||||
/** @param {HTMLLIElement} [li] */
|
||||
function setHighlight(li) {
|
||||
if (highlighted) delete highlighted.dataset.highlight;
|
||||
highlighted = li;
|
||||
if (li) li.dataset.highlight = "";
|
||||
}
|
||||
|
||||
const HEX64_RE = /^[0-9a-f]{64}$/i;
|
||||
const ADDR_RE = /^([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-z0-9]{8,87})$/;
|
||||
|
||||
/** @param {string} label @param {string} href @param {Element | null} [before] */
|
||||
function createResultLink(label, href, before) {
|
||||
const li = window.document.createElement("li");
|
||||
const a = window.document.createElement("a");
|
||||
a.href = href;
|
||||
a.textContent = label;
|
||||
a.title = label;
|
||||
if (href === window.location.pathname) setHighlight(li);
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
setHighlight(li);
|
||||
history.pushState(null, "", href);
|
||||
options.resolveUrl();
|
||||
});
|
||||
li.append(a);
|
||||
searchResultsElement.insertBefore(li, before ?? null);
|
||||
}
|
||||
|
||||
/** @type {AbortController | undefined} */
|
||||
let lookupController;
|
||||
|
||||
/** @param {string} needle @param {AbortSignal} signal */
|
||||
async function lookup(needle, signal) {
|
||||
/** @type {Array<[string, string]>} */
|
||||
const results = [];
|
||||
|
||||
if (HEX64_RE.test(needle)) {
|
||||
const [blockRes, txRes] = await Promise.allSettled([
|
||||
brk.getBlock(needle, { signal }),
|
||||
brk.getTx(needle, { signal }),
|
||||
]);
|
||||
if (signal.aborted) return;
|
||||
if (blockRes.status === "fulfilled")
|
||||
results.push(["Block", `/block/${needle}`]);
|
||||
if (txRes.status === "fulfilled")
|
||||
results.push(["Transaction", `/tx/${needle}`]);
|
||||
} else if (ADDR_RE.test(needle)) {
|
||||
try {
|
||||
const { isvalid } = await brk.validateAddress(needle, { signal });
|
||||
if (signal.aborted || !isvalid) return;
|
||||
results.push(["Address", `/address/${needle}`]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = searchResultsElement.firstElementChild;
|
||||
for (const [label, href] of results) {
|
||||
createResultLink(`${label} ${needle}`, href, before);
|
||||
}
|
||||
// Remove "No results" placeholder if present
|
||||
const last = searchResultsElement.lastElementChild;
|
||||
if (last && !last.querySelector("a")) last.remove();
|
||||
}
|
||||
|
||||
function inputEvent() {
|
||||
const needle = /** @type {string} */ (searchInput.value).trim();
|
||||
|
||||
if (lookupController) lookupController.abort();
|
||||
|
||||
searchResultsElement.scrollTo({ top: 0 });
|
||||
searchResultsElement.innerHTML = "";
|
||||
setHighlight();
|
||||
|
||||
if (!needle.length) return;
|
||||
|
||||
const matches = matcher.matches(needle);
|
||||
|
||||
const indexMatch = needle.match(
|
||||
/^(?:(block|b)|(transaction|tx))?\s*#?\s*(\d+)$/i,
|
||||
);
|
||||
|
||||
if (indexMatch) {
|
||||
const num = indexMatch[3];
|
||||
const entries = indexMatch[1]
|
||||
? [["Block", `/block/${num}`]]
|
||||
: indexMatch[2]
|
||||
? [["Transaction", `/tx/${num}`]]
|
||||
: [
|
||||
["Block", `/block/${num}`],
|
||||
["Transaction", `/tx/${num}`],
|
||||
];
|
||||
for (const [label, href] of entries) {
|
||||
createResultLink(`${label} #${num}`, href);
|
||||
}
|
||||
}
|
||||
|
||||
lookupController = new AbortController();
|
||||
lookup(needle, lookupController.signal);
|
||||
|
||||
if (matches.length) {
|
||||
matches.forEach((title) => {
|
||||
const option = titleToOption.get(title);
|
||||
if (!option) return;
|
||||
|
||||
const li = window.document.createElement("li");
|
||||
searchResultsElement.appendChild(li);
|
||||
|
||||
if (option === options.selected.value) setHighlight(li);
|
||||
|
||||
const element = options.createOptionElement({
|
||||
option,
|
||||
name: option.title,
|
||||
});
|
||||
|
||||
if (element) li.append(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (!searchResultsElement.children.length) {
|
||||
const li = window.document.createElement("li");
|
||||
li.textContent = "No results";
|
||||
li.style.color = "var(--off-color)";
|
||||
searchResultsElement.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
options.selected.onChange(() => {
|
||||
const selected = options.selected.value;
|
||||
const href =
|
||||
selected?.kind === "explorer"
|
||||
? window.location.pathname
|
||||
: selected?.path.length
|
||||
? `/${selected.path.join("/")}`
|
||||
: null;
|
||||
if (!href) return setHighlight();
|
||||
for (const li of searchResultsElement.children) {
|
||||
const a = li.querySelector("a");
|
||||
if (a && a.getAttribute("href") === href) {
|
||||
return setHighlight(/** @type {HTMLLIElement} */ (li));
|
||||
}
|
||||
}
|
||||
setHighlight();
|
||||
});
|
||||
|
||||
inputEvent();
|
||||
|
||||
searchInput.addEventListener("input", inputEvent);
|
||||
const len = searchInput.value.length;
|
||||
searchInput.setSelectionRange(len, len);
|
||||
}
|
||||
|
||||
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();
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { getElementById } from "../utils/dom.js";
|
||||
import * as leanQr from "../modules/lean-qr/2.7.1/index.mjs";
|
||||
|
||||
const shareDiv = getElementById("share-div");
|
||||
const shareContentDiv = getElementById("share-content-div");
|
||||
const shareButton = getElementById("share-button");
|
||||
const imgQrcode = /** @type {HTMLImageElement} */ (getElementById("share-img"));
|
||||
const anchor = /** @type {HTMLAnchorElement} */ (
|
||||
getElementById("share-anchor")
|
||||
);
|
||||
|
||||
/** @param {string | null} url */
|
||||
export function setQr(url) {
|
||||
if (!url) {
|
||||
shareDiv.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.href = url;
|
||||
anchor.innerText =
|
||||
(url.startsWith("http") ? url.split("//").at(-1) : url.split(":").at(-1)) ||
|
||||
"";
|
||||
|
||||
imgQrcode.src =
|
||||
// @ts-ignore — lean-qr types don't resolve for file path import
|
||||
leanQr.generate(url)?.toDataURL({ padX: 0, padY: 0 }) || "";
|
||||
|
||||
shareDiv.hidden = false;
|
||||
}
|
||||
|
||||
shareButton.addEventListener("click", () => {
|
||||
setQr(window.location.href);
|
||||
});
|
||||
|
||||
shareDiv.addEventListener("click", () => {
|
||||
setQr(null);
|
||||
});
|
||||
|
||||
shareContentDiv.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
});
|
||||
Reference in New Issue
Block a user