diff --git a/public/locales/en.json b/public/locales/en.json
index ac5aa97..87713f7 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -289,6 +289,7 @@
"copyNameSuffixes": "Include suffix when copying names",
"addressCopyButtons": "Show copy buttons next to all addresses",
"nameCopyButtons": "Show copy buttons next to all names",
+ "blockHashCopyButtons": "Show copy buttons next to all block hashes",
"subMenuDebug": "Debug settings",
"advancedWalletFormats": "Advanced wallet formats",
@@ -477,5 +478,24 @@
"resultNotFound": "That name does not exist.",
"resultUnknownTitle": "Unknown error",
"resultUnknown": "See console for details."
+ },
+
+ "blocks": {
+ "title": "Network Blocks",
+ "siteTitle": "Network Blocks",
+
+ "columnHeight": "Height",
+ "columnAddress": "Miner",
+ "columnShortHash": "Short Hash",
+ "columnHash": "Full Hash",
+ "columnValue": "Value",
+ "columnDifficulty": "Difficulty",
+ "columnTime": "Time",
+
+ "tableTotal": "{{count, number}} block",
+ "tableTotal_plural": "{{count, number}} blocks",
+
+ "resultUnknownTitle": "Unknown error",
+ "resultUnknown": "See console for details."
}
}
diff --git a/src/App.tsx b/src/App.tsx
index e2a1fe4..110811e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -29,7 +29,7 @@
function App(): JSX.Element {
debug("whole app is being rendered!");
- return }> {/* TODO */}
+ return }>
diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx
index 1b232c5..c97cb55 100644
--- a/src/global/AppRouter.tsx
+++ b/src/global/AppRouter.tsx
@@ -8,6 +8,7 @@
import { WalletsPage } from "../pages/wallets/WalletsPage";
import { AddressPage } from "../pages/addresses/AddressPage";
+import { BlocksPage } from "../pages/blocks/BlocksPage";
import { TransactionsPage, ListingType as TXListing } from "../pages/transactions/TransactionsPage";
import { NamePage } from "../pages/names/NamePage";
import { NamesPage, ListingType as NamesListing } from "../pages/names/NamesPage";
@@ -41,6 +42,7 @@
component: },
{ path: "/network/addresses/:address/names", name: "addressNames",
component: },
+ { path: "/network/blocks", name: "blocks", component: },
{ path: "/network/transactions", name: "transactions",
component: },
{ path: "/network/names", name: "networkNames",
diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts
index a085daf..a1d24db 100644
--- a/src/krist/api/lookup.ts
+++ b/src/krist/api/lookup.ts
@@ -1,9 +1,11 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { KristAddress, KristTransaction, KristName } from "./types";
+import { KristAddress, KristTransaction, KristName, KristBlock } from "./types";
import * as api from ".";
+import { LookupFilterOptionsBase, LookupResultsBase, getFilterOptionsQuery } from "../../utils/table";
+
// =============================================================================
// Addresses
// =============================================================================
@@ -50,6 +52,22 @@
}
// =============================================================================
+// Blocks
+// =============================================================================
+export type SortableBlockFields = "height" | "address" | "hash" | "value" |
+ "time" | "difficulty";
+export type LookupBlocksOptions = LookupFilterOptionsBase;
+
+export interface LookupBlocksResponse extends LookupResultsBase {
+ blocks: KristBlock[];
+}
+
+export async function lookupBlocks(opts: LookupBlocksOptions): Promise {
+ const qs = getFilterOptionsQuery(opts);
+ return await api.get("lookup/blocks?" + qs);
+}
+
+// =============================================================================
// Transactions
// =============================================================================
export type SortableTransactionFields = "id" | "from" | "to" | "value" | "time"
@@ -61,28 +79,18 @@
NAME_TRANSACTIONS
}
-export interface LookupTransactionsOptions {
+export interface LookupTransactionsOptions extends LookupFilterOptionsBase {
includeMined?: boolean;
- limit?: number;
- offset?: number;
- orderBy?: SortableTransactionFields;
- order?: "ASC" | "DESC";
type?: LookupTransactionType;
}
-export interface LookupTransactionsResponse {
- count: number;
- total: number;
+export interface LookupTransactionsResponse extends LookupResultsBase {
transactions: KristTransaction[];
}
export async function lookupTransactions(addresses: string[] | undefined, opts: LookupTransactionsOptions): Promise {
- const qs = new URLSearchParams();
+ const qs = getFilterOptionsQuery(opts);
if (opts.includeMined) qs.append("includeMined", "");
- if (opts.limit) qs.append("limit", opts.limit.toString());
- if (opts.offset) qs.append("offset", opts.offset.toString());
- if (opts.orderBy) qs.append("orderBy", opts.orderBy);
- if (opts.order) qs.append("order", opts.order);
// Map the lookup type to the appropriate route
// TODO: this is kinda wack
@@ -110,26 +118,14 @@
// =============================================================================
export type SortableNameFields = "name" | "owner" | "original_owner"
| "registered" | "updated" | "a" | "unpaid";
-export interface LookupNamesOptions {
- limit?: number;
- offset?: number;
- orderBy?: SortableNameFields;
- order?: "ASC" | "DESC";
-}
+export type LookupNamesOptions = LookupFilterOptionsBase;
-export interface LookupNamesResponse {
- count: number;
- total: number;
+export interface LookupNamesResponse extends LookupResultsBase {
names: KristName[];
}
export async function lookupNames(addresses: string[] | undefined, opts: LookupNamesOptions): Promise {
- const qs = new URLSearchParams();
- if (opts.limit) qs.append("limit", opts.limit.toString());
- if (opts.offset) qs.append("offset", opts.offset.toString());
- if (opts.orderBy) qs.append("orderBy", opts.orderBy);
- if (opts.order) qs.append("order", opts.order);
-
+ const qs = getFilterOptionsQuery(opts);
return await api.get(
"lookup/names/"
+ (addresses && addresses.length > 0
@@ -138,12 +134,3 @@
+ "?" + qs
);
}
-
-export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined {
- switch (order) {
- case "ascend":
- return "ASC";
- case "descend":
- return "DESC";
- }
-}
diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less
index 164dc90..11f111d 100644
--- a/src/layout/PageLayout.less
+++ b/src/layout/PageLayout.less
@@ -34,6 +34,12 @@
.page-layout-header.ant-page-header {
// Ensure it's still clickable
z-index: 10;
+
+ // And to ensure the extra content is still clickable, make the header
+ // smaller.
+ // TODO: This is a pretty unreliable hack. Is there a way to just get the
+ // pagination in the header?
+ display: inline-block;
}
.page-layout-contents {
diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx
index d25d8fc..559e462 100644
--- a/src/layout/sidebar/Sidebar.tsx
+++ b/src/layout/sidebar/Sidebar.tsx
@@ -31,7 +31,7 @@
{ icon: , name: "names", to: "/me/names" },
{ icon: , name: "mining", to: "/mining", nyi: true },
- { group: "network", icon: , name: "blocks", to: "/network/blocks", nyi: true },
+ { group: "network", icon: , name: "blocks", to: "/network/blocks" },
{ group: "network", icon: , name: "transactions", to: "/network/transactions" },
{ group: "network", icon: , name: "names", to: "/network/names" },
{ group: "network", icon: , name: "statistics", to: "/network/statistics", nyi: true },
diff --git a/src/pages/blocks/BlockHash.less b/src/pages/blocks/BlockHash.less
new file mode 100644
index 0000000..17e3728
--- /dev/null
+++ b/src/pages/blocks/BlockHash.less
@@ -0,0 +1,11 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.block-hash {
+ color: @kw-text-tertiary;
+
+ font-family: monospace;
+ font-size: 90%;
+}
diff --git a/src/pages/blocks/BlockHash.tsx b/src/pages/blocks/BlockHash.tsx
new file mode 100644
index 0000000..9ca460a
--- /dev/null
+++ b/src/pages/blocks/BlockHash.tsx
@@ -0,0 +1,31 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React from "react";
+import classNames from "classnames";
+import { Typography } from "antd";
+
+import { useBooleanSetting } from "../../utils/settings";
+
+import "./BlockHash.less";
+
+const { Text } = Typography;
+
+interface Props {
+ hash: string;
+ neverCopyable?: boolean;
+ className?: string;
+}
+
+export function BlockHash({ hash, neverCopyable, className }: Props): JSX.Element {
+ const blockHashCopyButtons = useBooleanSetting("blockHashCopyButtons");
+
+ const copyable = !neverCopyable && blockHashCopyButtons
+ ? true : undefined;
+
+ const classes = classNames("block-hash", className);
+
+ return
+ {hash}
+ ;
+}
diff --git a/src/pages/blocks/BlocksPage.tsx b/src/pages/blocks/BlocksPage.tsx
new file mode 100644
index 0000000..554f2ab
--- /dev/null
+++ b/src/pages/blocks/BlocksPage.tsx
@@ -0,0 +1,43 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React, { useState, useMemo } from "react";
+
+import { useSelector } from "react-redux";
+import { RootState } from "../../store";
+
+import { PageLayout } from "../../layout/PageLayout";
+import { BlocksResult } from "./BlocksResult";
+import { BlocksTable } from "./BlocksTable";
+
+import { useBooleanSetting } from "../../utils/settings";
+
+export function BlocksPage(): JSX.Element {
+ const [error, setError] = useState();
+
+ // Used to handle memoisation and auto-refreshing
+ const lastBlockID = useSelector((s: RootState) => s.node.lastBlockID);
+ const shouldAutoRefresh = useBooleanSetting("autoRefreshTables");
+
+ // If auto-refresh is disabled, use a static refresh ID
+ const usedRefreshID = shouldAutoRefresh ? lastBlockID : 0;
+
+ // Memoise the table so that it only updates the props (thus triggering a
+ // re-fetch of the blocks) when something relevant changes
+ const memoTable = useMemo(() => (
+
+ ), [usedRefreshID, setError]);
+
+ return
+ {error
+ ?
+ : memoTable}
+ ;
+}
diff --git a/src/pages/blocks/BlocksResult.tsx b/src/pages/blocks/BlocksResult.tsx
new file mode 100644
index 0000000..b6b92bf
--- /dev/null
+++ b/src/pages/blocks/BlocksResult.tsx
@@ -0,0 +1,21 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React from "react";
+import { QuestionCircleOutlined } from "@ant-design/icons";
+
+import { useTranslation } from "react-i18next";
+
+import { SmallResult } from "../../components/SmallResult";
+
+export function BlocksResult(): JSX.Element {
+ const { t } = useTranslation();
+
+ return }
+ title={t("blocks.resultUnknownTitle")}
+ subTitle={t("blocks.resultUnknown")}
+ fullPage
+ />;
+}
diff --git a/src/pages/blocks/BlocksTable.tsx b/src/pages/blocks/BlocksTable.tsx
new file mode 100644
index 0000000..0a1a6a2
--- /dev/null
+++ b/src/pages/blocks/BlocksTable.tsx
@@ -0,0 +1,129 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React, { useState, useEffect, Dispatch, SetStateAction } from "react";
+import { Table } from "antd";
+
+import { useTranslation } from "react-i18next";
+
+import { KristBlock } from "../../krist/api/types";
+import { lookupBlocks, LookupBlocksOptions, LookupBlocksResponse } from "../../krist/api/lookup";
+import { getTablePaginationSettings, handleLookupTableChange } from "../../utils/table";
+
+import { ContextualAddress } from "../../components/ContextualAddress";
+import { BlockHash } from "./BlockHash";
+import { DateTime } from "../../components/DateTime";
+
+import Debug from "debug";
+const debug = Debug("kristweb:blocks-table");
+
+interface Props {
+ // Number used to trigger a refresh of the blocks listing
+ refreshingID?: number;
+ setError?: Dispatch>;
+}
+
+export function BlocksTable({ refreshingID, setError }: Props): JSX.Element {
+ const { t } = useTranslation();
+
+ const [loading, setLoading] = useState(true);
+ const [res, setRes] = useState();
+ const [options, setOptions] = useState({
+ limit: 20,
+ offset: 0,
+ orderBy: "height",
+ order: "DESC"
+ });
+
+ // Fetch the blocks from the API, mapping the table options
+ useEffect(() => {
+ debug("looking up blocks");
+ setLoading(true);
+
+ lookupBlocks(options)
+ .then(setRes)
+ .catch(setError)
+ .finally(() => setLoading(false));
+ }, [refreshingID, setError, options]);
+
+ debug("results? %b res.blocks.length: %d res.count: %d res.total: %d", !!res, res?.blocks?.length, res?.count, res?.total);
+
+ return
+ className="blocks-table"
+ size="small"
+
+ loading={loading}
+ dataSource={res?.blocks || []}
+ rowKey="height"
+
+ // Triggered whenever the filter, sorting, or pagination changes
+ onChange={handleLookupTableChange(setOptions)}
+ pagination={getTablePaginationSettings(t, res, "blocks.tableTotal")}
+
+ columns={[
+ // Height
+ {
+ title: t("blocks.columnHeight"),
+ dataIndex: "height", key: "height",
+
+ render: height => height.toLocaleString(),
+ width: 100
+ },
+
+ // Miner address
+ {
+ title: t("blocks.columnAddress"),
+ dataIndex: "address", key: "address",
+
+ render: address => address && (
+
+ ),
+
+ sorter: true
+ },
+
+ // Short hash
+ {
+ title: t("blocks.columnShortHash"),
+ dataIndex: "short_hash", key: "short_hash",
+
+ render: hash =>
+ },
+
+ // Full hash
+ {
+ title: t("blocks.columnHash"),
+ dataIndex: "hash", key: "hash",
+
+ render: hash => ,
+
+ sorter: true
+ },
+
+ // Difficulty
+ {
+ title: t("blocks.columnDifficulty"),
+ dataIndex: "difficulty", key: "difficulty",
+
+ render: difficulty => difficulty.toLocaleString(),
+
+ sorter: true
+ },
+
+ // Time
+ {
+ title: t("blocks.columnTime"),
+ dataIndex: "time", key: "time",
+ render: time => ,
+ width: 200,
+
+ sorter: true,
+ defaultSortOrder: "descend"
+ }
+ ]}
+ />;
+}
diff --git a/src/pages/names/NamesPage.tsx b/src/pages/names/NamesPage.tsx
index e2592ce..dbeb9d0 100644
--- a/src/pages/names/NamesPage.tsx
+++ b/src/pages/names/NamesPage.tsx
@@ -79,7 +79,7 @@
: 0;
// Memoise the table so that it only updates the props (thus triggering a
- // re-fetch of the transactions) when something relevant changes
+ // re-fetch of the names) when something relevant changes
const memoTable = useMemo(() => (
{
- const pageSize = (pagination?.pageSize) || 20;
-
- // This will trigger a data re-fetch
- setOptions({
- ...options,
-
- limit: pageSize,
- offset: pageSize * ((pagination?.current || 1) - 1),
-
- orderBy: sorter instanceof Array ? undefined : sorter.field as SortableNameFields,
- order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order),
- });
- }}
-
- pagination={{
- size: "default",
- position: ["topRight", "bottomRight"],
-
- showSizeChanger: true,
- defaultPageSize: 20,
-
- total: res?.total || 0,
- showTotal: total => t("names.tableTotal", { count: total || 0 })
- }}
+ onChange={handleLookupTableChange(setOptions)}
+ pagination={getTablePaginationSettings(t, res, "names.tableTotal")}
columns={[
// Name
diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx
index 7a39195..1e68cc1 100644
--- a/src/pages/settings/SettingsPage.tsx
+++ b/src/pages/settings/SettingsPage.tsx
@@ -72,6 +72,11 @@
+
+ {/* Block hash copy buttons */}
+
+
+
{/* Debug settings */}
diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx
index c4ad580..30d7e22 100644
--- a/src/pages/transactions/TransactionsTable.tsx
+++ b/src/pages/transactions/TransactionsTable.tsx
@@ -7,7 +7,8 @@
import { useTranslation } from "react-i18next";
import { KristTransaction } from "../../krist/api/types";
-import { convertSorterOrder, lookupTransactions, LookupTransactionsOptions, LookupTransactionsResponse, LookupTransactionType, SortableTransactionFields } from "../../krist/api/lookup";
+import { lookupTransactions, LookupTransactionsOptions, LookupTransactionsResponse, LookupTransactionType } from "../../krist/api/lookup";
+import { getTablePaginationSettings, handleLookupTableChange } from "../../utils/table";
import { ListingType } from "./TransactionsPage";
@@ -82,35 +83,8 @@
rowKey="id"
// Triggered whenever the filter, sorting, or pagination changes
- onChange={(pagination, _, sorter) => {
- // While the pagination should never be undefined, it's important to
- // ensure that the default pageSize here is equal to the pagination's
- // default pageSize, otherwise ant-design will print a warning when the
- // data is first populated.
- const pageSize = (pagination?.pageSize) || 20;
-
- // This will trigger a data re-fetch
- setOptions({
- ...options,
-
- limit: pageSize,
- offset: pageSize * ((pagination?.current || 1) - 1),
-
- orderBy: sorter instanceof Array ? undefined : sorter.field as SortableTransactionFields,
- order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order),
- });
- }}
-
- pagination={{
- size: "default",
- position: ["topRight", "bottomRight"],
-
- showSizeChanger: true,
- defaultPageSize: 20,
-
- total: res?.total || 0,
- showTotal: total => t("transactions.tableTotal", { count: total || 0 })
- }}
+ onChange={handleLookupTableChange(setOptions)}
+ pagination={getTablePaginationSettings(t, res, "transactions.tableTotal")}
columns={[
// ID
diff --git a/src/utils/settings.ts b/src/utils/settings.ts
index 0a47a53..961aad2 100644
--- a/src/utils/settings.ts
+++ b/src/utils/settings.ts
@@ -25,6 +25,8 @@
readonly addressCopyButtons: boolean;
/** Show copy buttons next to all names. */
readonly nameCopyButtons: boolean;
+ /** Show copy buttons next to all block hashes. */
+ readonly blockHashCopyButtons: boolean;
/** Whether or not advanced wallet formats are enabled. */
readonly walletFormats: boolean;
@@ -37,6 +39,7 @@
copyNameSuffixes: true,
addressCopyButtons: false,
nameCopyButtons: false,
+ blockHashCopyButtons: false,
walletFormats: false
};
diff --git a/src/utils/table.ts b/src/utils/table.ts
new file mode 100644
index 0000000..15e5329
--- /dev/null
+++ b/src/utils/table.ts
@@ -0,0 +1,63 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { Dispatch, SetStateAction } from "react";
+import { TablePaginationConfig } from "antd";
+import { SorterResult } from "antd/lib/table/interface";
+
+import { TFunction } from "react-i18next";
+
+export interface LookupFilterOptionsBase {
+ limit?: number;
+ offset?: number;
+ orderBy?: FieldsT;
+ order?: "ASC" | "DESC";
+}
+
+export interface LookupResultsBase {
+ count: number;
+ total: number;
+}
+
+export const handleLookupTableChange = (setOptions: Dispatch>>) =>
+ (pagination: TablePaginationConfig, _: unknown, sorter: SorterResult | SorterResult[]): void => {
+ const pageSize = (pagination?.pageSize) || 20;
+
+ // This will trigger a data re-fetch
+ setOptions({
+ limit: pageSize,
+ offset: pageSize * ((pagination?.current || 1) - 1),
+
+ orderBy: sorter instanceof Array ? undefined : sorter.field as FieldsT,
+ order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order),
+ });
+ };
+
+export const getTablePaginationSettings = (t: TFunction, res: ResultT | undefined, totalKey: string): TablePaginationConfig => ({
+ size: "default",
+ position: ["topRight", "bottomRight"],
+
+ showSizeChanger: true,
+ defaultPageSize: 20,
+
+ total: res?.total || 0,
+ showTotal: total => t(totalKey, { count: total || 0 })
+});
+
+export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined {
+ switch (order) {
+ case "ascend":
+ return "ASC";
+ case "descend":
+ return "DESC";
+ }
+}
+
+export function getFilterOptionsQuery(opts: LookupFilterOptionsBase): URLSearchParams {
+ const qs = new URLSearchParams();
+ if (opts.limit) qs.append("limit", opts.limit.toString());
+ if (opts.offset) qs.append("offset", opts.offset.toString());
+ if (opts.orderBy) qs.append("orderBy", opts.orderBy);
+ if (opts.order) qs.append("order", opts.order);
+ return qs;
+}