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 {