diff --git a/public/locales/en.json b/public/locales/en.json index 87713f7..157736c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -486,8 +486,7 @@ "columnHeight": "Height", "columnAddress": "Miner", - "columnShortHash": "Short Hash", - "columnHash": "Full Hash", + "columnHash": "Block Hash", "columnValue": "Value", "columnDifficulty": "Difficulty", "columnTime": "Time", @@ -497,5 +496,33 @@ "resultUnknownTitle": "Unknown error", "resultUnknown": "See console for details." + }, + + "block": { + "title": "Block", + "siteTitle": "Block", + "siteTitleBlock": "Block #{{id, number}}", + "subTitleBlock": "#{{id, number}}", + + "height": "Height", + "miner": "Miner", + "value": "Value", + "time": "Time", + "hash": "Hash", + "difficulty": "Difficulty", + + "previous": "Prev", + "previousTooltip": "Previous block (#{{id, number}})", + "previousTooltipNone": "Previous block", + "next": "Next", + "nextTooltip": "Next block (#{{id, number}})", + "nextTooltipNone": "Next block", + + "resultInvalidTitle": "Invalid block height", + "resultInvalid": "That does not look like a valid block height.", + "resultNotFoundTitle": "Block not found", + "resultNotFound": "That block does not exist.", + "resultUnknownTitle": "Unknown error", + "resultUnknown": "See console for details." } } diff --git a/src/components/ws/SyncMOTD.tsx b/src/components/ws/SyncMOTD.tsx index 5fe6b80..e442dcc 100644 --- a/src/components/ws/SyncMOTD.tsx +++ b/src/components/ws/SyncMOTD.tsx @@ -25,6 +25,11 @@ store.dispatch(nodeActions.setCurrency(data.currency)); store.dispatch(nodeActions.setConstants(data.constants)); + if (data.last_block) { + debug("motd last block id: %d", data.last_block.height); + store.dispatch(nodeActions.setLastBlockID(data.last_block.height)); + } + const motdBase: KristMOTDBase = { motd: data.motd, motdSet: new Date(data.motd_set), diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 9271fbe..54d3e4e 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -9,9 +9,10 @@ import { AddressPage } from "../pages/addresses/AddressPage"; import { BlocksPage } from "../pages/blocks/BlocksPage"; +import { BlockPage } from "../pages/blocks/BlockPage"; import { TransactionsPage, ListingType as TXListing } from "../pages/transactions/TransactionsPage"; -import { NamePage } from "../pages/names/NamePage"; import { NamesPage, ListingType as NamesListing } from "../pages/names/NamesPage"; +import { NamePage } from "../pages/names/NamePage"; import { SettingsPage } from "../pages/settings/SettingsPage"; import { SettingsTranslations } from "../pages/settings/SettingsTranslations"; @@ -43,6 +44,7 @@ { path: "/network/addresses/:address/names", name: "addressNames", component: }, { path: "/network/blocks", name: "blocks", component: }, + { path: "/network/blocks/:id", name: "blocks", component: }, { path: "/network/transactions", name: "transactions", component: }, { path: "/network/names", name: "networkNames", diff --git a/src/krist/api/types.ts b/src/krist/api/types.ts index f67eeff..736004c 100644 --- a/src/krist/api/types.ts +++ b/src/krist/api/types.ts @@ -111,6 +111,8 @@ mining_enabled: boolean; debug_mode: boolean; + last_block?: KristBlock; + constants: KristConstants; currency: KristCurrency; } diff --git a/src/pages/blocks/BlockHash.less b/src/pages/blocks/BlockHash.less index 17e3728..e9630fb 100644 --- a/src/pages/blocks/BlockHash.less +++ b/src/pages/blocks/BlockHash.less @@ -8,4 +8,8 @@ font-family: monospace; font-size: 90%; + + &-short-part { + color: @kw-text; + } } diff --git a/src/pages/blocks/BlockHash.tsx b/src/pages/blocks/BlockHash.tsx index 9ca460a..0003cc5 100644 --- a/src/pages/blocks/BlockHash.tsx +++ b/src/pages/blocks/BlockHash.tsx @@ -11,21 +11,33 @@ const { Text } = Typography; +const SHORT_HASH_LENGTH = 12; + interface Props { hash: string; + alwaysCopyable?: boolean; neverCopyable?: boolean; className?: string; } -export function BlockHash({ hash, neverCopyable, className }: Props): JSX.Element { +export function BlockHash({ hash, alwaysCopyable, neverCopyable, className }: Props): JSX.Element { const blockHashCopyButtons = useBooleanSetting("blockHashCopyButtons"); - const copyable = !neverCopyable && blockHashCopyButtons - ? true : undefined; + // If the hash is longer than 12 characters (i.e. it's not just a short hash + // on its own), then split it into two parts, so the short hash can be + // highlighted. Otherwise, just put the whole hash in restHash. + const shortHash = hash.length > SHORT_HASH_LENGTH + ? hash.substr(0, SHORT_HASH_LENGTH) : ""; + const restHash = hash.length > SHORT_HASH_LENGTH + ? hash.substring(SHORT_HASH_LENGTH, hash.length) : hash; + + const copyable = alwaysCopyable || (!neverCopyable && blockHashCopyButtons) + ? { text: hash } : undefined; const classes = classNames("block-hash", className); return - {hash} + {shortHash && {shortHash}} + {restHash} ; } diff --git a/src/pages/blocks/BlockPage.less b/src/pages/blocks/BlockPage.less new file mode 100644 index 0000000..eb34f57 --- /dev/null +++ b/src/pages/blocks/BlockPage.less @@ -0,0 +1,37 @@ +// 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-page { + .block-nav-buttons { + .block-prev.ant-btn, .block-prev .ant-btn { + margin-right: @margin-sm; + padding-left: @padding-sm; + } + + .block-next.ant-btn, .block-next .ant-btn { + padding-right: @padding-sm; + } + } + + .block-info-row { + max-width: 768px; + margin-top: -@margin-lg; + margin-bottom: @margin-lg; + + .kw-statistic { + margin-top: @margin-lg; + margin-right: @margin-lg; + + .date-time { + font-size: @font-size-base * 1.5; + } + + &.statistic-block-hash .kw-statistic-value, .block-hash { + font-size: @font-size-base; + line-height: 1; + } + } + } +} diff --git a/src/pages/blocks/BlockPage.tsx b/src/pages/blocks/BlockPage.tsx new file mode 100644 index 0000000..916bf99 --- /dev/null +++ b/src/pages/blocks/BlockPage.tsx @@ -0,0 +1,188 @@ +// 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 } from "react"; +import { Row, Col, Skeleton, Button, Tooltip } from "antd"; +import { LeftOutlined, RightOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; +import { useParams, Link } from "react-router-dom"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; + +import { PageLayout } from "../../layout/PageLayout"; +import { BlockResult } from "./BlockResult"; + +import { Statistic } from "../../components/Statistic"; +import { ContextualAddress } from "../../components/ContextualAddress"; +import { BlockHash } from "./BlockHash"; +import { KristValue } from "../../components/KristValue"; +import { DateTime } from "../../components/DateTime"; + +import * as api from "../../krist/api"; +import { KristBlock } from "../../krist/api/types"; + +import "./BlockPage.less"; + +interface ParamTypes { + id: string; +} + +function PageContents({ block }: { block: KristBlock }): JSX.Element { + return <> + + {/* Height */} + + + + + {/* Miner */} + + } + /> + + + {/* Value */} + + 1} + />} + /> + + + {/* Time */} + + } + /> + + + {/* Difficulty */} + + + + + {/* Hash */} + + } + className="statistic-block-hash" + /> + + + ; +} + +function NavButtons({ block }: { block?: KristBlock }): JSX.Element { + const { t } = useTranslation(); + const lastBlockID = useSelector((s: RootState) => s.node.lastBlockID); + + // TODO: The Krist network's genesis block actually starts at ID 7 due to + // a migration issue, so this hasPrevious check is never going to be + // used. + const hasPrevious = block && block.height > 1; + const previousID = hasPrevious ? block!.height - 1 : 0; + const previousBtn = ( + + ); + + const hasNext = block && block.height < lastBlockID; + const nextID = hasNext ? block!.height + 1 : 0; + const nextBtn = ( + + ); + + return
+ {/* Previous block button */} + + {/* Wrap in a link if the button is enabled */} + {hasPrevious + ? ( + + {previousBtn} + + ) + : previousBtn} + + + {/* Next block button */} + + {/* Wrap in a link if the button is enabled */} + {hasNext + ? ( + + {nextBtn} + + ) + : nextBtn} + +
; +} + +export function BlockPage(): JSX.Element { + // Used to refresh the block data on syncNode change + const syncNode = api.useSyncNode(); + const { t } = useTranslation(); + + const { id } = useParams(); + const [kristBlock, setKristBlock] = useState(); + const [error, setError] = useState(); + + // Load the block on page load + useEffect(() => { + api.get<{ block: KristBlock }>("blocks/" + encodeURIComponent(id)) + .then(res => setKristBlock(res.block)) + .catch(err => { console.error(err); setError(err); }); + }, [syncNode, id]); + + // Change the page title depending on whether or not the block has loaded + const titleData = kristBlock + ? { + siteTitle: t("block.siteTitleBlock", { id: kristBlock.height }), + subTitle: t("block.subTitleBlock", { id: kristBlock.height }) + } + : { siteTitleKey: "block.siteTitle" }; + + return } + > + {error + ? + : (kristBlock + ? + : )} + ; +} diff --git a/src/pages/blocks/BlockResult.tsx b/src/pages/blocks/BlockResult.tsx new file mode 100644 index 0000000..ae2974e --- /dev/null +++ b/src/pages/blocks/BlockResult.tsx @@ -0,0 +1,52 @@ +// 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 { FrownOutlined, ExclamationCircleOutlined, QuestionCircleOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { SmallResult } from "../../components/SmallResult"; +import { APIError } from "../../krist/api"; + +interface Props { + error: Error; +} + +export function BlockResult({ error }: Props): JSX.Element { + const { t } = useTranslation(); + + // Handle the most commonly expected errors from the API + if (error instanceof APIError) { + // Invalid block height + if (error.message === "invalid_parameter") { + return } + title={t("block.resultInvalidTitle")} + subTitle={t("block.resultInvalid")} + fullPage + />; + } + + // Block not found + if (error.message === "block_not_found") { + return } + title={t("block.resultNotFoundTitle")} + subTitle={t("block.resultNotFound")} + fullPage + />; + } + } + + // Unknown error + return } + title={t("block.resultUnknownTitle")} + subTitle={t("block.resultUnknown")} + fullPage + />; +} diff --git a/src/pages/blocks/BlocksPage.tsx b/src/pages/blocks/BlocksPage.tsx index 554f2ab..801a1cf 100644 --- a/src/pages/blocks/BlocksPage.tsx +++ b/src/pages/blocks/BlocksPage.tsx @@ -34,7 +34,7 @@ negativeMargin titleKey={"blocks.title"} - siteTitle={"blocks.siteTitle"} + siteTitleKey={"blocks.siteTitle"} > {error ? diff --git a/src/pages/blocks/BlocksTable.tsx b/src/pages/blocks/BlocksTable.tsx index 0a1a6a2..5df954b 100644 --- a/src/pages/blocks/BlocksTable.tsx +++ b/src/pages/blocks/BlocksTable.tsx @@ -5,6 +5,7 @@ import { Table } from "antd"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; import { KristBlock } from "../../krist/api/types"; import { lookupBlocks, LookupBlocksOptions, LookupBlocksResponse } from "../../krist/api/lookup"; @@ -12,6 +13,7 @@ import { ContextualAddress } from "../../components/ContextualAddress"; import { BlockHash } from "./BlockHash"; +import { KristValue } from "../../components/KristValue"; import { DateTime } from "../../components/DateTime"; import Debug from "debug"; @@ -66,7 +68,11 @@ title: t("blocks.columnHeight"), dataIndex: "height", key: "height", - render: height => height.toLocaleString(), + render: height => ( + + {height.toLocaleString()} + + ), width: 100 }, @@ -86,15 +92,7 @@ sorter: true }, - // Short hash - { - title: t("blocks.columnShortHash"), - dataIndex: "short_hash", key: "short_hash", - - render: hash => - }, - - // Full hash + // Hash { title: t("blocks.columnHash"), dataIndex: "hash", key: "hash", @@ -104,6 +102,17 @@ sorter: true }, + // Value + { + title: t("blocks.columnValue"), + dataIndex: "value", key: "value", + + render: value => , + width: 100, + + sorter: true + }, + // Difficulty { title: t("blocks.columnDifficulty"), diff --git a/src/style/components.less b/src/style/components.less index f52904f..ec2a363 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -42,11 +42,21 @@ &:focus { background: lighten(@kw-lighter, 10%); } + &[disabled] { + background: @kw-light; + border-color: transparent; + } + &.ant-btn-primary { background: @primary-color; &:hover, &:focus { background: lighten(@primary-color, 5%); } &:focus { background: lighten(@primary-color, 10%); } + + &[disabled] { + background: fade(@primary-color, 75%); + border-color: transparent; + } } &.ant-btn-dangerous {