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; +}