diff --git a/public/locales/en.json b/public/locales/en.json index 2cf6c5f..d1aaf39 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -150,11 +150,13 @@ "nameCount_plural": "{{count, number}} names", "nameCountEmpty": "No names", "firstSeen": "First seen {{date}}", + "firstSeenMobile": "First seen: <1 />", "walletCount": "{{count, number}} wallet", "walletCount_plural": "{{count, number}} wallets", "walletCountEmpty": "No wallets", + "actionsViewAddress": "View address", "actionsEditTooltip": "Edit wallet", "actionsSendTransaction": "Send Krist", "actionsWalletInfo": "Wallet info", diff --git a/src/components/addresses/ContextualAddress.less b/src/components/addresses/ContextualAddress.less index d030790..5d0c09e 100644 --- a/src/components/addresses/ContextualAddress.less +++ b/src/components/addresses/ContextualAddress.less @@ -20,7 +20,7 @@ } &.contextual-address-non-existent { - &, a { + &, span, a { color: @text-color-secondary; cursor: not-allowed; diff --git a/src/components/addresses/ContextualAddress.tsx b/src/components/addresses/ContextualAddress.tsx index 294b835..ae7f156 100644 --- a/src/components/addresses/ContextualAddress.tsx +++ b/src/components/addresses/ContextualAddress.tsx @@ -32,6 +32,7 @@ neverCopyable?: boolean; nonExistent?: boolean; noLink?: boolean; + noTooltip?: boolean; className?: string; } @@ -46,6 +47,7 @@ neverCopyable, nonExistent, noLink, + noTooltip, className }: Props): JSX.Element { const { t } = useTranslation(); @@ -90,7 +92,7 @@ // If the address definitely doesn't exist, show the 'not yet initialised' // tooltip on hover instead. - const showTooltip = !verified && + const showTooltip = !noTooltip && !verified && ((hideNameAddress && !!hasMetaname) || !!walletLabel || !!contactLabel); const tooltipTitle = nonExistent ? t("contextualAddressNonExistentTooltip") diff --git a/src/pages/wallets/WalletActions.tsx b/src/pages/wallets/WalletActions.tsx index 031dce6..a457b41 100644 --- a/src/pages/wallets/WalletActions.tsx +++ b/src/pages/wallets/WalletActions.tsx @@ -8,7 +8,8 @@ SendOutlined } from "@ant-design/icons"; -import { useTFns } from "@utils/i18n"; +import { TFunction } from "react-i18next"; +import { useTFns, TStrFn } from "@utils/i18n"; import { useAuth } from "@comp/auth"; import { OpenEditWalletFn } from "./WalletEditButton"; @@ -33,19 +34,10 @@ const { t, tStr } = useTFns("myWallets."); const promptAuth = useAuth(); - const showWalletDeleteConfirm = useCallback((): void => { - Modal.confirm({ - icon: , - - title: tStr("actionsDeleteConfirm"), - content: tStr("actionsDeleteConfirmDescription"), - - onOk: () => deleteWallet(wallet), - okText: t("dialog.yes"), - okType: "danger", - cancelText: t("dialog.no") - }); - }, [t, tStr, wallet]); + const showWalletDeleteConfirm = useCallback( + () => showWalletDeleteConfirmModal(t, tStr, wallet), + [t, tStr, wallet] + ); const memoDropdown = useMemo(() => , + + title: tStr("actionsDeleteConfirm"), + content: tStr("actionsDeleteConfirmDescription"), + + onOk: () => deleteWallet(wallet), + okText: t("dialog.yes"), + okType: "danger", + cancelText: t("dialog.no") + }); +} diff --git a/src/pages/wallets/WalletMobileItem.tsx b/src/pages/wallets/WalletMobileItem.tsx new file mode 100644 index 0000000..f3174b5 --- /dev/null +++ b/src/pages/wallets/WalletMobileItem.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { useCallback, useMemo } from "react"; +import { Tag, Collapse, Menu } from "antd"; +import { + ProfileOutlined, SendOutlined, EditOutlined, InfoCircleOutlined, + DeleteOutlined +} from "@ant-design/icons"; + +import { Trans } from "react-i18next"; +import { useTFns } from "@utils/i18n"; + +import { useHistory } from "react-router-dom"; + +import { Wallet } from "@wallets"; + +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; +import { KristValue } from "@comp/krist/KristValue"; +import { DateTime } from "@comp/DateTime"; + +import { useAuth } from "@comp/auth"; +import { OpenEditWalletFn } from "./WalletEditButton"; +import { OpenSendTxFn } from "@comp/transactions/SendTransactionModalLink"; +import { OpenWalletInfoFn } from "./info/WalletInfoModal"; +import { showWalletDeleteConfirmModal } from "./WalletActions"; + +interface Props { + wallet: Wallet; + + openEditWallet: OpenEditWalletFn; + openSendTx: OpenSendTxFn; + openWalletInfo: OpenWalletInfoFn; +} + +export function WalletMobileItem({ + wallet, + openEditWallet, + openSendTx, + openWalletInfo +}: Props): JSX.Element { + const { t, tStr, tKey } = useTFns("myWallets."); + + const itemHead = useMemo(() => ( +
+ {/* Wallet balance */} +
+ +
+ + {/* Label, if possible */} + {wallet.label && + {wallet.label} + {/* Don't save tag */} + {wallet.dontSave && ( + + {tStr("tagDontSave")} + + )} + } + +
+ {/* Address */} + + + {/* Category */} + {wallet.category && <> + + + + {wallet.category} + + } + + {/* Names */} + {(wallet.names || 0) > 0 && <> + + + + {t(tKey("nameCount"), { count: wallet.names })} + + } +
+ + {/* First seen */} + {wallet.firstSeen &&
+ + First seen + +
} +
+ ), [ + t, tKey, tStr, + wallet.address, wallet.label, wallet.category, wallet.dontSave, + wallet.firstSeen, wallet.balance, wallet.names + ]); + + return + + + + ; +} + +function WalletMobileItemActions({ + wallet, + openEditWallet, + openSendTx, + openWalletInfo +}: Props): JSX.Element { + const { t, tStr } = useTFns("myWallets."); + + const history = useHistory(); + const promptAuth = useAuth(); + + const showWalletDeleteConfirm = useCallback( + () => showWalletDeleteConfirmModal(t, tStr, wallet), + [t, tStr, wallet] + ); + + const addressLink = `/network/addresses/${encodeURIComponent(wallet.address)}`; + + return + {/* View address */} + } + onClick={() => history.push(addressLink)}> + {tStr("actionsViewAddress")} + + + {/* Send Krist */} + } + onClick={() => promptAuth(false, () => openSendTx(wallet))}> + {tStr("actionsSendTransaction")} + + + + + {/* Edit wallet */} + } + onClick={() => promptAuth(false, () => openEditWallet(wallet))}> + {tStr("actionsEditTooltip")} + + + {/* Wallet info */} + } + onClick={() => openWalletInfo(wallet)}> + {tStr("actionsWalletInfo")} + + + + + {/* Delete wallet */} + } + onClick={showWalletDeleteConfirm}> + {tStr("actionsDelete")} + + ; +} diff --git a/src/pages/wallets/WalletsPage.less b/src/pages/wallets/WalletsPage.less new file mode 100644 index 0000000..015026a --- /dev/null +++ b/src/pages/wallets/WalletsPage.less @@ -0,0 +1,63 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; +@import "../../style/table.less"; + +.wallets-page .table-mobile-list-view { + .wallet-mobile-item { + background: transparent; + transition: background @animation-duration-base @ease-in-out; + + // Move the padding from the mobile-item to the collapse header, to make the + // clickable area the full size + &.card-list-item { padding: 0; } + .ant-collapse-header { + padding: @padding-sm @padding-md; + + // Darken the background when expanded + &[aria-expanded=true] { + background: @kw-darker; + } + } + + .wallet-label { + display: block; + font-size: 120%; + } + + .wallet-value { + float: right; + font-size: 120%; + } + + .wallet-first-seen { + display: block; + font-size: @font-size-sm; + color: @text-color-secondary; + } + + .wallet-names { + white-space: nowrap; + } + + .sep:before { + content: "\2013"; + + display: inline-block; + margin: 0 @padding-xs; + color: @text-color-secondary; + } + + .ant-collapse-content-box { + // Make the actions menu flush + padding: 0; + + .ant-menu { + .ant-menu-item { + margin-bottom: 0; + } + } + } + } +} diff --git a/src/pages/wallets/WalletsPage.tsx b/src/pages/wallets/WalletsPage.tsx index e211a43..b3d817c 100644 --- a/src/pages/wallets/WalletsPage.tsx +++ b/src/pages/wallets/WalletsPage.tsx @@ -14,6 +14,8 @@ import { useSendTransactionModal } from "@comp/transactions/SendTransactionModalLink"; import { useWalletInfoModal } from "./info/WalletInfoModal"; +import "./WalletsPage.less"; + /** Extract the subtitle into its own component to avoid re-rendering the * entire page when a wallet is added. */ function WalletsPageSubtitle(): JSX.Element { @@ -39,6 +41,7 @@ siteTitleKey="myWallets.title" titleKey="myWallets.title" subTitle={} extra={extra} + className="wallets-page" > = useCallback(wallet => ( + + ), [openEditWallet, openSendTx, openWalletInfo]); + + const { isMobile, list } = useSimpleMobileList( + false, walletValues, "id", "balance", true, renderMobileItem + ); + + return isMobile && list + ? list + : ; +} + +interface DesktopViewProps extends Props { + wallets: Wallet[]; +} + +function DesktopView({ + wallets, + openEditWallet, + openSendTx, + openWalletInfo +}: DesktopViewProps): JSX.Element { const { tStr } = useTFns("myWallets."); - const { wallets } = useWallets(); const { categories, joinedCategoryList } = useWalletCategories(); const dateColumnWidth = useDateColumnWidth(); @@ -151,7 +190,7 @@ size="small" scroll={{ x: true }} - dataSource={Object.values(wallets)} + dataSource={wallets} rowKey="id" pagination={{ diff --git a/src/utils/table/mobileList.tsx b/src/utils/table/mobileList.tsx index fd4c3ef..a14c8a0 100644 --- a/src/utils/table/mobileList.tsx +++ b/src/utils/table/mobileList.tsx @@ -8,6 +8,7 @@ import { PaginationChangeFn, LookupFilterOptionsBase } from "@utils/table/table"; import { useSortModal, SetOpenSortModalFn, SortOptions } from "./SortModal"; +import { keyedNullSort } from "@utils"; import { useBreakpoint } from "@utils/hooks"; interface MobileListHookRes { @@ -39,9 +40,6 @@ const bps = useBreakpoint(); const isMobile = !bps.md; - console.log(paginationConfig); - console.log(options); - const sortModal = useSortModal( sortOptions, defaultOrderBy, defaultOrder, options, setOptions, setOpenSortModal @@ -76,3 +74,46 @@ return { isMobile, list }; } + +/** Alternative for useMobileList that doesn't require the lookup API. + * Has limited functionality. */ +export function useSimpleMobileList( + loading: boolean, + values: T[], + rowKey: string, + + sortBy: keyof T, + sortDesc: boolean, + + renderItem: (item: T, index: number) => ReactNode +): MobileListHookRes { + const bps = useBreakpoint(); + const isMobile = !bps.md; + + const sortFn = useMemo(() => keyedNullSort(sortBy, true), [sortBy]); + + const sortedValues = useMemo(() => { + const sorted = values.sort((a, b) => sortFn(a, b, sortDesc ? "descend" : "ascend")); + if (sortDesc) sorted.reverse(); + return sorted; + }, [values, sortFn, sortDesc]); + + const list = useMemo(() => { + if (!isMobile) return null; + + return ; + }, [isMobile, loading, sortedValues, rowKey, renderItem]); + + return { isMobile, list }; +}