diff --git a/.eslintrc.js b/.eslintrc.js index 64024af2f..bb5682409 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -84,6 +84,7 @@ module.exports = { prop: 'ignore', }, ], + 'react/jsx-pascal-case': 'off', 'no-console': ['error', { allow: ['error'] }], 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', diff --git a/.stylelintrc.js b/.stylelintrc.js index ce97e1937..75d144025 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -6,7 +6,7 @@ module.exports = { rules: { 'no-empty-source': null, // Based on the discussion in https://github.com/Magickbase/ckb-explorer-public-issues/issues/442, we have decided to use lower camel case. - 'selector-class-pattern': "^[a-z][a-zA-Z0-9]+$", + 'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', 'selector-id-pattern': null, 'custom-property-pattern': null, // This rule provides little benefit relative to the cost of implementing it, so it has been disabled. @@ -27,6 +27,14 @@ module.exports = { extends: ['stylelint-config-standard-scss'], rules: { 'scss/dollar-variable-pattern': null, + 'scss/at-rule-no-unknown': [ + true, + { + // This syntax is specified by the CSS module specification and implemented by the css-loader. + // Refs: https://github.com/css-modules/css-modules/blob/master/docs/values-variables.md + ignoreAtRules: ['value'], + }, + ], }, }, { diff --git a/config-overrides.js b/config-overrides.js index c3493bddc..0e41dc659 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -1,6 +1,7 @@ /* config-overrides.js */ const SentryWebpackPlugin = require('@sentry/webpack-plugin') const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin') +const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent') module.exports = function override(config) { if (config.ignoreWarnings == null) { @@ -31,5 +32,46 @@ module.exports = function override(config) { ) } + // https://dhanrajsp.me/snippets/customize-css-loader-options-in-nextjs + const oneOf = config.module.rules.find(rule => typeof rule.oneOf === 'object') + if (oneOf) { + const moduleSassRule = oneOf.oneOf.find(rule => regexEqual(rule.test, /\.module\.(scss|sass)$/)) + if (moduleSassRule) { + // Get the config object for css-loader plugin + const cssLoader = moduleSassRule.use.find(({ loader }) => loader?.includes('css-loader')) + if (cssLoader) { + cssLoader.options = { + ...cssLoader.options, + modules: { + ...cssLoader.options.modules, + // By default, `CRA` uses `node_modules\react-dev-utils\getCSSModuleLocalIdent` as `getLocalIdent` passed to `css-loader`, + // which generates class names with base64 suffixes. + // However, the `@value` syntax of CSS modules does not execute `escapeLocalIdent` when replacing corresponding class names in actual files. + // Therefore, if a class name's base64 hash contains `+` and is also imported into another file using `@value`, + // the selector after applying the `@value` syntax will be incorrect. + // For example, `.CompA_main__KW\+Cg` will become `.CompA_main__KW+Cg` when imported into another file. + // This may not be a bug but a feature, because `@value` is probably not designed specifically for importing selectors from other files. + // So here, we add a `+` handling based on the logic of escapeLocalIdent. + getLocalIdent: (...args) => getCSSModuleLocalIdent(...args).replaceAll('+', '-'), + }, + } + } + } + } + return config } + +/** + * Stolen from https://stackoverflow.com/questions/10776600/testing-for-equality-of-regular-expressions + */ +function regexEqual(x, y) { + return ( + x instanceof RegExp && + y instanceof RegExp && + x.source === y.source && + x.global === y.global && + x.ignoreCase === y.ignoreCase && + x.multiline === y.multiline + ) +} diff --git a/package.json b/package.json index 39db54605..a3dc93d99 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@storybook/addon-essentials": "7.5.3", "@storybook/addon-interactions": "7.5.3", "@storybook/addon-links": "7.5.3", - "@storybook/addon-onboarding": "1.0.8", + "@storybook/addon-onboarding": "1.0.9", "@storybook/addon-storysource": "^7.5.3", "@storybook/blocks": "7.5.3", "@storybook/preset-create-react-app": "7.5.3", @@ -52,7 +52,7 @@ "@types/echarts": "4.9.22", "@types/eslint": "8.44.8", "@types/jest": "26.0.24", - "@types/node": "16.18.66", + "@types/node": "16.18.68", "@types/react": "17.0.65", "@types/react-dom": "17.0.20", "@types/react-outside-click-handler": "^1.3.0", @@ -125,5 +125,8 @@ "*.{ts,tsx}": "eslint --cache --fix", "*.{ts,tsx,json,html,scss}": "prettier --write", "*.{scss,css,tsx}": "stylelint --fix" + }, + "peerDependencies": { + "react-dev-utils": "*" } } diff --git a/src/App.tsx b/src/App.tsx index 234d60573..034354809 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { DefaultTheme, ThemeProvider } from 'styled-components' import Routers from './routes' import Toast from './components/Toast' import { isMainnet } from './utils/chain' -import { DASQueryContextProvider } from './contexts/providers/dasQuery' +import { DASQueryContextProvider } from './hooks/useDASAccount' import { getPrimaryColor, getSecondaryColor } from './constants/common' const appStyle = { diff --git a/src/components/AddressText/index.stories.tsx b/src/components/AddressText/index.stories.tsx index 1252c3d60..b959dcfda 100644 --- a/src/components/AddressText/index.stories.tsx +++ b/src/components/AddressText/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react' import AddressText from '.' import styles from './index.stories.module.scss' -import { useForkedState } from '../../utils/hook' +import { useForkedState } from '../../hooks' const meta = { component: AddressText, diff --git a/src/components/AddressText/index.tsx b/src/components/AddressText/index.tsx index e2f5d4a43..d33ac6d44 100644 --- a/src/components/AddressText/index.tsx +++ b/src/components/AddressText/index.tsx @@ -2,7 +2,7 @@ import { Tooltip } from 'antd' import { ComponentProps, FC } from 'react' import classNames from 'classnames' import { Link, LinkProps } from 'react-router-dom' -import { useBoolean } from '../../utils/hook' +import { useBoolean } from '../../hooks' import EllipsisMiddle from '../EllipsisMiddle' import CopyTooltipText from '../Text/CopyTooltipText' import styles from './styles.module.scss' diff --git a/src/components/AddressText/styles.module.scss b/src/components/AddressText/styles.module.scss index 9e56b2ae3..a77aaf816 100644 --- a/src/components/AddressText/styles.module.scss +++ b/src/components/AddressText/styles.module.scss @@ -1,4 +1,9 @@ .link { flex: 1; min-width: 0; + color: var(--primary-color); + + &:hover { + color: var(--primary-color); + } } diff --git a/src/components/Card/Card.module.scss b/src/components/Card/Card.module.scss new file mode 100644 index 000000000..a68dcaddf --- /dev/null +++ b/src/components/Card/Card.module.scss @@ -0,0 +1,27 @@ +.card { + width: 100%; + height: auto; + display: flex; + flex-direction: column; + justify-content: flex-start; + padding: 0 40px; + background: #fff; + box-shadow: 0 4px 4px 0 rgb(16 16 16 / 5%); + + &.rounded { + border-radius: 4px; + } + + &.roundedTop { + border-radius: 4px 4px 0 0; + } + + &.roundedBottom { + border-radius: 0 0 4px 4px; + } + + @media (width <= 750px) { + padding: 0 16px; + box-shadow: 1px 1px 3px 0 #dfdfdf; + } +} diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx new file mode 100644 index 000000000..3c291027b --- /dev/null +++ b/src/components/Card/Card.tsx @@ -0,0 +1,26 @@ +import { ComponentProps, FC } from 'react' +import classNames from 'classnames' +import styles from './Card.module.scss' + +export interface CardProps extends ComponentProps<'div'> { + rounded?: boolean | 'top' | 'bottom' +} + +export const Card: FC = ({ children, rounded = true, ...elProps }) => { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/Card/CardCell.module.scss b/src/components/Card/CardCell.module.scss new file mode 100644 index 000000000..c336af9a2 --- /dev/null +++ b/src/components/Card/CardCell.module.scss @@ -0,0 +1,51 @@ +.cardCell { + display: flex; + align-items: center; + justify-content: space-between; + height: 20px; + + .left { + display: flex; + align-items: center; + flex-shrink: 0; + + .icon { + width: 48px; + height: 48px; + } + + .icon + .title { + margin-left: 8px; + } + + .title { + display: flex; + align-items: center; + font-size: 16px; + color: rgb(0 0 0 / 60%); + } + } + + .right { + margin-left: 15px; + display: flex; + align-items: center; + font-size: 16px; + color: #000; + min-width: 0; + } + + @media (width <= 750px) { + flex-direction: column; + justify-content: initial; + align-items: initial; + height: auto; + padding: 16px 0; + + .right { + margin-left: 0; + word-wrap: break-word; + word-break: break-all; + } + } +} diff --git a/src/components/Card/CardCell.tsx b/src/components/Card/CardCell.tsx new file mode 100644 index 000000000..a23243d43 --- /dev/null +++ b/src/components/Card/CardCell.tsx @@ -0,0 +1,39 @@ +import { ComponentProps, FC, ReactNode } from 'react' +import { TooltipProps } from 'antd' +import classNames from 'classnames' +import styles from './CardCell.module.scss' +import { HelpTip } from '../HelpTip' + +export interface CardCellProps extends Omit, 'title' | 'content'> { + icon?: ReactNode + title: ReactNode + tooltip?: TooltipProps['title'] + content: ReactNode + contentWrapperClass?: string + contentTooltip?: TooltipProps['title'] +} + +export const CardCell: FC = ({ + icon, + title, + tooltip, + content, + contentWrapperClass, + contentTooltip, + ...divProps +}) => { + return ( +
+
+ {icon &&
{icon}
} +
{title}
+ {tooltip && } +
+ +
+ {content} + {contentTooltip && } +
+
+ ) +} diff --git a/src/components/Card/CardCellsLayout.module.scss b/src/components/Card/CardCellsLayout.module.scss new file mode 100644 index 000000000..378a2e9ca --- /dev/null +++ b/src/components/Card/CardCellsLayout.module.scss @@ -0,0 +1,108 @@ +@value cardCell from "./CardCell.module.scss"; + +.cardCellsLayout { + display: flex; + justify-content: space-between; + padding: 20px 0; + + &.borderTop { + border-top: 1px solid #e5e5e5; + } + + .left, + .leftSingle { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + gap: 20px; + } + + .leftSingle { + align-self: center; + + .cardCell { + height: auto; + } + } + + .right { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + margin-left: 40px; + border-left: 1px solid #e5e5e5; + padding-left: 40px; + gap: 20px; + } + + .list { + display: flex; + flex-direction: column; + gap: 20px; + } + + .expand { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 0 16px; + color: var(--primary-color); + + .isExpanded { + transform: rotate(180deg); + } + } + + @media (width <= 1200px) { + flex-direction: column; + gap: 20px; + + .leftSingle { + align-self: auto; + } + + .right { + margin-left: 0; + border-left: 0; + padding-left: 0; + } + } + + @media (width <= 750px) { + gap: 0; + padding: 0; + + &.borderTop { + border-top: 0; + } + + .left { + gap: 0; + } + + .right { + gap: 0; + } + + .list { + gap: 0; + } + + .cardCell { + border-top: 1px solid #e5e5e5; + } + + &:not(.borderTop) { + .left, + .list { + .cardCell { + &:first-child { + border-top: 0; + } + } + } + } + } +} diff --git a/src/components/Card/CardCellsLayout.tsx b/src/components/Card/CardCellsLayout.tsx new file mode 100644 index 000000000..f7535ebe0 --- /dev/null +++ b/src/components/Card/CardCellsLayout.tsx @@ -0,0 +1,148 @@ +// This seems to be an unexpected inspection prompt. It may be a bug in the inspection logic. Disable it temporarily. +/* eslint-disable react/no-unused-prop-types */ +import { ComponentProps, FC, ReactElement, isValidElement, useMemo } from 'react' +import classNames from 'classnames' +import { CardCell, CardCellProps } from './CardCell' +import styles from './CardCellsLayout.module.scss' +import { useBoolean, useIsMobile } from '../../hooks' +import { ReactComponent as DownArrowIcon } from './down_arrow.svg' + +type LayoutType = 'left-right' | 'leftSingle-right' | 'list' +type LayoutSlot = 'left' | 'right' | 'item' + +type CardCellInfo$WithoutSlot = ReactElement | CardCellProps +type CardCellInfo$WithSlot = { slot: S; cell: CardCellInfo$WithoutSlot } +export type CardCellInfo = CardCellInfo$WithSlot | CardCellInfo$WithoutSlot + +function isCardCellInfoWithSlot(info: CardCellInfo): info is CardCellInfo$WithSlot { + return typeof info === 'object' && info != null && 'slot' in info +} + +function renderCell(info: CardCellInfo$WithoutSlot) { + if (isValidElement(info)) return info + return +} + +interface CardCellsLayoutProps$Common { + defaultDisplayCountInMobile?: number + borderTop?: boolean +} + +interface CardCellsLayoutProps$LeftRight extends CardCellsLayoutProps$Common { + type: 'left-right' + cells: CardCellInfo<'left' | 'right'>[] +} + +interface CardCellsLayoutProps$LeftSingleRight extends CardCellsLayoutProps$Common { + type: 'leftSingle-right' + cells: CardCellInfo<'left' | 'right'>[] +} + +interface CardCellsLayoutProps$List extends CardCellsLayoutProps$Common { + type: 'list' + cells: CardCellInfo<'item'>[] +} + +type CardCellsLayoutProps = ComponentProps<'div'> & + (CardCellsLayoutProps$LeftRight | CardCellsLayoutProps$LeftSingleRight | CardCellsLayoutProps$List) + +const CardCellsLayout$LeftRightOrLeftSingleRight: FC< + { displayCount: number } & (CardCellsLayoutProps$LeftRight | CardCellsLayoutProps$LeftSingleRight) +> = ({ type, cells, displayCount }) => { + const { leftCells, rightCells } = useMemo(() => { + const leftCells: CardCellInfo$WithoutSlot[] = [] + const rightCells: CardCellInfo$WithoutSlot[] = [] + + let currentSlot: LayoutSlot | null = null + for (const info of cells) { + const infoWithSlot: CardCellInfo$WithSlot = isCardCellInfoWithSlot(info) + ? info + : { slot: getNextSlot(type, currentSlot), cell: info } + + const { slot, cell } = infoWithSlot + const container = slot === 'left' ? leftCells : rightCells + container.push(cell) + currentSlot = slot + } + + if (leftCells.length >= displayCount) { + leftCells.splice(displayCount) + rightCells.splice(0) + } else if (leftCells.length + rightCells.length > displayCount) { + rightCells.splice(displayCount - leftCells.length) + } + + return { leftCells, rightCells } + + function getNextSlot(layout: LayoutType, slot?: LayoutSlot | null): LayoutSlot { + if (layout === 'leftSingle-right') return 'right' + + switch (slot) { + case 'left': + return 'right' + case 'right': + return 'left' + default: + return 'left' + } + } + }, [cells, displayCount, type]) + + return ( + <> + {type === 'left-right' ? ( +
{leftCells.map(renderCell)}
+ ) : ( +
{renderCell(leftCells[0])}
+ )} + +
{rightCells.map(renderCell)}
+ + ) +} + +const CardCellsLayout$List: FC<{ displayCount: number } & CardCellsLayoutProps$List> = ({ cells, displayCount }) => { + const finalCells = useMemo( + () => cells.slice(0, displayCount).map(info => (isCardCellInfoWithSlot(info) ? info.cell : info)), + [cells, displayCount], + ) + + return
{finalCells.map(renderCell)}
+} + +export const CardCellsLayout: FC = ({ + type, + cells, + defaultDisplayCountInMobile = 10, + borderTop, + ...elProps +}) => { + const isMobile = useIsMobile() + const showExpandCtl = isMobile && cells.length > defaultDisplayCountInMobile + const [isExpanded, expandCtl] = useBoolean(false) + const displayCount = isMobile && !isExpanded ? defaultDisplayCountInMobile : Infinity + + return ( +
+ {(type === 'left-right' || type === 'leftSingle-right') && ( + + )} + {type === 'list' && } + + {showExpandCtl && ( +
expandCtl.toggle()} role="button" tabIndex={0}> + +
+ )} +
+ ) +} diff --git a/src/components/Card/CardHeader.module.scss b/src/components/Card/CardHeader.module.scss new file mode 100644 index 000000000..cfa6d234f --- /dev/null +++ b/src/components/Card/CardHeader.module.scss @@ -0,0 +1,25 @@ +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + + .left, + .right { + display: flex; + align-items: center; + } + + .left { + font-size: 20px; + font-weight: 600; + color: #333; + } + + @media (width <= 750px) { + flex-direction: column; + justify-content: initial; + align-items: initial; + gap: 16px; + } +} diff --git a/src/components/Card/CardHeader.tsx b/src/components/Card/CardHeader.tsx new file mode 100644 index 000000000..44c23813c --- /dev/null +++ b/src/components/Card/CardHeader.tsx @@ -0,0 +1,25 @@ +import { ComponentProps, FC, ReactNode } from 'react' +import classNames from 'classnames' +import styles from './CardHeader.module.scss' + +interface CardHeaderProps extends ComponentProps<'div'> { + leftContent?: ReactNode + rightContent?: ReactNode + leftProps?: ComponentProps<'div'> + rightProps?: ComponentProps<'div'> +} + +export const CardHeader: FC = ({ leftContent, rightContent, leftProps, rightProps, ...elProps }) => { + return ( +
+
+ {leftContent} +
+ {(rightProps || rightContent) && ( +
+ {rightContent} +
+ )} +
+ ) +} diff --git a/src/components/Card/HashCard/copy.png b/src/components/Card/HashCard/copy.png deleted file mode 100644 index 6ed450a18..000000000 Binary files a/src/components/Card/HashCard/copy.png and /dev/null differ diff --git a/src/components/Card/HashCard/download_tx.svg b/src/components/Card/HashCard/download_tx.svg deleted file mode 100644 index aa513fe55..000000000 --- a/src/components/Card/HashCard/download_tx.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/components/Card/HashCard/index.tsx b/src/components/Card/HashCard/index.tsx deleted file mode 100644 index 60482486c..000000000 --- a/src/components/Card/HashCard/index.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import type { FC, ReactNode } from 'react' -import { Link } from 'react-router-dom' -import { Radio, Tooltip } from 'antd' -import { useTranslation } from 'react-i18next' -import { LayoutLiteProfessional } from '../../../constants/common' -import CopyIcon from './copy.png' -import { explorerService } from '../../../services/ExplorerService' -import SmallLoading from '../../Loading/SmallLoading' -import { useIsMobile, useNewAddr, useDeprecatedAddr, useSearchParams, useUpdateSearchParams } from '../../../utils/hook' -import SimpleButton from '../../SimpleButton' -import { ReactComponent as OpenInNew } from '../../../assets/open_in_new.svg' -import { ReactComponent as DownloadIcon } from './download_tx.svg' -import { HashCardPanel, LoadingPanel } from './styled' -import styles from './styles.module.scss' -import AddressText from '../../AddressText' -import { useDASAccount } from '../../../contexts/providers/dasQuery' -import { useSetToast } from '../../Toast' - -const DASInfo: FC<{ address: string }> = ({ address }) => { - const alias = useDASAccount(address) - - if (alias == null) return null - - return ( - - - {alias} - {alias} - - - ) -} - -export default ({ - title, - hash, - loading, - specialAddress = '', - iconUri, - children, - showDASInfoOnHeader, -}: { - title: string - hash: string - loading?: boolean - specialAddress?: string - iconUri?: string - children?: ReactNode - showDASInfoOnHeader?: boolean | string -}) => { - const isMobile = useIsMobile() - const { Professional, Lite } = LayoutLiteProfessional - const setToast = useSetToast() - const { t } = useTranslation() - - const isTx = t('transaction.transaction') === title - const newAddr = useNewAddr(hash) - const deprecatedAddr = useDeprecatedAddr(hash) - const counterpartAddr = newAddr === hash ? deprecatedAddr : newAddr - - const searchParams = useSearchParams('layout') - const defaultLayout = Professional - const updateSearchParams = useUpdateSearchParams<'layout'>() - const layout = searchParams.layout === Lite ? Lite : defaultLayout - - const onChangeLayout = (layoutType: LayoutLiteProfessional) => { - updateSearchParams(params => - layoutType === defaultLayout - ? Object.fromEntries(Object.entries(params).filter(entry => entry[0] !== 'layout')) - : { ...params, layout: layoutType }, - ) - } - - const handleExportTxClick = async () => { - const raw = await explorerService.api.fetchTransactionRaw(hash).catch(error => { - setToast({ message: error.message }) - }) - if (typeof raw !== 'object') return - - const blob = new Blob([JSON.stringify(raw, null, 2)]) - - const link = document.createElement('a') - link.download = `tx-${hash}.json` - link.href = URL.createObjectURL(blob) - document.body.append(link) - link.click() - link.remove() - } - - return ( - -
- {iconUri && isMobile ? ( -
- hash icon -
{title}
-
- ) : ( - <> - {iconUri && hash icon} -
{title}
- - )} -
-
- {loading ? ( - - - - ) : ( -
- - {hash} - -
- )} - { - navigator.clipboard.writeText(hash) - setToast({ message: t('common.copied') }) - }} - > - {!loading && copy} - - {counterpartAddr ? ( - - - - - - ) : null} - {isTx ? ( - - - - ) : null} -
- - {!isMobile && isTx && !loading ? ( -
- onChangeLayout(value)} - value={layout} - optionType="button" - buttonStyle="solid" - /> -
- ) : null} - - {(showDASInfoOnHeader || showDASInfoOnHeader === '') && ( - - )} -
- {specialAddress && ( - - - {t('address.vesting')} - - - )} -
- {hash} -
-
- - {isMobile && isTx && !loading ? ( -
- onChangeLayout(value)} - value={layout} - optionType="button" - buttonStyle="solid" - /> -
- ) : null} - - {children} -
- ) -} diff --git a/src/components/Card/HashCard/styled.tsx b/src/components/Card/HashCard/styled.tsx deleted file mode 100644 index 6cd290bfb..000000000 --- a/src/components/Card/HashCard/styled.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import styled from 'styled-components' - -export const HashCardPanel = styled.div` - width: 100%; - height: auto; - display: flex; - flex-direction: column; - justify-content: flex-start; - box-shadow: 2px 2px 6px 0 #dfdfdf; - border-radius: 6px; - background-color: #fff; - padding: 0 40px; - margin-bottom: 18px; - - @media (max-width: 750px) { - padding: 0 16px; - box-shadow: 1px 1px 3px 0 #dfdfdf; - } - - .hashCardContentPanel { - width: 100%; - height: 80px; - display: flex; - flex-direction: row; - align-items: center; - overflow: hidden; - position: relative; - - @media (max-width: 750px) { - height: auto; - flex-direction: ${(props: { isColumn: boolean }) => (props.isColumn ? 'column' : 'row')}; - align-items: ${(props: { isColumn: boolean }) => (props.isColumn ? 'flex-start' : 'center')}; - padding: 12px 0; - } - } - - .hashIcon { - width: 40px; - height: 40px; - margin-right: 8px; - } - - .hashTitle { - font-size: 24px; - font-weight: 600; - color: #000; - white-space: nowrap; - - @media (max-width: 750px) { - font-size: 20px; - } - } - - .hashCardHashContent { - display: flex; - flex-direction: row; - align-items: center; - flex: 1; - min-width: 0; - } - - #hash__text { - min-width: 0; - margin-left: 20px; - font-size: 18px; - color: #000; - transform: translateY(3px); - - @media (max-width: 750px) { - font-size: 13px; - margin-left: ${(props: { isColumn: boolean }) => (props.isColumn ? '0px' : '10px')}; - font-weight: 500; - transform: translateY(1px); - } - } - - .hashCopyIcon { - cursor: pointer; - margin-left: 20px; - display: flex; - align-items: center; - - @media (max-width: 750px) { - margin-left: 10px; - transform: translateY(3px); - } - - > img { - width: 21px; - height: 24px; - - @media (max-width: 750px) { - width: 16px; - height: 18px; - margin-bottom: 3px; - } - } - } - - #hash__value { - color: #fff; - position: absolute; - bottom: -30px; - } - - .hashVesting { - color: ${props => props.theme.primary}; - margin-left: 12px; - margin-top: 6px; - - &:hover { - color: ${props => props.theme.primary}; - } - - @media (max-width: 750px) { - margin-top: 3px; - margin-left: 6px; - } - } -` - -export const LoadingPanel = styled.div` - width: 100%; -` diff --git a/src/components/Card/HashCard/styles.module.scss b/src/components/Card/HashCard/styles.module.scss deleted file mode 100644 index c7edcd4ef..000000000 --- a/src/components/Card/HashCard/styles.module.scss +++ /dev/null @@ -1,141 +0,0 @@ -.openInNew { - display: flex; - align-items: center; - cursor: pointer; - height: 30px; - margin-left: 4px; - - svg { - width: 22px; - height: 22px; - pointer-events: none; - - path { - fill: #999; - } - } - - @media screen and (width <= 750px) { - height: 22px; - margin-left: 2px; - - svg { - width: 16px; - height: 16px; - } - } -} - -.exportTx { - display: flex; - align-items: center; - margin-left: 4px; - appearance: none; - color: #9b9b9b; - background: transparent; - border: none; - cursor: pointer; - width: 22px; - height: 22px; - - &:hover { - color: var(--primary-color); - } - - svg { - pointer-events: none; - } -} - -.hashCardHeaderRight { - display: flex; - justify-content: space-between; - flex: 1; - min-width: 0; -} - -.dasAccount { - display: flex; - align-items: center; - background: #fafafa; - border: 1px solid #f0f0f0; - height: 32px; - border-radius: 4px; - padding: 0 4px; - margin-left: 12px; - max-width: 152px; - font-size: 16px; - font-weight: 500; - - @media (width <= 750px) { - max-width: 90px; - font-size: 13px; - } - - img { - width: 20px; - height: 20px; - margin-right: 2px; - border-radius: 50%; - } - - span { - overflow: hidden; - text-overflow: ellipsis; - color: #333; - } -} - -.professionalLiteBox { - margin-left: 10px; - - .layoutButtons { - > label { - height: 40px; - width: 120px; - text-align: center; - font-weight: 400; - font-size: 16px; - line-height: 38px; - color: #333; - border: 1px solid #e5e5e5; - box-shadow: none !important; - - &::before { - content: none !important; - } - - &:hover { - color: #333; - background: #fff; - } - - &:first-child { - border-radius: 4px 0 0 4px; - } - - &:last-child { - border-radius: 0 4px 4px 0; - } - - &:global(.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)) { - background: #333; - border-color: #333 !important; - - &:hover { - background: #333; - } - } - } - - @media screen and (width <= 750px) { - width: 100%; - margin: 6px 0 20px; - - > label { - height: 40px; - width: 50%; - } - } - } -} diff --git a/src/components/Card/HashCardHeader.module.scss b/src/components/Card/HashCardHeader.module.scss new file mode 100644 index 000000000..596d4e089 --- /dev/null +++ b/src/components/Card/HashCardHeader.module.scss @@ -0,0 +1,89 @@ +.hashCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + height: 80px; + + @media (width <= 750px) { + height: 56px; + } + + .left { + display: flex; + align-items: center; + height: 100%; + flex: 1; + min-width: 0; + + .title { + font-size: 24px; + font-weight: 600; + color: #000; + white-space: nowrap; + + @media (width <= 750px) { + font-size: 20px; + } + } + + .hash { + min-width: 0; + font-size: 18px; + color: #000; + + @media (width <= 750px) { + transform: translateY(2px); + } + + &.small { + font-size: 13px; + font-weight: 500; + } + } + + .title + .hash { + margin-left: 20px; + + @media (width <= 750px) { + margin-left: 8px; + } + } + + .actions { + display: flex; + align-items: center; + gap: 16px; + margin-left: 32px; + color: #999; + + @media (width <= 750px) { + gap: 12px; + margin-left: 8px; + } + + .action { + cursor: pointer; + display: flex; + align-items: center; + width: 24px; + height: 24px; + + &:hover { + color: var(--primary-color); + } + + @media (width <= 750px) { + width: 16px; + height: 16px; + } + } + } + } +} + +.copyAction { + display: flex; + align-items: center; + width: 100%; + height: 100%; +} diff --git a/src/components/Card/HashCardHeader.tsx b/src/components/Card/HashCardHeader.tsx new file mode 100644 index 000000000..d9b48f7b9 --- /dev/null +++ b/src/components/Card/HashCardHeader.tsx @@ -0,0 +1,65 @@ +import { ComponentProps, FC, ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import classNames from 'classnames' +import styles from './HashCardHeader.module.scss' +import { ReactComponent as CopyIcon } from './copy.svg' +import AddressText from '../AddressText' +import { useIsMobile } from '../../hooks' +import SimpleButton from '../SimpleButton' +import { useSetToast } from '../Toast' + +interface HashCardHeaderProps extends Omit, 'title'> { + title: ReactNode + hash: string + customActions?: ReactNode[] + rightContent?: ReactNode +} + +export const HashCardHeader: FC = ({ title, hash, customActions, rightContent, ...elProps }) => { + const { t } = useTranslation() + const isMobile = useIsMobile() + const setToast = useSetToast() + + // TODO: SimpleButton may not be necessary. + const copyAction = ( + { + navigator.clipboard.writeText(hash) + setToast({ message: t('common.copied') }) + }} + > + + + ) + + const actions: ReactNode[] = [hash && copyAction, ...(customActions ?? [])] + + const hashFontClass = isMobile ? styles.small : '' + + return ( +
+
+ {title &&
{title}
} + + {/* `hash__text` may be a historical legacy unused ID. */} +
+ + {hash} + +
+ +
+ {actions.map((action, idx) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {action} +
+ ))} +
+
+ + {rightContent} +
+ ) +} diff --git a/src/components/Card/ItemCard/index.tsx b/src/components/Card/ItemCard/index.tsx deleted file mode 100644 index c50f29dd2..000000000 --- a/src/components/Card/ItemCard/index.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ReactElement, ReactNode } from 'react' -import { ItemCardPanel, ItemContentPanel, ItemDetailPanel } from './styled' - -export interface ItemCardData { - title: string - render: (data: T, index: number) => ReactNode -} - -export function ItemCard({ - items, - data, - children, - className, -}: { - items: ItemCardData[] - data: T - children?: ReactNode - className?: string -}): ReactElement { - return ( - - - {items.map((item, index) => ( - -
{item.title}
-
{item.render(data, index)}
-
- ))} -
- {children} -
- ) -} - -export function ItemCardGroup({ - items, - dataSource, - getDataKey, - className, - cardClassName, -}: { - items: ItemCardData[] - dataSource: T[] - getDataKey: (data: T, index: number) => string | number - className?: string - cardClassName?: string -}): ReactElement { - return ( -
- {dataSource.map((data, index) => ( - - ))} -
- ) -} diff --git a/src/components/Card/ItemCard/styled.tsx b/src/components/Card/ItemCard/styled.tsx deleted file mode 100644 index 43a836b93..000000000 --- a/src/components/Card/ItemCard/styled.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import styled from 'styled-components' - -export const ItemCardPanel = styled.div` - width: 100%; - background-color: #fff; - color: #333; - font-size: 16px; - margin-top: 4px; - border-radius: 4px; - box-shadow: 1px 1px 3px 0 #dfdfdf; - padding: 0 16px; -` - -export const ItemContentPanel = styled.div` - display: flex; - flex: 1; - flex-direction: column; - width: 100%; - margin-right: 0; -` - -export const ItemDetailPanel = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - width: 100%; - position: relative; - padding: 16px 0; - border-bottom: solid #f0f0f0; - border-bottom-width: ${({ hideLine }: { hideLine: boolean }) => (hideLine ? '0' : '1px')}; - - .itemDetailTitle { - color: #666; - width: 100%; - margin-left: 0; - line-height: 1; - } - - .itemDetailValue { - display: flex; - width: 100%; - margin-left: 0; - margin-top: 8px; - line-height: 1; - word-wrap: break-word; - word-break: break-all; - - a { - color: ${props => props.theme.primary}; - } - - a:hover { - color: ${props => props.theme.primary}; - } - } - - .blockPointer { - cursor: pointer; - } -` diff --git a/src/components/Card/OverviewCard/index.tsx b/src/components/Card/OverviewCard/index.tsx deleted file mode 100644 index 7022b48de..000000000 --- a/src/components/Card/OverviewCard/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { isValidElement, ReactNode } from 'react' -import { TooltipProps } from 'antd' -import classNames from 'classnames' -import { OverviewCardPanel, OverviewContentPanel, OverviewItemPanel } from './styled' -import { useIsLGScreen, useIsMobile } from '../../../utils/hook' -import { HelpTip } from '../../HelpTip' - -export type OverviewItemData = { - title: ReactNode - content: ReactNode - contentWrapperClass?: string - filled?: boolean - icon?: string - isAsset?: boolean - tooltip?: TooltipProps['title'] - valueTooltip?: string -} | null - -const handleOverviewItems = (items: OverviewItemData[], isMobile: boolean) => ({ - leftItems: isMobile ? items : items.filter((_item, index) => index % 2 === 0), - rightItems: isMobile ? [] : items.filter((_item, index) => index % 2 !== 0), -}) - -export const OverviewItem = ({ item, hideLine }: { item: OverviewItemData; hideLine: boolean }) => - item ? ( - -
- {item.icon && ( -
- item icon -
- )} -
- {item.title} - {item.tooltip && } -
-
-
- {isValidElement(item.content) ? item.content : {item.content}} - {item.valueTooltip && } -
-
- ) : null - -export default ({ - items, - children, - titleCard, - hideShadow, -}: { - items: OverviewItemData[] - children?: ReactNode - titleCard?: ReactNode - hideShadow?: boolean -}) => { - const isMobile = useIsMobile() - const isLG = useIsLGScreen() - /* eslint-disable react/no-array-index-key */ - const { leftItems, rightItems } = handleOverviewItems(items, isMobile) - return ( - - {titleCard} -
- -
- {leftItems.map((item, index) => - item ? ( - - ) : null, - )} -
- {!isLG && } -
- {rightItems.map((item, index) => ( - - ))} -
-
- {children} - - ) -} diff --git a/src/components/Card/OverviewCard/styled.tsx b/src/components/Card/OverviewCard/styled.tsx deleted file mode 100644 index bf024be01..000000000 --- a/src/components/Card/OverviewCard/styled.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import styled, { css } from 'styled-components' - -export const OverviewCardPanel = styled.div` - width: 100%; - background-color: #fff; - color: #000; - font-size: 16px; - margin-bottom: 16px; - border-radius: 6px; - box-shadow: 2px 2px 6px 0 #dfdfdf; - padding: 15px 40px; - - ${(props: { hideShadow?: boolean }) => - props.hideShadow && - css` - margin-top: 0; - margin-bottom: 20px; - border-radius: 0; - box-shadow: 0 0 0 0 #dfdfdf; - padding: 0; - `}; - - .overviewSeparate { - background: #eaeaea; - width: 100%; - height: 1px; - transform: ${() => `scaleY(${Math.ceil((1.0 / window.devicePixelRatio) * 10.0) / 10.0})`}; - } - - @media (max-width: 1000px) { - font-size: 13px; - } - - @media (max-width: 750px) { - font-size: 13px; - box-shadow: 1px 1px 3px 0 #dfdfdf; - padding: 8px 20px; - ${(props: { hideShadow?: boolean }) => - props.hideShadow && - css` - border-radius: 0; - box-shadow: 0 0 0 0 #dfdfdf; - padding: 0; - `}; - } -` - -export const OverviewContentPanel = styled.div` - display: flex; - flex-direction: row; - align-items: flex-start; - - @media (max-width: 1200px) { - flex-direction: column; - } - - > span { - width: 1px; - height: ${({ length }: { length: number }) => `${length * 32}px`}; - background: #e2e2e2; - margin: 14px 0 0; - transform: ${() => `scaleX(${Math.ceil((1.0 / window.devicePixelRatio) * 10.0) / 10.0})`}; - - @media (max-width: 750px) { - display: none; - } - } - - .overviewContentLeftItems { - margin-right: 45px; - display: flex; - flex: 1; - min-width: 0; - flex-direction: column; - - @media (max-width: 1200px) { - width: 100%; - margin-right: 0; - } - } - - .overviewContentRightItems { - margin-left: 45px; - display: flex; - flex: 1; - min-width: 0; - flex-direction: column; - - @media (max-width: 1200px) { - width: 100%; - margin-left: 0; - } - } -` - -export const OverviewItemPanel = styled.div` - display: flex; - width: 100%; - align-items: center; - justify-content: space-between; - position: relative; - - @media (min-width: 750px) { - height: 20px; - margin-top: 14px; - margin-bottom: ${({ hasIcon }: { hasIcon: boolean }) => (hasIcon ? '16px' : '0px')}; - } - - @media (min-width: 1200px) { - height: 20px; - margin-top: 14px; - } - - @media (max-width: 750px) { - margin-top: 12px; - justify-content: normal; - flex-direction: column; - align-items: flex-start; - - &::after { - content: ''; - background: #e2e2e2; - height: 1px; - width: 100%; - display: ${({ hideLine }: { hideLine: boolean; hasIcon: boolean; isAsset?: boolean }) => - hideLine ? 'none' : 'block'}; - margin: 10px 0 0; - transform: ${() => `scaleY(${Math.ceil((1.0 / window.devicePixelRatio) * 10.0) / 10.0})`}; - } - } - - .overviewItemTitlePanel { - display: flex; - align-items: center; - flex-shrink: 0; - - @media (max-width: 1200px) { - margin-left: ${({ hasIcon, isAsset }: { hasIcon: boolean; isAsset?: boolean }) => - !hasIcon && isAsset ? '60px' : '0px'}; - } - - @media (max-width: 750px) { - margin-left: 0; - } - } - - .overviewItemIcon { - width: 48px; - height: 48px; - margin-right: 10px; - - @media (max-width: 750px) { - margin-bottom: 10px; - } - - > img { - width: 48px; - height: 48px; - } - } - - .overviewItemTitle { - display: flex; - align-items: center; - font-size: 16px; - color: rgb(0 0 0 / 60%); - margin-left: 0; - font-weight: ${({ hasIcon }: { hasIcon: boolean }) => (hasIcon ? '600' : '400')}; - - @media (max-width: 750px) { - width: 100%; - margin-left: 0; - } - - > img { - width: 15px; - height: 15px; - margin-left: 5px; - } - } - - .overviewItemValue { - margin-left: 15px; - display: flex; - align-items: center; - font-size: 16px; - color: #000; - min-width: 0; - - &.filled { - flex: 1; - flex-direction: row-reverse; - } - - @media (max-width: 750px) { - margin-left: 0; - word-wrap: break-word; - word-break: break-all; - width: 100%; - - &.filled { - flex-direction: row; - } - } - - > img { - width: 15px; - height: 15px; - margin-left: 5px; - margin-top: 2px; - } - - svg { - cursor: pointer; - } - - a { - color: ${props => props.theme.primary}; - } - - a:hover { - color: ${props => props.theme.primary}; - } - } - - .blockPointer { - cursor: pointer; - } -` diff --git a/src/components/Card/TitleCard/index.tsx b/src/components/Card/TitleCard/index.tsx deleted file mode 100644 index 926cb6a9e..000000000 --- a/src/components/Card/TitleCard/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import classNames from 'classnames' -import { ReactNode } from 'react' -import { TitleCardPanel } from './styled' - -export default ({ - title, - isSingle, - className, - rear, - rearClassName, -}: { - title: ReactNode - isSingle?: boolean - className?: string - rear?: ReactNode - rearClassName?: string -}) => ( - -
{title}
- {rear ?
{rear}
: null} -
-) diff --git a/src/components/Card/TitleCard/styled.tsx b/src/components/Card/TitleCard/styled.tsx deleted file mode 100644 index 13aa1efa8..000000000 --- a/src/components/Card/TitleCard/styled.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import styled from 'styled-components' - -interface TitleCardPanelProps { - isSingle?: boolean - hasRear?: boolean -} - -export const TitleCardPanel = styled.div` - width: 100%; - background-color: #fff; - height: ${(props: TitleCardPanelProps) => (props.isSingle ? '58px' : '50px')}; - padding: ${(props: TitleCardPanelProps) => (props.isSingle ? '0 40px' : '0')}; - display: flex; - flex-direction: row; - justify-content: ${(props: TitleCardPanelProps) => (props.hasRear ? 'space-between' : 'flex-start')}; - align-items: ${(props: TitleCardPanelProps) => (props.isSingle ? 'center' : 'flex-start')}; - border-radius: ${(props: TitleCardPanelProps) => (props.isSingle ? '6px 6px 0 0' : '0')}; - box-shadow: ${(props: TitleCardPanelProps) => (props.isSingle ? '2px 2px 6px 0 #dfdfdf' : '0')}; - - @media (max-width: 750px) { - height: ${(props: TitleCardPanelProps) => (props.isSingle ? '58px' : '40px')}; - padding-left: ${(props: TitleCardPanelProps) => (props.isSingle ? '20px' : '0')}; - } - - .titleCardContent { - color: #000; - font-size: 20px; - font-weight: 600; - margin-bottom: ${(props: TitleCardPanelProps) => (props.isSingle ? '0px' : '12px')}; - - @media (max-width: 750px) { - font-size: 20px; - margin-bottom: 8px; - } - } - - .titleCardRear { - display: flex; - flex-direction: row; - align-items: center; - - @media (max-width: 750px) { - flex-direction: column-reverse; - - > div:first-child { - padding: 16px 0 0; - justify-content: flex-end; - } - } - } -` diff --git a/src/components/Card/copy.svg b/src/components/Card/copy.svg new file mode 100644 index 000000000..14abb52fb --- /dev/null +++ b/src/components/Card/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Card/down_arrow.svg b/src/components/Card/down_arrow.svg new file mode 100644 index 000000000..e481291ff --- /dev/null +++ b/src/components/Card/down_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Card/index.ts b/src/components/Card/index.ts new file mode 100644 index 000000000..6592a64d1 --- /dev/null +++ b/src/components/Card/index.ts @@ -0,0 +1,4 @@ +export * from './Card' +export * from './CardCell' +export * from './CardCellsLayout' +export * from './HashCardHeader' diff --git a/src/components/CardList/index.module.scss b/src/components/CardList/index.module.scss new file mode 100644 index 000000000..b2a061359 --- /dev/null +++ b/src/components/CardList/index.module.scss @@ -0,0 +1,5 @@ +.cardList { + display: flex; + gap: 10px; + flex-direction: column; +} diff --git a/src/components/CardList/index.tsx b/src/components/CardList/index.tsx new file mode 100644 index 000000000..b61271b69 --- /dev/null +++ b/src/components/CardList/index.tsx @@ -0,0 +1,49 @@ +import { ComponentProps, ReactNode } from 'react' +import classNames from 'classnames' +import { Card, CardCellsLayout, CardProps } from '../Card' +import styles from './index.module.scss' + +interface CardListProps extends ComponentProps<'div'> { + dataSource: T[] + cardContentRender: (data: T) => ReactNode + getDataKey?: (data: T, index: number) => string | number + cardProps?: CardProps +} + +export function CardList({ dataSource, cardContentRender, getDataKey, cardProps, ...elProps }: CardListProps) { + return ( +
+ {dataSource.map((data, index) => ( + + {cardContentRender(data)} + + ))} +
+ ) +} + +export interface CardCellFactory { + title: ReactNode | ((data: T, index: number) => ReactNode) + content: ReactNode | ((data: T, index: number) => ReactNode) +} + +interface CardListWithCellsListProps extends Omit, 'cardContentRender'> { + cells: CardCellFactory[] +} + +export function CardListWithCellsList({ cells, ...props }: CardListWithCellsListProps) { + return ( + ( + ({ + title: typeof title === 'function' ? title(data, i) : title, + content: typeof content === 'function' ? content(data, i) : content, + }))} + /> + )} + /> + ) +} diff --git a/src/components/DecimalCapacity/styled.tsx b/src/components/DecimalCapacity/styled.tsx index abc4054bc..77c1f7432 100644 --- a/src/components/DecimalCapacity/styled.tsx +++ b/src/components/DecimalCapacity/styled.tsx @@ -2,17 +2,9 @@ import styled from 'styled-components' export const DecimalPanel = styled.div` display: flex; - justify-content: flex-end; + justify-content: flex-start; align-items: flex-end; - .subtraction { - color: var(--accent-color); - } - - .addition { - color: var(--primary-color); - } - .decimalUnit { margin-left: 5px; diff --git a/src/components/DecimalCapacity/styles.module.scss b/src/components/DecimalCapacity/styles.module.scss index 47c1cd795..7ba2c99d2 100644 --- a/src/components/DecimalCapacity/styles.module.scss +++ b/src/components/DecimalCapacity/styles.module.scss @@ -1,3 +1,7 @@ .integerPart { font-size: 16px; + + @media (width <= 1000px) { + font-size: 12px; + } } diff --git a/src/components/EllipsisMiddle/index.stories.tsx b/src/components/EllipsisMiddle/index.stories.tsx index 13e95dc9a..ce0d96f7f 100644 --- a/src/components/EllipsisMiddle/index.stories.tsx +++ b/src/components/EllipsisMiddle/index.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import EllipsisMiddle from '.' -import { useForkedState } from '../../utils/hook' +import { useForkedState } from '../../hooks' const meta = { component: EllipsisMiddle, diff --git a/src/components/EllipsisMiddle/index.tsx b/src/components/EllipsisMiddle/index.tsx index 0bb243f6e..fe4a9f412 100644 --- a/src/components/EllipsisMiddle/index.tsx +++ b/src/components/EllipsisMiddle/index.tsx @@ -9,8 +9,9 @@ const EllipsisMiddle: FC< children?: string /** When this item is not empty, ignore the value of `children`. */ text?: string + // TODO: Perhaps there are certain methods to automatically check the current font and optimize the fontKey accordingly. /** Any key that represents the use of a different font. */ - fontKey?: string | number | boolean + fontKey?: unknown minStartLen?: number minEndLen?: number onTruncateStateChange?: (isTruncated: boolean) => void diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index 814eac3ba..ad3c49c0e 100644 --- a/src/components/Footer/index.tsx +++ b/src/components/Footer/index.tsx @@ -9,7 +9,7 @@ import { ReactComponent as ForumIcon } from './footer_forum.svg' import { ReactComponent as Discord } from './footer_discord.svg' import { getCurrentYear } from '../../utils/date' import { FooterMenuPanel, FooterItemPanel, FooterImageItemPanel, FooterPanel } from './styled' -import { useIsMobile } from '../../utils/hook' +import { useIsMobile } from '../../hooks' import { udtSubmitEmail } from '../../utils/util' interface FooterLinkItem { diff --git a/src/components/Header/BlockchainComp/index.tsx b/src/components/Header/BlockchainComp/index.tsx index 7a6d3d069..0830f1cd4 100644 --- a/src/components/Header/BlockchainComp/index.tsx +++ b/src/components/Header/BlockchainComp/index.tsx @@ -7,7 +7,7 @@ import GreenDropUpIcon from '../../../assets/green_drop_up.png' import { HeaderBlockchainPanel, MobileSubMenuPanel } from './styled' import SimpleButton from '../../SimpleButton' import ChainDropdown from '../../Dropdown/ChainType' -import { useIsMobile } from '../../../utils/hook' +import { useIsMobile } from '../../../hooks' import { ChainName, MAINNET_URL, ONE_DAY_MILLISECOND, TESTNET_URL } from '../../../constants/common' import { explorerService } from '../../../services/ExplorerService' import { cacheService } from '../../../services/CacheService' diff --git a/src/components/MaintainAlert/index.tsx b/src/components/Header/MaintainAlert/index.tsx similarity index 83% rename from src/components/MaintainAlert/index.tsx rename to src/components/Header/MaintainAlert/index.tsx index 3cead3e90..a6b9a59f1 100644 --- a/src/components/MaintainAlert/index.tsx +++ b/src/components/Header/MaintainAlert/index.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { IS_MAINTAINING } from '../../constants/common' +import { IS_MAINTAINING } from '../../../constants/common' import styles from './styles.module.scss' const MaintainAlert = () => { diff --git a/src/components/MaintainAlert/styles.module.scss b/src/components/Header/MaintainAlert/styles.module.scss similarity index 100% rename from src/components/MaintainAlert/styles.module.scss rename to src/components/Header/MaintainAlert/styles.module.scss diff --git a/src/components/Header/MenusComp/index.tsx b/src/components/Header/MenusComp/index.tsx index be4a4b6b2..c8a317427 100644 --- a/src/components/Header/MenusComp/index.tsx +++ b/src/components/Header/MenusComp/index.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useIsMobile } from '../../../utils/hook' +import { useIsMobile } from '../../../hooks' import { MobileMenuItem, MobileMenuLink, HeaderMenuPanel } from './styled' import { isMainnet } from '../../../utils/chain' diff --git a/src/components/Sheet/index.tsx b/src/components/Header/Sheet/index.tsx similarity index 90% rename from src/components/Sheet/index.tsx rename to src/components/Header/Sheet/index.tsx index 3644a61a2..022c8fae4 100644 --- a/src/components/Sheet/index.tsx +++ b/src/components/Header/Sheet/index.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { useMemo } from 'react' import { SheetPanel, SheetPointPanel, SheetItem } from './styled' -import { useBlockchainAlerts, useNetworkErrMsgs } from '../../services/ExplorerService' +import { useBlockchainAlerts, useNetworkErrMsgs } from '../../../services/ExplorerService' const Sheet = () => { const { t } = useTranslation() diff --git a/src/components/Sheet/styled.tsx b/src/components/Header/Sheet/styled.tsx similarity index 100% rename from src/components/Sheet/styled.tsx rename to src/components/Header/Sheet/styled.tsx diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index fc4e40155..f1e025dd5 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -8,10 +8,10 @@ import MenusComp from './MenusComp' import { SearchComp } from './SearchComp' import { LanguageDropdown } from './LanguageComp' import BlockchainComp from './BlockchainComp' -import { useElementSize, useIsMobile } from '../../utils/hook' +import { useElementSize, useIsMobile } from '../../hooks' import styles from './index.module.scss' -import MaintainAlert from '../MaintainAlert' -import Sheet from '../Sheet' +import MaintainAlert from './MaintainAlert' +import Sheet from './Sheet' import { createGlobalState, useGlobalState } from '../../utils/state' import MobileMenu from './MobileMenu' diff --git a/src/components/HelpTip/index.tsx b/src/components/HelpTip/index.tsx index 6cef23ed9..b8e2afff5 100644 --- a/src/components/HelpTip/index.tsx +++ b/src/components/HelpTip/index.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipProps } from 'antd' import classNames from 'classnames' import HelpIcon from '../../assets/qa_help.png' import styles from './index.module.scss' -import { useIsMobile } from '../../utils/hook' +import { useIsMobile } from '../../hooks' export const HelpTip: FC< TooltipProps & { diff --git a/src/components/Pagination/index.tsx b/src/components/Pagination/index.tsx index 39b2ac84d..5274a559f 100644 --- a/src/components/Pagination/index.tsx +++ b/src/components/Pagination/index.tsx @@ -5,7 +5,7 @@ import LeftBlack from './pagination_black_left.png' import RightBlack from './pagination_black_right.png' import LeftGrey from './pagination_grey_left.png' import RightGrey from './pagination_grey_right.png' -import { useIsMobile } from '../../utils/hook' +import { useIsMobile } from '../../hooks' import SimpleButton from '../SimpleButton' import { HelpTip } from '../HelpTip' diff --git a/src/components/Error/index.tsx b/src/components/QueryResult/Error/index.tsx similarity index 88% rename from src/components/Error/index.tsx rename to src/components/QueryResult/Error/index.tsx index 7c5986a0e..7651fc6d4 100644 --- a/src/components/Error/index.tsx +++ b/src/components/QueryResult/Error/index.tsx @@ -1,6 +1,6 @@ import PCDataNotFoundImage from './pc_data_not_found.png' import MobileDataNotFoundImage from './mobile_data_not_found.png' -import { useIsMobile } from '../../utils/hook' +import { useIsMobile } from '../../../hooks' import { ErrorPanel } from './styled' export default () => { diff --git a/src/components/Error/mobile_data_not_found.png b/src/components/QueryResult/Error/mobile_data_not_found.png similarity index 100% rename from src/components/Error/mobile_data_not_found.png rename to src/components/QueryResult/Error/mobile_data_not_found.png diff --git a/src/components/Error/pc_data_not_found.png b/src/components/QueryResult/Error/pc_data_not_found.png similarity index 100% rename from src/components/Error/pc_data_not_found.png rename to src/components/QueryResult/Error/pc_data_not_found.png diff --git a/src/components/Error/styled.tsx b/src/components/QueryResult/Error/styled.tsx similarity index 100% rename from src/components/Error/styled.tsx rename to src/components/QueryResult/Error/styled.tsx diff --git a/src/components/QueryResult/index.tsx b/src/components/QueryResult/index.tsx index d2c079695..32729451b 100644 --- a/src/components/QueryResult/index.tsx +++ b/src/components/QueryResult/index.tsx @@ -1,9 +1,9 @@ import { ReactElement } from 'react' import { DefinedUseQueryResult } from '@tanstack/react-query' import { LOADING_WAITING_TIME } from '../../constants/common' -import Error from '../Error' +import Error from './Error' import Loading from '../Loading' -import { useDelayLoading } from '../../utils/hook' +import { useDelayLoading } from '../../hooks' export function QueryResult({ query, diff --git a/src/components/Search/Filter/styled.tsx b/src/components/Search/Filter/styled.tsx index a66b67719..efc214a67 100644 --- a/src/components/Search/Filter/styled.tsx +++ b/src/components/Search/Filter/styled.tsx @@ -3,7 +3,7 @@ import SimpleButton from '../../SimpleButton' export const FilterPanel = styled.div` width: 600px; - height: 38px; + height: 40px; text-align: center; display: flex; align-items: center; @@ -18,7 +18,7 @@ export const FilterPanel = styled.div` } @media (max-width: 750px) { - width: 80vw; + width: 100%; } ` diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3bb8c3540..9802ac3af 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -7,7 +7,7 @@ import SearchLogo from '../../assets/search_black.png' import ClearLogo from '../../assets/clear.png' import { addPrefixForHash, containSpecialChar } from '../../utils/string' import { HttpErrorCode, SearchFailType } from '../../constants/common' -import { useIsMobile } from '../../utils/hook' +import { useIsMobile } from '../../hooks' import { isChainTypeError } from '../../utils/chain' import { isAxiosError } from '../../utils/error' // TODO: Refactor is needed. Should not directly import anything from the descendants of ExplorerService. diff --git a/src/components/SortButton/index.tsx b/src/components/SortButton/index.tsx index fde14a6f6..edd3835db 100644 --- a/src/components/SortButton/index.tsx +++ b/src/components/SortButton/index.tsx @@ -1,6 +1,6 @@ import { ReactComponent as SortIcon } from '../../assets/sort_icon.svg' import styles from './styles.module.scss' -import { useSortParam } from '../../utils/hook' +import { useSortParam } from '../../hooks' /* * REFACTOR: could be refactored for https://github.com/Magickbase/ckb-explorer-frontend/pull/8#discussion_r1267484265 diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index 1df0011c8..ea132d619 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useReducer, useCallback } from 'react' -import { useTimeoutWithUnmount } from '../../utils/hook' +import { useTimeoutWithUnmount } from '../../hooks' import { ToastItemPanel, ToastPanel } from './styled' import { createGlobalState, useGlobalState } from '../../utils/state' diff --git a/src/components/TransactionItem/TransactionIncome/index.tsx b/src/components/TransactionItem/TransactionIncome/index.tsx index eba38aa33..83c1dabc9 100644 --- a/src/components/TransactionItem/TransactionIncome/index.tsx +++ b/src/components/TransactionItem/TransactionIncome/index.tsx @@ -5,7 +5,7 @@ import { TransactionIncomePanel, TransactionCapacityValuePanel } from './styled' import { shannonToCkb } from '../../../utils/util' import { localeNumberString } from '../../../utils/number' import DecimalCapacity from '../../DecimalCapacity' -import { useIsMobile } from '../../../utils/hook' +import { useIsMobile } from '../../../hooks' import CurrentAddressIcon from '../../../assets/current_address.svg' export default ({ income }: { income: string }) => { diff --git a/src/components/TransactionItem/TransactionItemCell/index.tsx b/src/components/TransactionItem/TransactionItemCell/index.tsx index 15eac0623..10f0112b2 100644 --- a/src/components/TransactionItem/TransactionItemCell/index.tsx +++ b/src/components/TransactionItem/TransactionItemCell/index.tsx @@ -24,9 +24,9 @@ import DecimalCapacity from '../../DecimalCapacity' import { parseDiffDate } from '../../../utils/date' import Cellbase from '../../Transaction/Cellbase' import styles from './index.module.scss' -import { useDASAccount } from '../../../contexts/providers/dasQuery' +import { useDASAccount } from '../../../hooks/useDASAccount' import { ReactComponent as BitAccountIcon } from '../../../assets/bit_account.svg' -import { useBoolean, useIsMobile } from '../../../utils/hook' +import { useBoolean, useIsMobile } from '../../../hooks' import CopyTooltipText from '../../Text/CopyTooltipText' import EllipsisMiddle from '../../EllipsisMiddle' import { Cell, Cell$UDT, UDTInfo } from '../../../models/Cell' diff --git a/src/components/TransactionItem/TransactionLiteItem/index.tsx b/src/components/TransactionItem/TransactionLiteItem/index.tsx index 9227ead3f..2169bce25 100644 --- a/src/components/TransactionItem/TransactionLiteItem/index.tsx +++ b/src/components/TransactionItem/TransactionLiteItem/index.tsx @@ -4,7 +4,7 @@ import { localeNumberString } from '../../../utils/number' import AddressText from '../../AddressText' import styles from './index.module.scss' import TransactionLiteIncome from '../TransactionLiteIncome' -import { useIsMobile, useParsedDate } from '../../../utils/hook' +import { useIsMobile, useParsedDate } from '../../../hooks' import { Transaction } from '../../../models/Transaction' const TransactionLiteItem = ({ transaction, address }: { transaction: Transaction; address?: string }) => { diff --git a/src/components/TransactionItem/index.tsx b/src/components/TransactionItem/index.tsx index 6448d9a5b..2112f0201 100644 --- a/src/components/TransactionItem/index.tsx +++ b/src/components/TransactionItem/index.tsx @@ -9,7 +9,7 @@ import TransactionIncome from './TransactionIncome' import { FullPanel, TransactionHashBlockPanel, TransactionCellPanel, TransactionPanel } from './styled' import { CellType } from '../../constants/common' import AddressText from '../AddressText' -import { useIsLGScreen, useParsedDate } from '../../utils/hook' +import { useIsLGScreen, useParsedDate } from '../../hooks' import { Transaction } from '../../models/Transaction' export interface CircleCorner { diff --git a/src/hooks/browser.ts b/src/hooks/browser.ts new file mode 100644 index 000000000..f8632c512 --- /dev/null +++ b/src/hooks/browser.ts @@ -0,0 +1,161 @@ +/** + * The file implements hooks related to the browser, + * such as BOM (Browser Object Model), DOM (Document Object Model), Style, and others. + */ +import { useEffect, useState, RefObject, useCallback, useRef } from 'react' +import { useResizeDetector } from 'react-resize-detector' +import { startEndEllipsis } from '../utils/string' + +export function useElementIntersecting( + ref: RefObject, + opts: IntersectionObserverInit = {}, + defaultValue = false, +) { + const [isIntersecting, setIntersecting] = useState(defaultValue) + + useEffect(() => { + const el = ref.current + if (!el) return + + const observer = new IntersectionObserver(([entry]) => { + setIntersecting(entry.isIntersecting) + }, opts) + observer.observe(el) + + // eslint-disable-next-line consistent-return + return () => { + observer.unobserve(el) + observer.disconnect() + } + }, [opts, ref]) + + return isIntersecting +} + +export function useElementSize(ref: RefObject) { + const { width: resizedWidth, height: resizedHeight } = useResizeDetector({ + targetRef: ref, + }) + const width = resizedWidth ?? ref.current?.clientWidth ?? null + const height = resizedHeight ?? ref.current?.clientHeight ?? null + return { width, height } +} + +export function useWindowResize(callback: (event: UIEvent) => void) { + useEffect(() => { + window.addEventListener('resize', callback) + return () => window.removeEventListener('resize', callback) + }, [callback]) +} + +export function useWindowSize() { + const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }) + useWindowResize(() => setSize({ width: window.innerWidth, height: window.innerHeight })) + return size +} + +/** + * copied from https://usehooks-ts.com/react-hook/use-media-query + */ +export function useMediaQuery(query: string): boolean { + const getMatches = (query: string): boolean => { + // Prevents SSR issues + if (typeof window !== 'undefined') { + return window.matchMedia(query).matches + } + return false + } + + const [matches, setMatches] = useState(getMatches(query)) + + useEffect(() => { + const matchMedia = window.matchMedia(query) + const handleChange = () => setMatches(getMatches(query)) + + // Triggered at the first client-side load and if query changes + handleChange() + + // Listen matchMedia + if (matchMedia.addListener) { + matchMedia.addListener(handleChange) + } else { + matchMedia.addEventListener('change', handleChange) + } + + return () => { + if (matchMedia.removeListener) { + matchMedia.removeListener(handleChange) + } else { + matchMedia.removeEventListener('change', handleChange) + } + } + }, [query]) + + return matches +} + +export const MOBILE_DEVICE_MAX_WIDTH = 750 +export const useIsMobile = () => useMediaQuery(`(max-width: ${MOBILE_DEVICE_MAX_WIDTH}px)`) +export const useIsLGScreen = (exact = false) => { + const isMobile = useIsMobile() + const isLG = useMediaQuery(`(max-width: 1200px)`) + return !exact ? isLG : isLG && !isMobile +} + +export function useAdaptMobileEllipsis() { + const { width } = useWindowSize() + + const adaptMobileEllipsis = useCallback( + (value: string, length = 8) => { + if (width <= 320) { + return startEndEllipsis(value, length, length) + } + if (width < 500) { + const step = Math.ceil((width - 420) / 15) + return startEndEllipsis(value, length + step, length + step) + } + if (width < 750) { + const step = Math.ceil((width - 500) / 15) + return startEndEllipsis(value, length + step, length + step) + } + return value + }, + [width], + ) + + return adaptMobileEllipsis +} + +export function useAdaptPCEllipsis(factor = 40) { + const { width } = useWindowSize() + const isMobile = width < 750 + const clippedWidth = Math.min(width, 1200) + const step = Math.ceil((clippedWidth - 700) / factor) + + const adaptPCEllipsis = useCallback( + (value: string, length = 8) => { + if (isMobile) return value + return startEndEllipsis(value, length + step, length + step) + }, + [isMobile, step], + ) + + return adaptPCEllipsis +} + +export const useAnimationFrame = (callback: () => void, running: boolean = true) => { + const savedCallback = useRef(callback) + + useEffect(() => { + if (!running) return + + let requestId = 0 + function tick() { + savedCallback.current() + requestId = window.requestAnimationFrame(tick) + } + requestId = window.requestAnimationFrame(tick) + + return () => window.cancelAnimationFrame(requestId) + }, [running]) +} diff --git a/src/hooks/halving.ts b/src/hooks/halving.ts new file mode 100644 index 000000000..99f92478f --- /dev/null +++ b/src/hooks/halving.ts @@ -0,0 +1,141 @@ +import { useEffect, useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { THEORETICAL_EPOCH_TIME, EPOCHS_PER_HALVING } from '../constants/common' +// TODO: This file depends on higher-level abstractions, so it should not be in the utils folder. It should be moved to `src/hooks/index.ts`. +import { useStatistics, explorerService } from '../services/ExplorerService' +import { cacheService } from '../services/CacheService' + +export const useCountdown = (targetDate: Date): [number, number, number, number, number] => { + const countdownDate = new Date(targetDate).getTime() + + const [countdown, setCountdown] = useState(countdownDate - new Date().getTime()) + + useEffect(() => { + const interval = setInterval(() => { + setCountdown(countdownDate - new Date().getTime()) + }, 1000) + + return () => clearInterval(interval) + }, [countdownDate]) + + const expired = countdown <= 0 + const days = expired ? 0 : Math.floor(countdown / (1000 * 60 * 60 * 24)) + const hours = expired ? 0 : Math.floor((countdown % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const minutes = expired ? 0 : Math.floor((countdown % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = expired ? 0 : Math.floor((countdown % (1000 * 60)) / 1000) + + return [days, hours, minutes, seconds, countdown] +} + +export const useCurrentEpochOverTime = (theoretical: boolean) => { + const statistics = useStatistics() + const epochLength = Number(statistics.epochInfo.epochLength) + const epochBlockIndex = Number(statistics.epochInfo.index) + const tipBlockNumber = Number(statistics.tipBlockNumber) + const firstBlockHeight = (tipBlockNumber - epochBlockIndex).toString() + const firstBlock = useQuery(['block', firstBlockHeight], () => explorerService.api.fetchBlock(firstBlockHeight), { + enabled: !theoretical, + }) + const averageBlockTime = useMemo( + () => (new Date().getTime() - (firstBlock.data?.timestamp || 0)) / epochBlockIndex, + [firstBlock.data?.timestamp, epochBlockIndex], + ) + + if (!theoretical) { + if (!firstBlock.data) { + return { + currentEpochUsedTime: 0, + currentEpochEstimatedTime: 0, + averageBlockTime: 0, + isLoading: true, + } + } + // Extrapolate the end time based on how much time has elapsed since the current epoch. + const currentEpochEstimatedTime = (epochLength - epochBlockIndex) * averageBlockTime + + return { + currentEpochUsedTime: new Date().getTime() - firstBlock.data.timestamp, + currentEpochEstimatedTime, + averageBlockTime, + isLoading: statistics.epochInfo.index === '0', + } + } + + const currentEpochUsedTime = (epochBlockIndex / epochLength) * THEORETICAL_EPOCH_TIME + const currentEpochEstimatedTime = THEORETICAL_EPOCH_TIME - currentEpochUsedTime + return { + currentEpochUsedTime, + currentEpochEstimatedTime, + averageBlockTime: THEORETICAL_EPOCH_TIME / epochLength, + isLoading: statistics.epochInfo.index === '0', + } +} + +export const useSingleHalving = (_halvingCount = 1) => { + const halvingCount = Math.max(Math.floor(_halvingCount) || 1, 1) // halvingCount should be a positive integer greater than 1. + const statistics = useStatistics() + const celebrationSkipKey = `having-celebration-#${halvingCount}` + const celebrationSkipped = cacheService.get(celebrationSkipKey) ?? false + function skipCelebration() { + cacheService.set(celebrationSkipKey, true) + } + + const currentEpoch = Number(statistics.epochInfo.epochNumber) + const targetEpoch = EPOCHS_PER_HALVING * halvingCount + const epochLength = Number(statistics.epochInfo.epochLength) + const epochBlockIndex = Number(statistics.epochInfo.index) + + // special handling for last epoch: https://github.com/Magickbase/ckb-explorer-public-issues/issues/483 + const { currentEpochEstimatedTime, currentEpochUsedTime, isLoading } = useCurrentEpochOverTime( + !(currentEpoch === targetEpoch - 1 && epochBlockIndex / epochLength > 0.5), + ) + + const estimatedTime = currentEpochEstimatedTime + THEORETICAL_EPOCH_TIME * (targetEpoch - currentEpoch - 1) + const estimatedDate = useMemo(() => new Date(new Date().getTime() + estimatedTime), [estimatedTime]) + const haveDone = currentEpoch >= targetEpoch + const celebrationOverEpoch = targetEpoch + 30 * 6 // Every 6 epochs is theoretically 1 day. + const inCelebration = haveDone && currentEpoch < celebrationOverEpoch && !celebrationSkipped + + return { + isLoading, + halvingCount, + currentEpoch, + targetEpoch, + inCelebration, + skipCelebration, + currentEpochUsedTime, + estimatedDate, + } +} + +export const useEpochBlockMap = () => { + const statistics = useStatistics() + const currentEpoch = Number(statistics.epochInfo.epochNumber) + const { data: epochStatistic } = useQuery(['fetchStatisticDifficultyUncleRateEpoch', currentEpoch], () => + explorerService.api.fetchStatisticDifficultyUncleRateEpoch(), + ) + + const epochBlockMap = useMemo(() => { + const r = new Map([[0, 0]]) + epochStatistic?.forEach(i => { + const last = r.get(+i.epochNumber) ?? 0 + r.set(+i.epochNumber + 1, +i.epochLength + last) + }) + + return r + }, [epochStatistic]) + + return { + epochBlockMap, + } +} + +export const useHalving = () => { + const statistics = useStatistics() + const currentEpoch = Number(statistics.epochInfo.epochNumber) + const nextHalvingCount = Math.ceil((currentEpoch + 1) / EPOCHS_PER_HALVING) + const nextHalving = useSingleHalving(nextHalvingCount) + const previousHalving = useSingleHalving(nextHalvingCount - 1) + + return previousHalving.inCelebration ? previousHalving : nextHalving +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 000000000..5b7d5d82c --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,220 @@ +import { useEffect, useState, useRef, useMemo, useCallback, Dispatch, SetStateAction } from 'react' +import { + AddressPrefix, + addressToScript, + AddressType, + bech32Address, + parseAddress, + systemScripts, +} from '@nervosnetwork/ckb-sdk-utils' +import { interval, share } from 'rxjs' +import { deprecatedAddrToNewAddr } from '../utils/util' +import { useParseDate } from '../utils/date' + +/** + * Returns the value of the argument from the previous render + * @param {T} value + * @returns {T | undefined} previous value + * @see https://react-hooks-library.vercel.app/core/usePrevious + */ +export function usePrevious(value: T): T | undefined { + const ref = useRef() + + useEffect(() => { + ref.current = value + }, [value]) + + return ref.current +} + +export function useForkedState(basedState: S): [S, Dispatch>] { + const [state, setState] = useState(basedState) + useEffect(() => setState(basedState), [basedState]) + return [state, setState] +} + +export const useInterval = (callback: () => void, delay: number, deps: any[] = []) => { + const savedCallback = useRef(() => {}) + useEffect(() => { + savedCallback.current = callback + }) + useEffect(() => { + const listener = setInterval(savedCallback.current, delay) + return () => clearInterval(listener) + // eslint-disable-next-line + }, [delay, ...deps]) +} + +export const useTimeout = (callback: () => void, delay: number) => { + const savedCallback = useRef(() => {}) + useEffect(() => { + savedCallback.current = callback + }) + useEffect(() => { + const tick = () => { + savedCallback.current() + } + const listener = setTimeout(tick, delay) + return () => clearTimeout(listener) + }, [delay]) +} + +export const useTimeoutWithUnmount = (callback: () => void, clearCallback: () => void, delay: number) => { + const savedCallback = useRef(() => {}) + const savedClearCallback = useRef(() => {}) + useEffect(() => { + savedCallback.current = callback + savedClearCallback.current = clearCallback + }) + useEffect(() => { + const tick = () => { + savedCallback.current() + } + const listener = setTimeout(tick, delay) + return () => { + clearTimeout(listener) + savedClearCallback.current() + } + }, [delay]) +} + +export function useBoolean(initialState: boolean): [ + boolean, + { + on: () => void + off: () => void + toggle: (newState?: boolean) => void + }, +] { + const [state, setState] = useState(initialState) + + const on = useCallback(() => setState(true), []) + const off = useCallback(() => setState(false), []) + const toggle = useCallback(newState => { + setState(oldState => (newState != null ? newState : !oldState)) + }, []) + + return [ + state, + { + on, + off, + toggle, + }, + ] +} + +export function useDelayLoading(delay: number, loading: boolean) { + const [isDelayFinished, delayFinishedCtl] = useBoolean(false) + useTimeout(delayFinishedCtl.on, delay) + return isDelayFinished && loading +} + +export const useNewAddr = (addr: string) => + useMemo(() => { + if (addr.startsWith('0x')) { + return addr + } + try { + const isAddrNew = parseAddress(addr, 'hex').startsWith('0x00') + return isAddrNew ? addr : deprecatedAddrToNewAddr(addr) + } catch { + return addr + } + }, [addr]) + +export const useDeprecatedAddr = (addr: string) => + useMemo(() => { + if (addr.startsWith('0x')) { + return null + } + const BLAKE160_ARGS_LENGTH = 42 + try { + const isMainnet = addr.startsWith('ckb') + const prefix = isMainnet ? AddressPrefix.Mainnet : AddressPrefix.Testnet + const script = addressToScript(addr) + switch (true) { + case script.codeHash === systemScripts.SECP256K1_BLAKE160.codeHash && + script.hashType === systemScripts.SECP256K1_BLAKE160.hashType && + script.args.length === BLAKE160_ARGS_LENGTH: { + return bech32Address(script.args, { + prefix, + type: AddressType.HashIdx, + codeHashOrCodeHashIndex: '0x00', + }) + } + case script.codeHash === systemScripts.SECP256K1_MULTISIG.codeHash && + script.hashType === systemScripts.SECP256K1_MULTISIG.hashType && + script.args.length === BLAKE160_ARGS_LENGTH: { + return bech32Address(script.args, { + prefix, + type: AddressType.HashIdx, + codeHashOrCodeHashIndex: '0x01', + }) + } + case script.codeHash === systemScripts.ANYONE_CAN_PAY_MAINNET.codeHash && + script.hashType === systemScripts.ANYONE_CAN_PAY_MAINNET.hashType && + script.args.length === BLAKE160_ARGS_LENGTH && + isMainnet: { + return bech32Address(script.args, { + prefix, + type: AddressType.HashIdx, + codeHashOrCodeHashIndex: '0x02', + }) + } + case script.codeHash === systemScripts.ANYONE_CAN_PAY_TESTNET.codeHash && + script.hashType === systemScripts.ANYONE_CAN_PAY_TESTNET.hashType && + script.args.length === BLAKE160_ARGS_LENGTH && + !isMainnet: { + return bech32Address(script.args, { + prefix, + type: AddressType.HashIdx, + codeHashOrCodeHashIndex: '0x02', + }) + } + case script.hashType === 'data': { + return bech32Address(script.args, { + prefix, + type: AddressType.DataCodeHash, + codeHashOrCodeHashIndex: script.codeHash, + }) + } + case script.hashType === 'type': { + return bech32Address(script.args, { + prefix, + type: AddressType.TypeCodeHash, + codeHashOrCodeHashIndex: script.codeHash, + }) + } + default: { + return null + } + } + } catch { + return null + } + }, [addr]) + +const secondSignal$ = interval(1000).pipe(share()) + +export function useTimestamp(): number { + const [timestamp, setTimestamp] = useState(Date.now()) + + useEffect(() => { + const sub = secondSignal$.subscribe(() => setTimestamp(Date.now())) + return () => sub.unsubscribe() + }, []) + + return timestamp +} + +export function useParsedDate(timestamp: number): string { + const parseDate = useParseDate() + const now = useTimestamp() + return parseDate(timestamp, now) +} + +export * from './browser' +export * from './route' +export * from './halving' +export * from './useDASAccount' diff --git a/src/hooks/route.ts b/src/hooks/route.ts new file mode 100644 index 000000000..72de13af1 --- /dev/null +++ b/src/hooks/route.ts @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useCallback } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { ListPageParams, PageParams } from '../constants/common' +import { omit } from '../utils/object' + +function getSearchParams(search: string, names?: T[]): Partial> { + const urlSearchParams = new URLSearchParams(search) + const entries = [...urlSearchParams.entries()].filter( + (entry): entry is [T, string] => names == null || (names as string[]).includes(entry[0]), + ) + return Object.fromEntries(entries) as Partial> +} + +export function useSearchParams(...names: T[]): Partial> { + const location = useLocation() + return useMemo(() => getSearchParams(location.search, names), [location.search, names]) +} + +export type OrderByType = 'asc' | 'desc' + +// REFACTOR: remove useSearchParams +export function useSortParam( + isSortBy?: (s?: string) => boolean, + defaultValue?: string, +): { + sortBy: T | undefined + orderBy: OrderByType + sort?: string + handleSortClick: (sortRule?: T) => void +} { + type SortType = T | undefined + function isSortByType(s?: string): s is SortType { + if (!isSortBy) return true + return isSortBy(s) || s === undefined + } + function isOrderByType(s?: string): s is OrderByType { + return s === 'asc' || s === 'desc' + } + const { sort: sortParam = defaultValue } = useSearchParams('sort') + const updateSearchParams = useUpdateSearchParams<'sort' | 'page'>() + let sortBy: SortType + let orderBy: OrderByType = 'asc' + if (sortParam) { + const sortEntry = sortParam.split(',')[0] + const indexOfPoint = sortEntry.indexOf('.') + if (indexOfPoint < 0) { + if (isSortByType(sortEntry)) { + sortBy = sortEntry + } + } else { + const sBy = sortEntry.substring(0, indexOfPoint) + if (isSortByType(sBy)) { + sortBy = sBy + const oBy = sortEntry.substring(indexOfPoint + 1) + if (isOrderByType(oBy)) { + orderBy = oBy + } + } + } + } + const sort = sortBy ? `${sortBy}.${orderBy}` : undefined + + const handleSortClick = (sortRule?: SortType) => { + if (sortBy === sortRule) { + if (orderBy === 'desc') { + updateSearchParams(params => omit({ ...params, sort: `${sortRule}.asc` }, ['page']), true) + } else { + updateSearchParams(params => omit({ ...params, sort: `${sortRule}.desc` }, ['page']), true) + } + } else { + updateSearchParams(params => omit({ ...params, sort: `${sortRule}.desc` }, ['page']), true) + } + } + + return { sortBy, orderBy, sort, handleSortClick } +} + +export function useUpdateSearchParams(): ( + updater: (current: Partial>) => Partial>, + replace?: boolean, +) => void { + const history = useHistory() + const { search, pathname, hash } = useLocation() + + return useCallback( + (updater, replace) => { + const oldParams: Partial> = getSearchParams(search) + const newParams = updater(oldParams) + const newUrlSearchParams = new URLSearchParams(newParams as Record) + newUrlSearchParams.sort() + const newQueryString = newUrlSearchParams.toString() + const to = `${pathname}${newQueryString ? `?${newQueryString}` : ''}${hash}` + + if (replace) { + history.replace(to) + } else { + history.push(to) + } + }, + [hash, history, pathname, search], + ) +} + +export function usePaginationParamsFromSearch(opts: { + defaultPage?: number + maxPage?: number + defaultPageSize?: number + maxPageSize?: number +}) { + const { defaultPage = 1, maxPage = Infinity, defaultPageSize = 10, maxPageSize = 100 } = opts + const updateSearchParams = useUpdateSearchParams<'page' | 'size'>() + const params = useSearchParams('page', 'size') + const currentPage = Number.isNaN(Number(params.page)) ? defaultPage : Number(params.page) + const pageSize = Number.isNaN(Number(params.size)) ? defaultPageSize : Number(params.size) + + useEffect(() => { + const pageSizeOversized = pageSize > maxPageSize + const pageOversized = currentPage > maxPage + if (pageSizeOversized || pageOversized) { + updateSearchParams( + params => ({ + ...params, + page: pageOversized ? maxPage.toString() : params.page, + size: pageSizeOversized ? maxPageSize.toString() : params.size, + }), + true, + ) + } + }, [currentPage, maxPage, maxPageSize, pageSize, updateSearchParams]) + + const setPage = useCallback( + (page: number) => + updateSearchParams(params => ({ + ...params, + page: Math.min(page, maxPage).toString(), + })), + [maxPage, updateSearchParams], + ) + + const setPageSize = useCallback( + (size: number) => + updateSearchParams(params => ({ + ...params, + size: Math.min(size, maxPageSize).toString(), + })), + [maxPageSize, updateSearchParams], + ) + + return { + currentPage: Math.min(currentPage, maxPage), + pageSize: Math.min(pageSize, maxPageSize), + setPage, + setPageSize, + } +} + +// TODO: refactor this hook +export const usePaginationParamsInPage = () => + usePaginationParamsFromSearch({ + defaultPage: PageParams.PageNo, + defaultPageSize: PageParams.PageSize, + maxPageSize: PageParams.MaxPageSize, + }) + +export const usePaginationParamsInListPage = () => + usePaginationParamsFromSearch({ + defaultPage: ListPageParams.PageNo, + defaultPageSize: ListPageParams.PageSize, + maxPageSize: ListPageParams.MaxPageSize, + }) diff --git a/src/contexts/providers/dasQuery.tsx b/src/hooks/useDASAccount.tsx similarity index 79% rename from src/contexts/providers/dasQuery.tsx rename to src/hooks/useDASAccount.tsx index 3e8c2f7f3..ea81931ae 100644 --- a/src/contexts/providers/dasQuery.tsx +++ b/src/hooks/useDASAccount.tsx @@ -1,10 +1,10 @@ import { createContext, FC, useCallback, useContext, useMemo, useRef } from 'react' import { useQuery } from '@tanstack/react-query' -import { explorerService } from '../../services/ExplorerService' -import type { DASAccount, DASAccountMap } from '../../services/ExplorerService/fetcher' -import { unique } from '../../utils/array' -import { throttle } from '../../utils/function' -import { pick } from '../../utils/object' +import { explorerService } from '../services/ExplorerService' +import type { DASAccount, DASAccountMap } from '../services/ExplorerService/fetcher' +import { unique } from '../utils/array' +import { throttle } from '../utils/function' +import { pick } from '../utils/object' export interface DASQueryContextValue { getDASAccounts: (addresses: string[]) => Promise @@ -22,6 +22,10 @@ interface PendingQuery { } } +// Currently, this ContextProvider is placed at the App level, which means that the caching +// and logic for aggregating multiple queries can actually be implemented in ExplorerService instead of relying on the Context. +// However, the current implementation still uses Context because it provides +// the possibility of implementing page-level and component-level accountMap caching in the future. export const DASQueryContextProvider: FC = ({ children }) => { const accountMap = useRef({}) const pendingQueries = useRef([]) diff --git a/src/pages/404/index.tsx b/src/pages/404/index.tsx index ef5f8219e..adceb2411 100644 --- a/src/pages/404/index.tsx +++ b/src/pages/404/index.tsx @@ -2,7 +2,7 @@ import PC404mage from './pc_404.png' import Mobile404Image from './mobile_404.png' import PCBlue404Image from './blue_pc_404.png' import MobileBlue404Image from './blue_mobile_404.png' -import { useIsMobile } from '../../utils/hook' +import { useIsMobile } from '../../hooks' import { isMainnet } from '../../utils/chain' import styles from './index.module.scss' diff --git a/src/pages/Address/AddressComp.tsx b/src/pages/Address/AddressComp.tsx index 3afe3f739..9d871cfb0 100644 --- a/src/pages/Address/AddressComp.tsx +++ b/src/pages/Address/AddressComp.tsx @@ -4,8 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { Radio } from 'antd' import { Base64 } from 'js-base64' import { hexToBytes } from '@nervosnetwork/ckb-sdk-utils' -import { TFunction, useTranslation } from 'react-i18next' -import OverviewCard, { OverviewItemData } from '../../components/Card/OverviewCard' +import { useTranslation } from 'react-i18next' import TransactionItem from '../../components/TransactionItem/index' import { explorerService } from '../../services/ExplorerService' import { parseSporeCellData } from '../../utils/spore' @@ -19,7 +18,6 @@ import { AddressUDTItemPanel, } from './styled' import DecimalCapacity from '../../components/DecimalCapacity' -import TitleCard from '../../components/Card/TitleCard' import CKBTokenIcon from './ckb_token_icon.png' import SUDTTokenIcon from '../../assets/sudt_token.png' import { ReactComponent as TimeDownIcon } from './time_down.svg' @@ -27,13 +25,12 @@ import { ReactComponent as TimeUpIcon } from './time_up.svg' import { sliceNftName } from '../../utils/string' import { OrderByType, - useIsLGScreen, useIsMobile, useNewAddr, usePaginationParamsInListPage, useSearchParams, useUpdateSearchParams, -} from '../../utils/hook' +} from '../../hooks' import styles from './styles.module.scss' import TransactionLiteItem from '../../components/TransactionItem/TransactionLiteItem' import Script from '../../components/Script' @@ -50,49 +47,8 @@ import { CsvExport } from '../../components/CsvExport' import PaginationWithRear from '../../components/PaginationWithRear' import { Transaction } from '../../models/Transaction' import { Address, SUDT, UDTAccount } from '../../models/Address' - -const addressAssetInfo = (address: Address, useMiniStyle: boolean, t: TFunction) => { - const items = [ - { - title: '', - content: '', - }, - { - title: t('address.occupied'), - tooltip: t('glossary.occupied'), - content: , - isAsset: true, - }, - { - icon: CKBTokenIcon, - title: t('common.ckb_unit'), - content: , - }, - { - title: t('address.dao_deposit'), - tooltip: t('glossary.nervos_dao_deposit'), - content: , - isAsset: true, - }, - { - title: '', - content: '', - }, - { - title: t('address.compensation'), - content: , - tooltip: t('glossary.nervos_dao_compensation'), - isAsset: true, - }, - ] as OverviewItemData[] - if (useMiniStyle) { - const item2 = items[2] - items[0] = item2 - items.splice(2, 1) - items.splice(3, 1) - } - return items -} +import { Card, CardCellInfo, CardCellsLayout } from '../../components/Card' +import { CardHeader } from '../../components/Card/CardHeader' const UDT_LABEL: Record = { sudt: 'sudt', @@ -198,9 +154,12 @@ const lockScriptIcon = (show: boolean) => { return isMainnet() ? ArrowDownIcon : ArrowDownBlueIcon } -const useAddressInfo = ({ liveCellsCount, minedBlocksCount, type, addressHash, lockInfo }: Address) => { +const AddressLockScript: FC<{ address: Address }> = ({ address }) => { + const [showLock, setShowLock] = useState(false) const { t } = useTranslation() - const items: OverviewItemData[] = [ + + const { liveCellsCount, minedBlocksCount, type, addressHash, lockInfo } = address + const overviewItems: CardCellInfo<'left' | 'right'>[] = [ { title: t('address.live_cells'), tooltip: t('glossary.live_cells'), @@ -215,12 +174,12 @@ const useAddressInfo = ({ liveCellsCount, minedBlocksCount, type, addressHash, l if (type === 'LockHash') { if (!addressHash) { - items.push({ + overviewItems.push({ title: t('address.address'), content: t('address.unable_decode_address'), }) } else { - items.push({ + overviewItems.push({ title: t('address.address'), contentWrapperClass: styles.addressWidthModify, content: {addressHash}, @@ -229,35 +188,27 @@ const useAddressInfo = ({ liveCellsCount, minedBlocksCount, type, addressHash, l } if (lockInfo && lockInfo.epochNumber !== '0' && lockInfo.estimatedUnlockTime !== '0') { const estimate = Number(lockInfo.estimatedUnlockTime) > new Date().getTime() ? t('address.estimated') : '' - items.push({ + overviewItems.push({ title: t('address.lock_until'), content: `${lockInfo.epochNumber} ${t('address.epoch')} (${estimate} ${parseSimpleDateNoSecond( lockInfo.estimatedUnlockTime, )})`, }) } - return items -} - -const AddressLockScript: FC<{ address: Address }> = ({ address }) => { - const [showLock, setShowLock] = useState(false) - const { t } = useTranslation() return ( - - - setShowLock(!showLock)}> -
{t('address.lock_script')}
- lock script -
- {showLock && address.lockScript &&