From 22327a2fbbbd101ed474e3c05e91a3641ecffbae Mon Sep 17 00:00:00 2001 From: tangxuezhi <0x74616e67@gmail.com> Date: Tue, 28 Jun 2022 10:44:51 +0800 Subject: [PATCH] feat: add profile --- src/app/components/AddressContainer/index.tsx | 25 +- src/app/components/ConnectWallet/Button.tsx | 1 - .../AddressDetailPage.tsx | 62 +++- .../ContractDetailPage.tsx | 55 +++- src/app/containers/Header/index.tsx | 16 + src/app/containers/Profile/AddressLabel.tsx | 295 +++++++++++++++++ .../containers/Profile/CreateAddressLabel.tsx | 245 ++++++++++++++ src/app/containers/Profile/CreateTxNote.tsx | 228 ++++++++++++++ src/app/containers/Profile/Loadable.tsx | 10 + src/app/containers/Profile/TxNote.tsx | 298 ++++++++++++++++++ src/app/containers/Profile/index.tsx | 45 +++ src/app/containers/Transaction/Detail.tsx | 80 ++++- src/app/index.tsx | 51 +++ src/locales/en/translation.json | 56 +++- src/locales/zh_cn/translation.json | 56 +++- src/setupProxy.js | 4 +- src/styles/global-styles.ts | 4 + src/utils/constants.ts | 2 + src/utils/gaConstants.ts | 1 + src/utils/tableColumns/token.tsx | 9 +- 20 files changed, 1519 insertions(+), 24 deletions(-) create mode 100644 src/app/containers/Profile/AddressLabel.tsx create mode 100644 src/app/containers/Profile/CreateAddressLabel.tsx create mode 100644 src/app/containers/Profile/CreateTxNote.tsx create mode 100644 src/app/containers/Profile/Loadable.tsx create mode 100644 src/app/containers/Profile/TxNote.tsx create mode 100644 src/app/containers/Profile/index.tsx diff --git a/src/app/components/AddressContainer/index.tsx b/src/app/components/AddressContainer/index.tsx index 0e8a117cf..b7ea3cc28 100644 --- a/src/app/components/AddressContainer/index.tsx +++ b/src/app/components/AddressContainer/index.tsx @@ -14,7 +14,7 @@ import { isPosAddress, getUrl, } from 'utils'; -import { AlertTriangle, File } from '@zeit-ui/react-icons'; +import { AlertTriangle, File, Bookmark } from '@zeit-ui/react-icons'; import ContractIcon from 'images/contract-icon.png'; import isMeIcon from 'images/me.png'; import InternalContractIcon from 'images/internal-contract-icon.png'; @@ -27,6 +27,8 @@ import { } from 'utils/constants'; import { monospaceFont } from 'styles/variable'; import SDK from 'js-conflux-sdk/dist/js-conflux-sdk.umd.min.js'; +import { useGlobalData } from 'utils/hooks/useGlobal'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; interface Props { value: string; // address value @@ -41,6 +43,7 @@ interface Props { showIcon?: boolean; // whether show contract icon, default true verify?: boolean; // show verified contract icon or unverified contract icon isEspaceAddress?: boolean; // check the address if is a eSpace hex address, if yes, link to https://evm.confluxscan.net/address/{hex_address} + showLabeled?: boolean; } const defaultPCMaxWidth = 138; @@ -147,7 +150,10 @@ export const AddressContainer = withTranslation()( t, verify = false, isEspaceAddress, + showLabeled = true, }: Props & WithTranslation) => { + const [globalData = {}] = useGlobalData(); + const suffixSize = suffixAddressSize || (window.innerWidth <= sizes.m @@ -259,6 +265,21 @@ export const AddressContainer = withTranslation()( alias = t(translations.general.zeroAddress); } + let labelIcon: React.ReactNode = null; + if (showLabeled) { + const addressLabelMap = globalData[LOCALSTORAGE_KEYS_MAP.addressLabel]; + if (addressLabelMap[cfxAddress]) { + alias = `${addressLabelMap[cfxAddress]}${alias ? ` (${alias})` : ''}`; + labelIcon = ( + + + + + + ); + } + } + if (isContractAddress(cfxAddress) || isInnerContractAddress(cfxAddress)) { const typeText = t( isInnerContractAddress(cfxAddress) @@ -280,6 +301,7 @@ export const AddressContainer = withTranslation()( prefixFloat ? 'float' : '' }`} > + {labelIcon} {isInnerContractAddress(cfxAddress) ? ( @@ -334,6 +356,7 @@ export const AddressContainer = withTranslation()( isFull, maxWidth, suffixSize, + prefix: labelIcon, }); }, ), diff --git a/src/app/components/ConnectWallet/Button.tsx b/src/app/components/ConnectWallet/Button.tsx index dc882a734..9c755a46c 100644 --- a/src/app/components/ConnectWallet/Button.tsx +++ b/src/app/components/ConnectWallet/Button.tsx @@ -31,7 +31,6 @@ export const Button = ({ className, onClick, showBalance }: Button) => { const { t } = useTranslation(); const [balance, setBalance] = useState('0'); const { installed, connected, accounts } = usePortal(); - const { pendingRecords } = useContext(TxnHistoryContext); const { isValid } = useCheckHook(true); diff --git a/src/app/containers/AddressContractDetail/AddressDetailPage.tsx b/src/app/containers/AddressContractDetail/AddressDetailPage.tsx index a34903d0a..98cbeb6e0 100644 --- a/src/app/containers/AddressContractDetail/AddressDetailPage.tsx +++ b/src/app/containers/AddressContractDetail/AddressDetailPage.tsx @@ -4,7 +4,7 @@ * */ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -25,12 +25,18 @@ import { Link as RouterLink } from 'react-router-dom'; import DownIcon from '../../../images/down.png'; import styled from 'styled-components'; import { media } from '../../../styles/media'; +import { useGlobalData } from 'utils/hooks/useGlobal'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; +import { Bookmark } from '@zeit-ui/react-icons'; +import { Text } from 'app/components/Text/Loadable'; +import { CreateAddressLabel } from '../Profile/CreateAddressLabel'; interface RouteParams { address: string; } export const AddressDetailPage = memo(() => { + const [globalData = {}] = useGlobalData(); const { t } = useTranslation(); const { address } = useParams(); const { data: accountInfo } = useAccount(address, [ @@ -40,6 +46,10 @@ export const AddressDetailPage = memo(() => { 'erc1155TransferCount', 'stakingBalance', ]); + const [visible, setVisible] = useState(false); + + const addressLabelMap = globalData[LOCALSTORAGE_KEYS_MAP.addressLabel]; + const addressLabel = addressLabelMap[address]; const menu = ( @@ -53,6 +63,23 @@ export const AddressDetailPage = memo(() => { {t(translations.general.address.more.NFTChecker)} + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + { + e.preventDefault(); + e.stopPropagation(); + setVisible(true); + }} + href="" + > + {t( + translations.general.address.more[ + addressLabel ? 'updateLabel' : 'addLabel' + ], + )} + + {t(translations.general.address.more.report)} @@ -61,6 +88,20 @@ export const AddressDetailPage = memo(() => { ); + const props = { + stage: addressLabel ? 'edit' : 'create', + visible, + data: { + address, + }, + onOk: () => { + setVisible(false); + }, + onCancel: () => { + setVisible(false); + }, + }; + return ( <> @@ -78,11 +119,25 @@ export const AddressDetailPage = memo(() => { : t(translations.general.address.address)} - {address} + + {address} + {addressLabel ? ( + <> + {' '} + ( + + + + {addressLabel}) + + ) : ( + '' + )} +
- + e.preventDefault()}> {t(translations.general.address.more.title)}{' '} { + ); diff --git a/src/app/containers/AddressContractDetail/ContractDetailPage.tsx b/src/app/containers/AddressContractDetail/ContractDetailPage.tsx index 9e779262b..a6b529e72 100644 --- a/src/app/containers/AddressContractDetail/ContractDetailPage.tsx +++ b/src/app/containers/AddressContractDetail/ContractDetailPage.tsx @@ -4,7 +4,7 @@ * */ -import React, { memo, useEffect } from 'react'; +import React, { memo, useEffect, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link as RouterLink, useHistory, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -41,15 +41,22 @@ import DownIcon from '../../../images/down.png'; import { Menu } from '@cfxjs/antd'; import { DropdownWrapper, MenuWrapper } from './AddressDetailPage'; import { tokenTypeTag } from '../TokenDetail/Basic'; +import { useGlobalData } from 'utils/hooks/useGlobal'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; +import { Bookmark } from '@zeit-ui/react-icons'; +import { Text } from 'app/components/Text/Loadable'; +import { CreateAddressLabel } from '../Profile/CreateAddressLabel'; interface RouteParams { address: string; } export const ContractDetailPage = memo(() => { + const [globalData = {}] = useGlobalData(); const { t } = useTranslation(); const { address } = useParams(); const history = useHistory(); + const [visible, setVisible] = useState(false); const { data: contractInfo } = useContract(address, [ 'name', @@ -92,6 +99,8 @@ export const ContractDetailPage = memo(() => { websiteUrl !== 'https://' && websiteUrl !== 'http://' && websiteUrl !== t(translations.general.loading); + const addressLabelMap = globalData[LOCALSTORAGE_KEYS_MAP.addressLabel]; + const addressLabel = addressLabelMap[address]; const menu = ( @@ -112,6 +121,23 @@ export const ContractDetailPage = memo(() => { {t(translations.general.address.more.editContract)} + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + { + e.preventDefault(); + e.stopPropagation(); + setVisible(true); + }} + href="" + > + {t( + translations.general.address.more[ + addressLabel ? 'updateLabel' : 'addLabel' + ], + )} + + {[NETWORK_TYPES.testnet, NETWORK_TYPES.mainnet].includes(NETWORK_TYPE) ? ( @@ -146,6 +172,20 @@ export const ContractDetailPage = memo(() => { ); + const props = { + stage: addressLabel ? 'edit' : 'create', + visible, + data: { + address, + }, + onOk: () => { + setVisible(false); + }, + onCancel: () => { + setVisible(false); + }, + }; + return ( <> @@ -181,6 +221,18 @@ export const ContractDetailPage = memo(() => { )}   {address} + {addressLabel ? ( + <> + {' '} + ( + + + + {addressLabel}) + + ) : ( + '' + )}
@@ -227,6 +279,7 @@ export const ContractDetailPage = memo(() => {
+ ); diff --git a/src/app/containers/Header/index.tsx b/src/app/containers/Header/index.tsx index 4af972bfd..c4430ce30 100644 --- a/src/app/containers/Header/index.tsx +++ b/src/app/containers/Header/index.tsx @@ -519,6 +519,14 @@ export const Header = memo(() => { afterClick: menuClick, href: '/balance-checker', }, + { + // profile + title: t(translations.header.profile), + name: ScanEvent.menu.action.home, + afterClick: menuClick, + href: '/profile', + className: 'profile', + }, ], }, { @@ -535,6 +543,14 @@ export const Header = memo(() => { ]; const endLinks: HeaderLinks = [ + // { + // // profile + // title: t(translations.header.profile), + // name: ScanEvent.menu.action.home, + // afterClick: menuClick, + // href: '/profile', + // className: 'profile', + // }, { // switch network name: 'switch-network', diff --git a/src/app/containers/Profile/AddressLabel.tsx b/src/app/containers/Profile/AddressLabel.tsx new file mode 100644 index 000000000..a0e4ee749 --- /dev/null +++ b/src/app/containers/Profile/AddressLabel.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { translations } from 'locales/i18n'; +import { Space, Modal, Input } from '@cfxjs/antd'; +import { Button } from 'app/components/Button/Loadable'; +import { formatTimeStamp } from 'utils'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; +import { ContentWrapper } from 'utils/tableColumns/utils'; +import { TablePanel as TablePanelNew } from 'app/components/TablePanelNew'; +import { useGlobalData } from 'utils/hooks/useGlobal'; +import { Link } from 'app/components/Link/Loadable'; +import { useHistory, useLocation } from 'react-router-dom'; +import styled from 'styled-components/macro'; +import qs from 'query-string'; +import { CreateAddressLabel } from './CreateAddressLabel'; + +const { confirm, warning } = Modal; +const { Search } = Input; + +type Type = { + a: string; + l: string; + t: number; + u: number; +}; + +export function AddressLabel() { + const history = useHistory(); + const { search: s } = useLocation(); + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [stage, setStage] = useState('create'); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [globalData, setGlobalData] = useGlobalData(); + const [search, setSearch] = useState(() => { + return qs.parse(s).search || ''; + }); + const [data, setData] = useState({ + address: '', + label: '', + }); + + useEffect(() => { + try { + setLoading(true); + const l = localStorage.getItem(LOCALSTORAGE_KEYS_MAP.addressLabel); + if (l) { + setList(JSON.parse(l)); + } + setLoading(false); + } catch (e) {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const columns = [ + { + title: t(translations.profile.address.address), + dataIndex: 'a', + key: 'a', + width: 8, + render(v) { + return ( + + {v} + + ); + }, + }, + { + title: t(translations.profile.address.label), + dataIndex: 'l', + key: 'l', + width: 4, + }, + { + title: ( + + {t(translations.general.timestamp)} + + ), + dataIndex: 'u', + key: 'u', + width: 4, + render(v, r) { + return ( + + {formatTimeStamp(v * 1000, 'standard')} + + ); + }, + }, + ]; + + const handleClickC = (e, address = '') => { + if (list.length > 1000) { + warning({ + title: t(translations.general.warning), + content: t(translations.general.exceedTip), + icon: null, + okText: t(translations.general.buttonOk), + cancelText: t(translations.general.buttonCancel), + }); + + return; + } + + setData({ + address: address, + label: '', + }); + setStage('create'); + setVisible(true); + }; + + const handleClickE = () => { + const selectedItem = list.filter(l => l.a === selectedRowKeys[0])[0]; + setData({ + address: selectedItem?.a, + label: selectedItem?.l, + }); + setStage('edit'); + setVisible(true); + }; + + const handleClickD = () => { + confirm({ + title: t(translations.general.warning), + content: t(translations.general.deleteTip), + icon: null, + okText: t(translations.general.buttonOk), + cancelText: t(translations.general.buttonCancel), + onOk() { + const newList = list.filter(l => !selectedRowKeys.includes(l.a)); + + setLoading(true); + + localStorage.setItem( + LOCALSTORAGE_KEYS_MAP.addressLabel, + JSON.stringify(newList), + ); + + setGlobalData({ + ...globalData, + [LOCALSTORAGE_KEYS_MAP.addressLabel]: newList.reduce((prev, curr) => { + return { + ...prev, + [curr.a]: curr.l, + }; + }, {}), + }); + + handleSearch(''); + + setList(newList); + setLoading(false); + }, + onCancel() { + console.log('Cancel'); + }, + }); + }; + + const handleOk = () => { + setVisible(true); + }; + + const handleCancel = () => { + setVisible(false); + }; + + const onSelectChange = (newSelectedRowKeys: React.Key[]) => { + setSelectedRowKeys(newSelectedRowKeys); + }; + + const handleSearch = value => { + const search = value ? `&&search=${value}` : ''; + history.push(`/profile?tab=address-label${search}`); + }; + + const handleSearchChange = e => { + setSearch(e.target.value.trim()); + }; + + const rowSelection = { + selectedRowKeys, + onChange: onSelectChange, + columnWidth: 1, + }; + + const text = { + create: t(translations.general.create), + edit: t(translations.general.edit), + delete: t(translations.general.delete), + }; + + const queryKey = (qs.parse(s).search as string) || ''; + + return ( + <> +
+ + + + + + + + + +
+ l.a.includes(queryKey) || l.l.includes(queryKey), + )} + columns={columns} + loading={loading} + rowKey="a" + key={Math.random()} + /> + + + ); +} + +const SearchWrapper = styled.div` + width: 500px; + + .ant-input { + border-radius: 16px !important; + background: rgba(30, 61, 228, 0.04); + border: none !important; + padding-right: 41px; + } + + .ant-input-group { + display: flex; + } + + .ant-input-group-addon { + background: transparent !important; + left: -38px !important; + z-index: 80; + + .ant-btn { + background: transparent !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; + line-height: 1 !important; + box-shadow: none !important; + + &:after { + display: none !important; + } + + .anticon { + font-size: 18px; + margin-bottom: 3px; + } + } + } +`; diff --git a/src/app/containers/Profile/CreateAddressLabel.tsx b/src/app/containers/Profile/CreateAddressLabel.tsx new file mode 100644 index 000000000..9d82ce49c --- /dev/null +++ b/src/app/containers/Profile/CreateAddressLabel.tsx @@ -0,0 +1,245 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { translations } from 'locales/i18n'; +import { Form, Modal, Input, message } from '@cfxjs/antd'; +import { + isBase32Address, + isCurrentNetworkAddress, + publishRequestError, +} from 'utils'; +import { NETWORK_TYPE } from 'utils/constants'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; +import { useGlobalData } from 'utils/hooks/useGlobal'; + +type Type = { + a: string; + l: string; + t: number; + u: number; +}; + +type Props = { + visible: boolean; + stage: string; + data: { + address: string; + label?: string; + note?: string; + }; + list?: null | Array; + labelLengthLimit?: number; + onOk: () => void; + onCancel: () => void; +}; + +export function CreateAddressLabel({ + visible = false, + stage = 'create', + data = { + address: '', + label: '', + }, + list: outerList, + labelLengthLimit = 20, + onOk = () => {}, + onCancel = () => {}, +}: Props) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [list, setList] = useState(outerList || []); + const [loading, setLoading] = useState(false); + const [globalData, setGlobalData] = useGlobalData(); + + useEffect(() => { + try { + if (!outerList) { + setLoading(true); + const l = localStorage.getItem(LOCALSTORAGE_KEYS_MAP.addressLabel); + if (l) { + setList(JSON.parse(l)); + } + setLoading(false); + } else { + setList(outerList); + } + } catch (e) {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [outerList]); + + const handleOk = () => { + form + .validateFields() + .then(async function ({ address, label }) { + try { + let newList: Array = list; + const timestamp = Math.floor(+new Date() / 1000); + + if (stage === 'create') { + if (list.some(l => l.a === address)) { + message.error(t(translations.profile.address.error.duplicated)); + return; + } + + const item: Type = { + a: address as string, // address + l: label as string, // label + t: timestamp, // create timestamp + u: timestamp, // update timestamp + }; + + newList = [item].concat(list); + } else if (stage === 'edit') { + const i = list.findIndex(l => l.a === address); + const old = list[i]; + + newList.splice(i, 1); + newList = [ + { + ...old, + u: timestamp, + l: label as string, + }, + ].concat(newList); + } + + setLoading(true); + + localStorage.setItem( + LOCALSTORAGE_KEYS_MAP.addressLabel, + JSON.stringify(newList), + ); + + setGlobalData({ + ...globalData, + [LOCALSTORAGE_KEYS_MAP.addressLabel]: newList.reduce( + (prev, curr) => { + return { + ...prev, + [curr.a]: curr.l, + }; + }, + {}, + ), + }); + + setLoading(false); + onOk(); + } catch (e) { + publishRequestError(e, 'code'); + } + }) + .catch(e => publishRequestError(e, 'code')); + }; + + const handleCancel = () => { + form.resetFields([]); + setLoading(false); + onCancel(); + }; + + const validator = useCallback(() => { + return { + validator(_, value) { + if (isBase32Address(value)) { + if (isCurrentNetworkAddress(value)) { + return Promise.resolve(); + } else { + return Promise.reject( + new Error( + t(translations.nftDetail.error.invalidNetwork, { + network: t( + translations.general.networks[ + String(NETWORK_TYPE).toLowerCase() + ], + ), + }), + ), + ); + } + } + return Promise.reject( + new Error(t(translations.nftDetail.error.invalidAddress)), + ); + }, + }; + }, [t]); + + const tagValidator = useCallback(() => { + return { + validator(_, value) { + if (value.length > labelLengthLimit) { + return Promise.reject( + new Error( + t(translations.profile.address.error.invalidLabelRange, { + amount: 20, + }), + ), + ); + } else { + return Promise.resolve(); + } + }, + }; + }, [labelLengthLimit, t]); + + const text = { + create: t(translations.general.create), + edit: t(translations.general.edit), + delete: t(translations.general.delete), + }; + + const d = { + ...data, + label: list.filter(l => l.a === data.address)[0]?.l, + }; + + return ( + +
+ + + + + + + +
+ ); +} diff --git a/src/app/containers/Profile/CreateTxNote.tsx b/src/app/containers/Profile/CreateTxNote.tsx new file mode 100644 index 000000000..6c34be932 --- /dev/null +++ b/src/app/containers/Profile/CreateTxNote.tsx @@ -0,0 +1,228 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { translations } from 'locales/i18n'; +import { Form, Modal, Input, message } from '@cfxjs/antd'; +import { isHash, publishRequestError } from 'utils'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; +import { useGlobalData } from 'utils/hooks/useGlobal'; + +type Type = { + h: string; + n: string; + t: number; + u: number; +}; + +type Props = { + visible: boolean; + stage: string; + data: { + hash: string; + note?: string; + }; + list?: null | Array; + noteLengthLimit?: number; + onOk: () => void; + onCancel: () => void; +}; + +export function CreateTxNote({ + visible = false, + stage = 'create', + data = { + hash: '', + note: '', + }, + list: outerList, + noteLengthLimit = 20, + onOk = () => {}, + onCancel = () => {}, +}: Props) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [list, setList] = useState(outerList || []); + const [loading, setLoading] = useState(false); + const [globalData, setGlobalData] = useGlobalData(); + + useEffect(() => { + try { + if (!outerList) { + setLoading(true); + const l = localStorage.getItem(LOCALSTORAGE_KEYS_MAP.txPrivateNote); + if (l) { + setList(JSON.parse(l)); + } + setLoading(false); + } else { + setList(outerList); + } + } catch (e) {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [outerList]); + + const handleOk = () => { + form + .validateFields() + .then(async function ({ hash, note }) { + try { + let newList: Array = list; + const timestamp = Math.floor(+new Date() / 1000); + + if (stage === 'create') { + if (list.some(l => l.h === hash)) { + message.error(t(translations.profile.tx.error.duplicated)); + return; + } + + const item: Type = { + h: hash as string, // hash + n: note as string, // note + t: timestamp, // create timestamp + u: timestamp, // update timestamp + }; + + newList = [item].concat(list); + } else if (stage === 'edit') { + const i = list.findIndex(l => l.h === hash); + const old = list[i]; + + newList.splice(i, 1); + newList = [ + { + ...old, + u: timestamp, + n: note as string, + }, + ].concat(newList); + } + + setLoading(true); + + localStorage.setItem( + LOCALSTORAGE_KEYS_MAP.txPrivateNote, + JSON.stringify(newList), + ); + + const d = { + ...globalData, + [LOCALSTORAGE_KEYS_MAP.txPrivateNote]: newList.reduce( + (prev, curr) => { + return { + ...prev, + [curr.h]: curr.n, + }; + }, + {}, + ), + }; + setGlobalData(d); + + console.log(19999, LOCALSTORAGE_KEYS_MAP.txPrivateNote, d); + + setLoading(false); + onOk(); + } catch (e) { + publishRequestError(e, 'code'); + } + }) + .catch(e => publishRequestError(e, 'code')); + }; + + const handleCancel = () => { + form.resetFields([]); + setLoading(false); + onCancel(); + }; + + const validator = useCallback(() => { + return { + validator(_, value) { + if (isHash(value)) { + return Promise.resolve(); + } + return Promise.reject( + new Error(t(translations.profile.tx.error.invalidHash)), + ); + }, + }; + }, [t]); + + const tagValidator = useCallback(() => { + return { + validator(_, value) { + if (value.length > noteLengthLimit) { + return Promise.reject( + new Error( + t(translations.profile.tx.error.invalidNoteRange, { + amount: 20, + }), + ), + ); + } else { + return Promise.resolve(); + } + }, + }; + }, [noteLengthLimit, t]); + + const text = { + create: t(translations.general.create), + edit: t(translations.general.edit), + delete: t(translations.general.delete), + }; + + const d = { + ...data, + note: list.filter(l => l.h === data.hash)[0]?.n, + }; + + return ( + +
+ + + + + + + +
+ ); +} diff --git a/src/app/containers/Profile/Loadable.tsx b/src/app/containers/Profile/Loadable.tsx new file mode 100644 index 000000000..0fc2a01d3 --- /dev/null +++ b/src/app/containers/Profile/Loadable.tsx @@ -0,0 +1,10 @@ +/** + * Asynchronously loads the component for Profile + */ + +import { lazyLoad } from 'utils/loadable'; + +export const Profile = lazyLoad( + () => import('./index'), + module => module.Profile, +); diff --git a/src/app/containers/Profile/TxNote.tsx b/src/app/containers/Profile/TxNote.tsx new file mode 100644 index 000000000..a68005f79 --- /dev/null +++ b/src/app/containers/Profile/TxNote.tsx @@ -0,0 +1,298 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { translations } from 'locales/i18n'; +import { Space, Modal, Input } from '@cfxjs/antd'; +import { Button } from 'app/components/Button/Loadable'; +import { formatTimeStamp } from 'utils'; +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; +import { ContentWrapper } from 'utils/tableColumns/utils'; +import { TablePanel as TablePanelNew } from 'app/components/TablePanelNew'; +import { useGlobalData } from 'utils/hooks/useGlobal'; +import { Link } from 'app/components/Link/Loadable'; +import { useHistory, useLocation } from 'react-router-dom'; +import styled from 'styled-components/macro'; +import qs from 'query-string'; +import { CreateTxNote } from './CreateTxNote'; + +const { confirm, warning } = Modal; +const { Search } = Input; + +type Type = { + h: string; + n: string; + t: number; + u: number; +}; + +export function TxNote() { + const history = useHistory(); + const { search: s } = useLocation(); + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [stage, setStage] = useState('create'); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [globalData, setGlobalData] = useGlobalData(); + const [search, setSearch] = useState(() => { + return qs.parse(s).search || ''; + }); + const [data, setData] = useState({ + hash: '', + note: '', + }); + + useEffect(() => { + try { + setLoading(true); + const l = localStorage.getItem(LOCALSTORAGE_KEYS_MAP.txPrivateNote); + if (l) { + setList(JSON.parse(l)); + } + setLoading(false); + } catch (e) {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const columns = [ + { + title: t(translations.profile.tx.hash), + dataIndex: 'h', + key: 'h', + width: 8, + render(v) { + return ( + + {v} + + ); + }, + }, + { + title: t(translations.profile.tx.note), + dataIndex: 'n', + key: 'n', + width: 4, + }, + { + title: ( + + {t(translations.general.timestamp)} + + ), + dataIndex: 'u', + key: 'u', + width: 4, + render(v, r) { + return ( + + {formatTimeStamp(v * 1000, 'standard')} + + ); + }, + }, + ]; + + const handleClickC = (e, hash = '') => { + if (list.length > 1000) { + warning({ + title: t(translations.general.warning), + content: t(translations.general.exceedTip), + icon: null, + okText: t(translations.general.buttonOk), + cancelText: t(translations.general.buttonCancel), + }); + + return; + } + + setData({ + hash: hash, + note: '', + }); + setStage('create'); + setVisible(true); + }; + + const handleClickE = () => { + const selectedItem = list.filter(l => l.h === selectedRowKeys[0])[0]; + setData({ + hash: selectedItem?.h, + note: selectedItem?.n, + }); + setStage('edit'); + setVisible(true); + }; + + const handleClickD = () => { + confirm({ + title: t(translations.general.warning), + content: t(translations.general.deleteTip), + icon: null, + okText: t(translations.general.buttonOk), + cancelText: t(translations.general.buttonCancel), + onOk() { + const newList = list.filter(l => !selectedRowKeys.includes(l.h)); + + setLoading(true); + + localStorage.setItem( + LOCALSTORAGE_KEYS_MAP.txPrivateNote, + JSON.stringify(newList), + ); + const d = { + ...globalData, + [LOCALSTORAGE_KEYS_MAP.txPrivateNote]: newList.reduce( + (prev, curr) => { + return { + ...prev, + [curr.h]: curr.n, + }; + }, + {}, + ), + }; + setGlobalData(d); + + handleSearch(''); + + setList(newList); + setLoading(false); + }, + onCancel() { + console.log('Cancel'); + }, + }); + }; + + const handleOk = () => { + setVisible(true); + }; + + const handleCancel = () => { + setVisible(false); + }; + + const onSelectChange = (newSelectedRowKeys: React.Key[]) => { + setSelectedRowKeys(newSelectedRowKeys); + }; + + const handleSearch = value => { + const search = value ? `&&search=${value}` : ''; + history.push(`/profile?tab=tx-note${search}`); + }; + + const handleSearchChange = e => { + setSearch(e.target.value.trim()); + }; + + const rowSelection = { + selectedRowKeys, + onChange: onSelectChange, + columnWidth: 1, + }; + + const text = { + create: t(translations.general.create), + edit: t(translations.general.edit), + delete: t(translations.general.delete), + }; + + const queryKey = (qs.parse(s).search as string) || ''; + + return ( + <> +
+ + + + + + + + + +
+ l.h.includes(queryKey) || l.n.includes(queryKey), + )} + columns={columns} + loading={loading} + rowKey="h" + key={Math.random()} + /> + + + ); +} + +const SearchWrapper = styled.div` + width: 500px; + + .ant-input { + border-radius: 16px !important; + background: rgba(30, 61, 228, 0.04); + border: none !important; + padding-right: 41px; + } + + .ant-input-group { + display: flex; + } + + .ant-input-group-addon { + background: transparent !important; + left: -38px !important; + z-index: 80; + + .ant-btn { + background: transparent !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; + line-height: 1 !important; + box-shadow: none !important; + + &:after { + display: none !important; + } + + .anticon { + font-size: 18px; + margin-bottom: 3px; + } + } + } +`; diff --git a/src/app/containers/Profile/index.tsx b/src/app/containers/Profile/index.tsx new file mode 100644 index 000000000..7e80cdef5 --- /dev/null +++ b/src/app/containers/Profile/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { TabsTablePanel } from 'app/components/TabsTablePanel/Loadable'; +import { useTranslation } from 'react-i18next'; +import { translations } from 'locales/i18n'; +import styled from 'styled-components/macro'; +import { Helmet } from 'react-helmet-async'; +import { PageHeader } from 'app/components/PageHeader/Loadable'; +import { AddressLabel } from './AddressLabel'; +import { TxNote } from './TxNote'; + +export function Profile() { + const { t } = useTranslation(); + const tabs = [ + { + value: 'address-label', + label: t(translations.profile.address.title), + content: , + }, + { + value: 'tx-note', + label: t(translations.profile.tx.title), + content: , + }, + ]; + return ( + + + {t(translations.profile.title)} + + + + {t(translations.profile.title)} + + + + ); +} + +const StyledWrapper = styled.div` + .button-group-container { + display: flex; + align-items: center; + justify-content: space-between; + } +`; diff --git a/src/app/containers/Transaction/Detail.tsx b/src/app/containers/Transaction/Detail.tsx index 56d961e2c..5b51af41b 100644 --- a/src/app/containers/Transaction/Detail.tsx +++ b/src/app/containers/Transaction/Detail.tsx @@ -41,10 +41,12 @@ import { TokenTypeTag, } from 'app/components/TxnComponents'; import _ from 'lodash'; - +import { LOCALSTORAGE_KEYS_MAP } from 'utils/constants'; import imgChevronDown from 'images/chevronDown.png'; import { renderAddress } from 'utils/tableColumns/token'; import { NFTPreview } from '../../components/NFTPreview/Loadable'; +import { useGlobalData } from 'utils/hooks/useGlobal'; +import { CreateTxNote } from '../Profile/CreateTxNote'; import iconInfo from 'images/info.svg'; @@ -53,6 +55,8 @@ const getStorageFee = byteSize => // Transaction Detail Page export const Detail = () => { + const [visible, setVisible] = useState(false); + const [globalData = {}] = useGlobalData(); const { t, i18n } = useTranslation(); const [isContract, setIsContract] = useState(false); const [transactionDetail, setTransactionDetail] = useState({}); @@ -611,6 +615,22 @@ export const Detail = () => { }, 0), ) : 0; + const txNoteMap = globalData[LOCALSTORAGE_KEYS_MAP.txPrivateNote]; + const txNote = txNoteMap[routeHash]; + + const txNoteProps = { + stage: txNote ? 'edit' : 'create', + visible, + data: { + hash: routeHash, + }, + onOk: () => { + setVisible(false); + }, + onCancel: () => { + setVisible(false); + }, + }; return ( @@ -959,17 +979,49 @@ export const Detail = () => { )} - -
- {t(translations.general[folded ? 'viewMore' : 'showLess'])} -
-
+ +
+ {t(translations.general[folded ? 'viewMore' : 'showLess'])} +
+ + } + > + {' '} +
+ + {t(translations.transaction.note)} + + } + > + { + + } + +
); }; @@ -1062,6 +1114,11 @@ const StyledCardWrapper = styled.div` height: 1.4286rem; margin-left: 0.5714rem; } + + .tx-note { + font-style: italic; + margin-right: 10px; + } `; const StyledEpochConfirmationsWrapper = styled.span` @@ -1081,6 +1138,7 @@ const StyledFoldButtonWrapper = styled.div` font-size: 1rem; color: #002257; cursor: pointer; + padding: 0; &::after { content: ''; diff --git a/src/app/index.tsx b/src/app/index.tsx index 7134a9f71..e23d10e93 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -123,6 +123,8 @@ import { Transactions as posTransactions } from './containers/pos/Transactions/L import { Transaction as posTransaction } from './containers/pos/Transaction/Loadable'; import { IncomingRank as posIncomingRank } from './containers/pos/IncomingRank/Loadable'; +import { Profile } from './containers/Profile/Loadable'; + import enUS from '@cfxjs/antd/lib/locale/en_US'; import zhCN from '@cfxjs/antd/lib/locale/zh_CN'; import moment from 'moment'; @@ -190,6 +192,53 @@ export function App() { return props.children; } + useEffect(() => { + const key = LOCALSTORAGE_KEYS_MAP.addressLabel; + const keyTx = LOCALSTORAGE_KEYS_MAP.txPrivateNote; + const data = globalData || {}; + + // address label + if (!data[key]) { + let dStr = localStorage.getItem(key); + let d = {}; + + if (dStr) { + d = JSON.parse(dStr).reduce((prev, curr) => { + return { + ...prev, + [curr.a]: curr.l, + }; + }, {}); + } + + setGlobalData({ + ...globalData, + [key]: d, + }); + } + + // private tx note + if (!data[keyTx]) { + let dStrTx = localStorage.getItem(keyTx); + let dTx = {}; + + if (dStrTx) { + dTx = JSON.parse(dStrTx).reduce((prev, curr) => { + return { + ...prev, + [curr.h]: curr.n, + }; + }, {}); + } + + setGlobalData({ + ...globalData, + [keyTx]: dTx, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [globalData]); + const ScrollToTop = withRouter(_ScrollToTop); useEffect(() => { @@ -810,6 +859,8 @@ export function App() { component={ContractsCharts} /> + + diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8e3e884d2..ac0a18d41 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1,4 +1,41 @@ { + "profile": { + "title": "Profile", + "subtitle": "Private info, like private name tag and transaction private note. Decentralized storage in browser's localstorage.", + "tip": { + "label": "Private name tag", + "note": "Private note to keep track of the transaction." + }, + "tx": { + "title": "Transaction Private Notes", + "hash": "Tx Hash", + "note": "Note", + "search": "Please input tx hash or note", + "error": { + "invalidHash": "Please input valid tx hash", + "invalidNote": "Please input valid note", + "invalidNoteRange": "Please input 1-{{amount}} characters", + "hash": "Please input tx hash", + "note": "Please input note", + "duplicated": "Duplicated hash, already in the note list" + } + }, + "address": { + "title": "Private Name Tags", + "address": "Address", + "label": "Name Tags", + "search": "Please input address or name tags", + "error": { + "invalidAddress": "Please input valid base32 address", + "invalidNetwork": "Please input {{network}} address", + "invalidLabel": "Please input valid name tags", + "invalidLabelRange": "Please input 1-{{amount}} characters", + "address": "Please input address", + "label": "Please input name tags", + "duplicated": "Duplicated address, already in the name tags list" + } + } + }, "metadata": { "title": "Conflux BlockChain Explorer", "description": "BlockChain Explorer for Conflux Network" @@ -424,7 +461,8 @@ "transactions": "Transactions", "committee": "Committee", "incomingRank": "Incoming Rank" - } + }, + "profile": "My Profile" }, "footer": { "currency": { @@ -880,6 +918,15 @@ } }, "general": { + "timestamp": "Timestamp", + "create": "Create", + "edit": "Edit", + "delete": "Delete", + "buttonCancel": "Cancel", + "buttonOk": "Confirm", + "warning": "Warning", + "deleteTip": "Do you Want to delete these records?", + "exceedTip": "The max records is limited to 1000, please delete some of them first.", "networks": { "mainnet": "mainnet", "testnet": "testnet", @@ -974,7 +1021,9 @@ "website": "Official Website", "balanceChecker": "Balance Checker", "verifyContract": "Verify Contract", - "NFTChecker": "NFT Checker" + "NFTChecker": "NFT Checker", + "addLabel": "Add Name Tag", + "updateLabel": "Update Name Tag" } }, "loading": "loading...", @@ -1271,6 +1320,9 @@ "byFoundation": " (by Foundation)" }, "transaction": { + "note": "Private Note", + "addNote": "Add Private Note", + "updateNote": "Update Private Note", "pendingReasonLink": "View Reason", "tipOfTokenTransferCount": "Show up to 100 records", "gotoDetail": "Click to view details", diff --git a/src/locales/zh_cn/translation.json b/src/locales/zh_cn/translation.json index 12c213f74..97eb07dcc 100644 --- a/src/locales/zh_cn/translation.json +++ b/src/locales/zh_cn/translation.json @@ -1,4 +1,41 @@ { + "profile": { + "title": "用户", + "subtitle": "私人信息,例如地址标签,交易备注等。去中心化存储,保存在浏览器本地存储中。", + "tip": { + "label": "地址标签", + "note": "交易备注用于记录您写入的信息" + }, + "tx": { + "title": "交易备注", + "hash": "交易哈希", + "note": "备注", + "search": "请输入交易哈希或备注", + "error": { + "invalidHash": "请输入合法的交易哈希", + "invalidNote": "请输入合法的备注", + "invalidNoteRange": "请输入 1-{{amount}} 个字符", + "hash": "请输入交易哈希", + "note": "请输入备注", + "duplicated": "重复的交易哈希,已经存在于记录中" + } + }, + "address": { + "title": "地址标签", + "address": "地址", + "label": "标签", + "search": "请输入地址或标签", + "error": { + "invalidAddress": "请输入合法地址", + "invalidNetwork": "请输入{{network}}地址", + "invalidLabel": "请输入合法的标签", + "invalidLabelRange": "请输入 1-{{amount}} 个字符", + "address": "请输入地址", + "label": "请输入标签", + "duplicated": "重复的地址,已经存在于记录中" + } + } + }, "metadata": { "title": "树图区块链浏览器", "description": "树图区块链浏览器" @@ -424,7 +461,8 @@ "transactions": "交易", "committee": "委员会", "incomingRank": "收益排行" - } + }, + "profile": "用户信息" }, "footer": { "currency": { @@ -880,6 +918,15 @@ } }, "general": { + "timestamp": "时间戳", + "create": "创建", + "edit": "编辑", + "delete": "删除", + "buttonCancel": "取消", + "buttonOk": "确认", + "warning": "提示", + "deleteTip": "你确定要删除这些记录吗?", + "exceedTip": "最大存储记录条数是 1000,请先删除部分记录。", "networks": { "mainnet": "主网", "testnet": "测试网", @@ -973,7 +1020,9 @@ "website": "官方网站", "balanceChecker": "余额检索器", "verifyContract": "验证合约", - "NFTChecker": "数字藏品查看器" + "NFTChecker": "数字藏品查看器", + "addLabel": "添加标签", + "updateLabel": "更新标签" } }, "loading": "加载中...", @@ -1269,6 +1318,9 @@ "byFoundation": "(来自基金会)" }, "transaction": { + "note": "交易备注", + "addNote": "添加交易备注", + "updateNote": "更新交易备注", "pendingReasonLink": "查看原因", "tipOfTokenTransferCount": "最多展示 100 条记录", "gotoDetail": "查看更多", diff --git a/src/setupProxy.js b/src/setupProxy.js index 7418097fb..393e0500f 100644 --- a/src/setupProxy.js +++ b/src/setupProxy.js @@ -12,8 +12,8 @@ let rpcv2 = `${url}/rpcv2`; let confluxDag = `${url}`; if (process.env.REACT_APP_TestNet === 'true') { - const testnet = 'https://testnet-stage.confluxscan.net'; - // const testnet = 'https://testnet.confluxscan.net/'; + // const testnet = 'https://testnet-stage.confluxscan.net'; + const testnet = 'https://testnet.confluxscan.net/'; stat = `${testnet}`; v1 = `${testnet}`; rpc = `${testnet}/rpc`; diff --git a/src/styles/global-styles.ts b/src/styles/global-styles.ts index a2afb2e15..f88c515de 100644 --- a/src/styles/global-styles.ts +++ b/src/styles/global-styles.ts @@ -309,6 +309,10 @@ export const GlobalStyle = createGlobalStyle` background-color: var(--theme-color-blue0); color: #ffffff; + + } + + .ant-btn { &:hover { background: #4665f0; color: #ffffff; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b37aecdfb..aa30e3ffa 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -53,6 +53,8 @@ export enum LOCALSTORAGE_KEYS_MAP { cookieAgreed = 'CONFLUXSCAN_COOKIE_AGREED', txnRecords = 'CONFLUXSCAN_TXN_RECORDS', fccfxNotice = 'CONFLUX_SCAN_FCCFX_NOTICE', + addressLabel = 'CONFLUX_SCAN_ADDRESS_LABEL', + txPrivateNote = 'CONFLUX_SCAN_TX_PRIVATE_NOTE', } export const NETWORK_ID = (() => { diff --git a/src/utils/gaConstants.ts b/src/utils/gaConstants.ts index 75e964350..b4afea787 100644 --- a/src/utils/gaConstants.ts +++ b/src/utils/gaConstants.ts @@ -65,6 +65,7 @@ export const ScanEvent = { incomingRank: 'incomingRank', posBlocks: 'posBlocks', posTransactions: 'posTransactions', + profile: 'profile', }, }, // global search diff --git a/src/utils/tableColumns/token.tsx b/src/utils/tableColumns/token.tsx index de90a1fae..5bb8873c2 100644 --- a/src/utils/tableColumns/token.tsx +++ b/src/utils/tableColumns/token.tsx @@ -454,7 +454,14 @@ export const contract = (isFull = false) => ({ } else if (row.verified === true) { verify = true; } - return ; + return ( + + ); }, });