diff --git a/public/locales/en.json b/public/locales/en.json index 01a7ac7..3d4d2d5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -409,5 +409,31 @@ "name_purchased": "Purchased name", "unknown": "Unknown" } + }, + + "names": { + "titleWallets": "My Names", + "titleNetworkAll": "Network Names", + "titleNetworkAddress": "Network Names", + + "siteTitleWallets": "My Names", + "siteTitleNetworkAll": "Network Names", + "siteTitleNetworkAddress": "{{address}}'s Names", + + "columnName": "Name", + "columnOwner": "Owner", + "columnOriginalOwner": "Original Owner", + "columnRegistered": "Registered", + "columnUpdated": "Updated", + "columnARecord": "A Record", + "columnUnpaid": "Unpaid Blocks", + + "tableTotal": "{{count, number}} name", + "tableTotal_plural": "{{count, number}} names", + + "resultInvalidTitle": "Invalid address", + "resultInvalid": "That does not look like a valid Krist address.", + "resultUnknownTitle": "Unknown error", + "resultUnknown": "See console for details." } } diff --git a/src/components/transactions/TransactionConciseMetadata.tsx b/src/components/transactions/TransactionConciseMetadata.tsx index 9c315ff..734b4a0 100644 --- a/src/components/transactions/TransactionConciseMetadata.tsx +++ b/src/components/transactions/TransactionConciseMetadata.tsx @@ -13,7 +13,8 @@ import "./TransactionConciseMetadata.less"; interface Props { - transaction: KristTransaction; + transaction?: KristTransaction; + metadata?: string; limit?: number; className?: string; } @@ -22,17 +23,18 @@ * Trims the name and metaname from the start of metadata, and truncates it * to a specified amount of characters. */ -export function TransactionConciseMetadata({ transaction, limit = 30, className }: Props): JSX.Element | null { +export function TransactionConciseMetadata({ transaction, metadata, limit = 30, className }: Props): JSX.Element | null { const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); // Don't render anything if there's no metadata (after the hooks) - if (!transaction || !transaction.metadata) return null; + const meta = metadata || transaction?.metadata; + if (!meta) return null; // Strip the name from the start of the transaction metadata, if it is present - const hasName = transaction.sent_name || transaction.sent_metaname; + const hasName = transaction && (transaction.sent_name || transaction.sent_metaname); const withoutName = hasName - ? stripNameFromMetadata(nameSuffix, transaction.metadata) - : transaction.metadata; + ? stripNameFromMetadata(nameSuffix, meta) + : meta; // Trim it down to the limit if necessary const wasTruncated = withoutName.length > limit; diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 4d20979..f5c2de3 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -8,7 +8,8 @@ import { WalletsPage } from "../pages/wallets/WalletsPage"; import { AddressPage } from "../pages/addresses/AddressPage"; -import { TransactionsPage, ListingType } from "../pages/transactions/TransactionsPage"; +import { TransactionsPage, ListingType as TXListing } from "../pages/transactions/TransactionsPage"; +import { NamesPage, ListingType as NamesListing } from "../pages/names/NamesPage"; import { SettingsPage } from "../pages/settings/SettingsPage"; import { SettingsTranslations } from "../pages/settings/SettingsTranslations"; @@ -24,41 +25,36 @@ } export const APP_ROUTES: AppRoute[] = [ - { path: "/", name: "dashboard", component: }, - { path: "/wallets", name: "wallets", component: }, - { - path: "/me/transactions", - name: "myTransactions", - component: - }, + { path: "/", name: "dashboard", component: }, - { path: "/network/addresses/:address", name: "address", component: }, - { - path: "/network/addresses/:address/transactions", - name: "addressTransactions", - component: - }, - { - path: "/network/transactions", - name: "transactions", - component: - }, - { - path: "/network/names/:name/history", - name: "nameHistory", - component: - }, - { - path: "/network/names/:name/transactions", - name: "nameTransactions", - component: - }, + // My wallets, etc + { path: "/wallets", name: "wallets", component: }, + { path: "/me/transactions", name: "myTransactions", + component: }, + { path: "/me/names", name: "myNames", + component: }, - { path: "/settings", name: "settings", component: }, - { path: "/settings/debug", name: "settingsDebug" }, - { path: "/settings/debug/translations", name: "settings", component: }, + // Network explorer + { path: "/network/addresses/:address", name: "address", component: }, + { path: "/network/addresses/:address/transactions", name: "addressTransactions", + component: }, + { path: "/network/addresses/:address/names", name: "addressNames", + component: }, + { path: "/network/transactions", name: "transactions", + component: }, + { path: "/network/names", name: "networkNames", + component: }, + { path: "/network/names/:name/history", name: "nameHistory", + component: }, + { path: "/network/names/:name/transactions", name: "nameTransactions", + component: }, - { path: "/credits", name: "credits", component: }, + // Settings + { path: "/settings", name: "settings", component: }, + { path: "/settings/debug", name: "settingsDebug" }, + { path: "/settings/debug/translations", name: "settings", component: }, + + { path: "/credits", name: "credits", component: }, ]; export function AppRouter(): JSX.Element { diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts index 8536e42..978fd5a 100644 --- a/src/krist/api/lookup.ts +++ b/src/krist/api/lookup.ts @@ -123,9 +123,7 @@ names: KristName[]; } -export async function lookupNames(addresses: string[], opts: LookupNamesOptions): Promise { - if (!addresses || addresses.length === 0) return { count: 0, total: 0, names: [] }; - +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()); @@ -134,7 +132,9 @@ return await api.get( "lookup/names/" - + encodeURIComponent(addresses.join(",")) + + (addresses && addresses.length > 0 + ? encodeURIComponent(addresses.join(",")) + : "") + "?" + qs ); } diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less index efddcd7..dcfeab6 100644 --- a/src/layout/PageLayout.less +++ b/src/layout/PageLayout.less @@ -23,4 +23,11 @@ padding-top: 0; } } + + &.page-layout-negative-margin { + .page-layout-contents { + // Used to pull table pagination up to the 'extra' area of the header + margin-top: -@kw-page-header-height + 4; + } + } } diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx index 3d26a31..0606d37 100644 --- a/src/layout/PageLayout.tsx +++ b/src/layout/PageLayout.tsx @@ -23,6 +23,7 @@ className?: string; withoutTopPadding?: boolean; + negativeMargin?: boolean; } export const PageLayout: FC = ({ @@ -34,6 +35,7 @@ className, withoutTopPadding, + negativeMargin, children, ...rest }) => { @@ -46,7 +48,8 @@ }, [t, siteTitle, siteTitleKey]); const classes = classNames("page-layout", className, { - "page-layout-no-top-padding": withoutTopPadding + "page-layout-no-top-padding": withoutTopPadding, + "page-layout-negative-margin": negativeMargin }); return
diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx index c0e12da..d25d8fc 100644 --- a/src/layout/sidebar/Sidebar.tsx +++ b/src/layout/sidebar/Sidebar.tsx @@ -28,12 +28,12 @@ { icon: , name: "myWallets", to: "/wallets" }, { icon: , name: "addressBook", to: "/friends", nyi: true }, { icon: , name: "transactions", to: "/me/transactions" }, - { icon: , name: "names", to: "/me/names", nyi: true }, + { 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: "transactions", to: "/network/transactions" }, - { group: "network", icon: , name: "names", to: "/network/names", nyi: true }, + { group: "network", icon: , name: "names", to: "/network/names" }, { group: "network", icon: , name: "statistics", to: "/network/statistics", nyi: true }, ]; diff --git a/src/pages/names/NamesPage.tsx b/src/pages/names/NamesPage.tsx new file mode 100644 index 0000000..bf3e28c --- /dev/null +++ b/src/pages/names/NamesPage.tsx @@ -0,0 +1,105 @@ +// 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, Dispatch, SetStateAction } from "react"; + +import { useTranslation, TFunction } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { PageLayout } from "../../layout/PageLayout"; +import { NamesResult } from "./NamesResult"; +import { NamesTable } from "./NamesTable"; + +import { useWallets } from "../../krist/wallets/Wallet"; + +/** The type of name listing to search by. */ +export enum ListingType { + /** Names owned by the user's wallets */ + WALLETS, + + /** Names across the whole network */ + NETWORK_ALL, + /** Network names filtered to a particular owner */ + NETWORK_ADDRESS +} + +const LISTING_TYPE_TITLES: Record = { + [ListingType.WALLETS]: "names.titleWallets", + [ListingType.NETWORK_ALL]: "names.titleNetworkAll", + [ListingType.NETWORK_ADDRESS]: "names.titleNetworkAddress" +}; + +interface ParamTypes { + address?: string; +} + +interface Props { + listingType: ListingType; +} + +function getSiteTitle(t: TFunction, listingType: ListingType, address?: string): string { + switch (listingType) { + case ListingType.WALLETS: + return t("names.siteTitleWallets"); + case ListingType.NETWORK_ALL: + return t("names.siteTitleNetworkAll"); + case ListingType.NETWORK_ADDRESS: + return t("names.siteTitleNetworkAddress", { address }); + } +} + +export function NamesPage({ listingType }: Props): JSX.Element { + const { t } = useTranslation(); + const { address } = useParams(); + + // If there is an error (e.g. the lookup rejected the address list due to an + // invalid address), the table will bubble it up to here + const [error, setError] = useState(); + + const siteTitle = getSiteTitle(t, listingType, address); + const subTitle = listingType === ListingType.NETWORK_ADDRESS + ? address : undefined; + + return + {error + ? + : (listingType === ListingType.WALLETS + // Version of the table component that memoises the wallets + ? + : )} + ; +} + +/** + * This is equivalent to TransactionsPage.TransactionsTableWithWallets. See that + * component for some comments and review on why this is necessary, and how it + * could be improved in the future. + */ +function NamesTableWithWallets({ setError }: { setError: Dispatch> }): JSX.Element { + const { walletAddressMap } = useWallets(); + + // See TransactionsPage.tsx for comments + // TODO: improve this + const addresses = Object.keys(walletAddressMap); + addresses.sort(); + const addressList = addresses.join(","); + + const table = useMemo(() => ( + + ), [addressList, setError]); + + return table; +} diff --git a/src/pages/names/NamesResult.tsx b/src/pages/names/NamesResult.tsx new file mode 100644 index 0000000..9208add --- /dev/null +++ b/src/pages/names/NamesResult.tsx @@ -0,0 +1,41 @@ +// 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 { 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 NamesResult({ error }: Props): JSX.Element { + const { t } = useTranslation(); + + // Handle the most commonly expected errors from the API + if (error instanceof APIError) { + // Invalid address list + if (error.message === "invalid_parameter") { + return } + title={t("names.resultInvalidTitle")} + subTitle={t("names.resultInvalid")} + fullPage + />; + } + } + + // Unknown error + return } + title={t("names.resultUnknownTitle")} + subTitle={t("names.resultUnknown")} + fullPage + />; +} diff --git a/src/pages/names/NamesTable.tsx b/src/pages/names/NamesTable.tsx new file mode 100644 index 0000000..4e78efa --- /dev/null +++ b/src/pages/names/NamesTable.tsx @@ -0,0 +1,174 @@ +// 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 { KristName } from "../../krist/api/types"; +import { convertSorterOrder, lookupNames, LookupNamesOptions, LookupNamesResponse, SortableNameFields } from "../../krist/api/lookup"; + +import { KristNameLink } from "../../components/KristNameLink"; +import { ContextualAddress } from "../../components/ContextualAddress"; +import { TransactionConciseMetadata } from "../../components/transactions/TransactionConciseMetadata"; +import { DateTime } from "../../components/DateTime"; + +import Debug from "debug"; +const debug = Debug("kristweb:names-table"); + +interface Props { + addresses?: string[]; + setError?: Dispatch>; +} + +export function NamesTable({ addresses, 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: "name", + order: "ASC" + }); + + // Fetch the names from the API, mapping the table options + useEffect(() => { + debug("looking up names for %s", addresses ? addresses.join(",") : "network"); + setLoading(true); + + lookupNames(addresses, options) + .then(setRes) + .catch(setError) + .finally(() => setLoading(false)); + }, [addresses, setError, options]); + + debug("results? %b res.names.length: %d res.count: %d res.total: %d", !!res, res?.names?.length, res?.count, res?.total); + + return + className="names-table" + size="small" + + loading={loading} + dataSource={res?.names || []} + rowKey="name" + + // Triggered whenever the filter, sorting, or pagination changes + onChange={(pagination, _, sorter) => { + 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 }) + }} + + columns={[ + // Name + { + title: t("names.columnName"), + dataIndex: "name", key: "name", + + render: name => , + + sorter: true, + defaultSortOrder: "ascend" + }, + + // Owner + { + title: t("names.columnOwner"), + dataIndex: "owner", key: "owner", + + render: owner => owner && ( + + ), + + sorter: true + }, + + // Original owner + { + title: t("names.columnOriginalOwner"), + dataIndex: "original_owner", key: "original_owner", + + render: owner => owner && ( + + ), + + sorter: true + }, + + // A record + { + title: t("names.columnARecord"), + dataIndex: "a", key: "a", + + render: a => , + + sorter: true + }, + + // Unpaid blocks + { + title: t("names.columnUnpaid"), + dataIndex: "unpaid", key: "unpaid", + + // TODO: highlight this? + render: unpaid => unpaid && unpaid.toLocaleString(), + width: 50, + + sorter: true + }, + + // Registered time + { + title: t("names.columnRegistered"), + dataIndex: "registered", key: "registered", + + render: time => , + width: 200, + + sorter: true + }, + + // Updated time + { + title: t("names.columnUpdated"), + dataIndex: "updated", key: "updated", + + render: time => , + width: 200, + + sorter: true + } + ]} + />; +} diff --git a/src/pages/transactions/TransactionsPage.tsx b/src/pages/transactions/TransactionsPage.tsx index aa73e59..71bf1a2 100644 --- a/src/pages/transactions/TransactionsPage.tsx +++ b/src/pages/transactions/TransactionsPage.tsx @@ -84,6 +84,10 @@ className="transactions-page" withoutTopPadding + // If there's no "Include mined transactions" switch, pull the table's + // pagination up to the page header's extra area + negativeMargin={!!name} + // Alter the page title depending on the listing type titleKey={LISTING_TYPE_TITLES[listingType]} siteTitle={siteTitle} diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx index 1e0ee8b..2e3c560 100644 --- a/src/pages/transactions/TransactionsTable.tsx +++ b/src/pages/transactions/TransactionsTable.tsx @@ -115,7 +115,7 @@ title: t("transactions.columnID"), dataIndex: "id", key: "id", - render: id => <>{id.toLocaleString()}, + render: id => id.toLocaleString(), width: 100 // Don't allow sorting by ID to save a bit of width in the columns;