diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ae3fd428c..2c66781dd 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -38,7 +38,8 @@ const Search: FC<{ content?: string hasButton?: boolean onEditEnd?: () => void -}> = memo(({ content, hasButton, onEditEnd: handleEditEnd }) => { + onClear?: () => void +}> = memo(({ content, hasButton, onEditEnd: handleEditEnd, onClear: handleClear }) => { const history = useHistory() const queryClient = useQueryClient() const { t } = useTranslation() @@ -118,8 +119,8 @@ const Search: FC<{ const onClear = useCallback(() => { setKeyword('') resetSearchByName() - setEditEnded(true) - }, [resetSearchByName, setEditEnded]) + handleClear?.() + }, [resetSearchByName, handleClear]) return ( diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index ea132d619..a76dba907 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -13,7 +13,7 @@ interface ToastMessage { const getColor = (type: ToastMessage['type']) => { switch (type) { case 'success': - return '#3cc68a' + return 'var(--primary-color)' case 'warning': return '#ffae42' case 'danger': diff --git a/src/locales/en.json b/src/locales/en.json index 8f0219209..f9f3fa182 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -368,6 +368,8 @@ "hash_type": "Hash Type", "overview": "Overview", "user_defined_token": "Simple User Defined Token", + "cell": "Cell", + "cells": "Cells", "inscription": "Inscription", "confirmation": "Confirmation", "confirmations": "Confirmations", diff --git a/src/locales/zh.json b/src/locales/zh.json index a93063b60..6c2a1a6d9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -368,6 +368,8 @@ "hash_type": "Hash Type", "overview": "概览", "user_defined_token": "Simple User Defined Token", + "cell": "Cell", + "cells": "Cells", "inscription": "铭文", "confirmation": "确认区块", "confirmations": "确认区块", diff --git a/src/pages/Address/AddressComp.tsx b/src/pages/Address/AddressComp.tsx index a99e47582..10d3efe0c 100644 --- a/src/pages/Address/AddressComp.tsx +++ b/src/pages/Address/AddressComp.tsx @@ -46,6 +46,7 @@ import { Transaction } from '../../models/Transaction' import { Address, UDTAccount } from '../../models/Address' import { Card, CardCellInfo, CardCellsLayout } from '../../components/Card' import { CardHeader } from '../../components/Card/CardHeader' +import Cells from './Cells' import { AddressCoTAComp, AddressOmigaInscriptionComp, @@ -57,6 +58,7 @@ import { enum AssetInfo { UDT = 1, INSCRIPTION, + CELLs, } const lockScriptIcon = (show: boolean) => { @@ -188,11 +190,22 @@ export const AddressOverviewCard: FC<{ address: Address }> = ({ address }) => { }, ] + const hasAssets = udts.length > 0 || (cotaList?.length && cotaList.length > 0) + const hasInscriptions = inscriptions.length > 0 + const hasCells = +address.liveCellsCount > 0 + useEffect(() => { - if (!udts.length && !cotaList?.length && inscriptions.length) { + if (hasAssets) { + return + } + if (hasInscriptions) { setActiveTab(AssetInfo.INSCRIPTION) + return } - }, [udts.length, cotaList?.length, inscriptions.length]) + if (hasCells) { + setActiveTab(AssetInfo.CELLs) + } + }, [hasAssets, hasInscriptions, hasCells, setActiveTab]) return ( @@ -200,7 +213,7 @@ export const AddressOverviewCard: FC<{ address: Address }> = ({ address }) => { - {udts.length > 0 || (cotaList?.length && cotaList.length > 0) || inscriptions.length > 0 ? ( + {hasAssets || hasInscriptions || hasCells ? ( {(udts.length > 0 || cotaList?.length) && ( @@ -244,7 +257,7 @@ export const AddressOverviewCard: FC<{ address: Address }> = ({ address }) => { )} - {inscriptions.length > 0 && ( + {hasInscriptions ? ( setActiveTab(AssetInfo.INSCRIPTION)}> @@ -270,7 +283,22 @@ export const AddressOverviewCard: FC<{ address: Address }> = ({ address }) => { })} - )} + ) : null} + + {hasCells ? ( + setActiveTab(AssetInfo.CELLs)}> + {t(`address.${+address.liveCellsCount > 1 ? 'cells' : 'cell'}`)} + + } + key={AssetInfo.CELLs} + > +
+ +
+
+ ) : null}
) : null} @@ -410,3 +438,5 @@ export const AddressTransactions = ({ ) } + +// FIXME: plural in i18n not work, address.cell and address.cells diff --git a/src/pages/Address/Cells.tsx b/src/pages/Address/Cells.tsx new file mode 100644 index 000000000..de81beef6 --- /dev/null +++ b/src/pages/Address/Cells.tsx @@ -0,0 +1,242 @@ +import { type FC, useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import BigNumber from 'bignumber.js' +import { useInfiniteQuery } from '@tanstack/react-query' +import { explorerService } from '../../services/ExplorerService' +import SUDTTokenIcon from '../../assets/sudt_token.png' +import CKBTokenIcon from './ckb_token_icon.png' +import { ReactComponent as CopyIcon } from './copy.svg' +import { ReactComponent as TypeHashIcon } from './type_script.svg' +import { ReactComponent as DataIcon } from './data.svg' +import { ReactComponent as SporeCluterIcon } from './spore_cluster.svg' +import { ReactComponent as SporeCellIcon } from './spore_cell.svg' +import { parseUDTAmount } from '../../utils/number' +import { shannonToCkb } from '../../utils/util' +import { useSetToast } from '../../components/Toast' +import { PAGE_SIZE } from '../../constants/common' +import styles from './cells.module.scss' + +const fetchCells = async ({ + address, + size = 10, + sort = 'capacity.desc', + page = 1, +}: { + address: string + size: number + sort: string + page: number +}) => { + const res = await explorerService.api.fetchAddressLiveCells(address, page, size, sort) + return { + data: res.data, + nextPage: page + 1, + } +} + +const initialPageParams = { size: 10, sort: 'capacity.desc' } + +const ATTRIBUTE_LENGTH = 18 + +const Cells: FC<{ address: string; count: number }> = ({ address, count }) => { + const [params] = useState(initialPageParams) + const loadMoreRef = useRef(null) + const setToast = useSetToast() + + const { t } = useTranslation() + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( + ['address live cells', address, params.size, params.sort], + ({ pageParam = 1 }) => fetchCells({ ...params, address, page: pageParam }), + { + getNextPageParam: lastPage => { + if (lastPage.data.length < params.size) return false + return lastPage.nextPage + }, + }, + ) + + const isListDisplayed = count && data + + useEffect(() => { + const trigger = loadMoreRef.current + + if (!isListDisplayed) return + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + fetchNextPage() + } + }, + { threshold: 0.5 }, + ) + if (trigger) { + observer.observe(trigger) + } + return () => { + if (trigger) { + observer.unobserve(trigger) + } + } + }, [isListDisplayed, fetchNextPage]) + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + const { detail } = e.currentTarget.dataset + if (!detail) return + navigator.clipboard.writeText(detail).then(() => { + setToast({ message: t('common.copied') }) + }) + } + + if (!isListDisplayed) { + return null + } + + const cells = data.pages.map(page => page.data).flat() + + return ( +
+
UTXO: {count.toLocaleString('en')}
+
    + {cells.map(cell => { + const ckb = Number(shannonToCkb(cell.capacity)).toLocaleString('en') + const title = `${cell.txHash.slice(0, 8)}...${cell.txHash.slice(-8)}#${cell.cellIndex}` + const link = `/transaction/${cell.txHash}?${new URLSearchParams({ + page_of_outputs: Math.ceil(+cell.cellIndex / PAGE_SIZE).toString(), + })}` + const assetType: string = cell.extraInfo?.type ?? cell.cellType + let icon: string | React.ReactElement | null = null + let assetName = null + let attribute = null + let detailInfo = null + + switch (assetType) { + case 'ckb': { + if (cell.typeHash) { + icon = + assetName = 'UNKNOWN ASSET' + attribute = `TYPE HASH: ${cell.typeHash.slice(0, 10)}...` + detailInfo = cell.typeHash + break + } + if (cell.data !== '0x') { + // TODO: indicate this is a contentful cell + icon = + assetName = 'DATA' + if (cell.data.length > ATTRIBUTE_LENGTH) { + attribute = `${cell.data.slice(0, ATTRIBUTE_LENGTH)}...` + } else { + attribute = cell.data + } + detailInfo = cell.data + break + } + icon = CKBTokenIcon + assetName = 'CKB' + attribute = ckb + detailInfo = BigNumber(cell.capacity).toFormat({ groupSeparator: '' }) + break + } + case 'udt': + case 'omiga_inscription': { + icon = SUDTTokenIcon + assetName = cell.extraInfo.symbol || t('udt.inscription') + attribute = cell.extraInfo.decimal + ? parseUDTAmount(cell.extraInfo.amount, cell.extraInfo.decimal) + : 'Unknown UDT amount' + detailInfo = cell.extraInfo.amount + break + } + case 'spore_cell': { + icon = + assetName = 'Spore' + if (cell.data.length > ATTRIBUTE_LENGTH) { + attribute = `${cell.data.slice(0, ATTRIBUTE_LENGTH)}...` + } else { + attribute = cell.data + } + detailInfo = cell.data + break + } + case 'spore_cluster': { + icon = + assetName = 'Spore Cluster' + if (cell.data.length > ATTRIBUTE_LENGTH) { + attribute = `${cell.data.slice(0, ATTRIBUTE_LENGTH)}...` + } else { + attribute = cell.data + } + detailInfo = cell.data + break + } + case 'nrc_721': { + icon = SUDTTokenIcon + assetName = 'NRC 721' + attribute = '-' + break + } + case 'm_nft': { + icon = SUDTTokenIcon + assetName = cell.extraInfo.className + attribute = `#${parseInt(cell.extraInfo.tokenId, 16)}` + break + } + default: { + icon = SUDTTokenIcon + assetName = 'UNKNOWN' + attribute = '-' + } + } + const outPoint = { + tx_hash: cell.txHash, + index: `0x${cell.cellIndex.toString(16)}`, + } + + return ( +
  • +
    + {title} + + + {`${ckb} CKB`} +
    +
    + {typeof icon === 'string' ? {assetName : null} + {icon && typeof icon !== 'string' ? icon : null} +
    +
    {assetName}
    +
    + {attribute} + {detailInfo ? ( + + ) : null} +
    +
    +
    +
  • + ) + })} +
+ {isFetchingNextPage ? Loading... : null} + {!hasNextPage || isFetchingNextPage ? null : ( +
+ +
+ )} +
+ ) +} +export default Cells diff --git a/src/pages/Address/cells.module.scss b/src/pages/Address/cells.module.scss new file mode 100644 index 000000000..c0b8dd9dd --- /dev/null +++ b/src/pages/Address/cells.module.scss @@ -0,0 +1,131 @@ +.container { + width: 100%; + overflow: scroll; + + .toolbar { + margin-top: 1rem; + margin-bottom: 1rem; + } + + ul { + list-style: none; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); + gap: 1rem; + } +} + +.card { + min-height: 86px; + background: #fff; + border-radius: 4px; + overflow: hidden; + + h5 { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--primary-color); + color: #fff; + height: 1.875rem; + padding: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + a { + &:hover { + font-weight: bold; + color: #fff; + text-decoration: underline; + } + } + + .copy { + appearance: none; + border: none; + background: none; + width: 14px; + cursor: pointer; + margin-right: 8px; + height: 14px; + + svg { + pointer-events: none; + height: 14px; + } + } + + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + flex: 1; + text-align: right; + } + } + + .content { + padding: 8px; + display: flex; + line-height: 1; + + img, + svg { + margin-right: 8px; + } + + .assetName { + color: #666; + } + + .attribute { + display: flex; + } + + .copy { + appearance: none; + border: none; + background: none; + width: 14px; + cursor: pointer; + + &:hover { + svg { + stroke: var(--primary-color); + } + } + + svg { + pointer-events: none; + height: 14px; + } + } + + .fields { + display: flex; + flex-direction: column; + justify-content: space-around; + } + } +} + +.loading { + display: flex; + justify-content: center; + padding: 0.5rem; +} + +.loadMore { + display: flex; + justify-content: center; + padding: 0.5rem; + + button { + cursor: pointer; + appearance: none; + border: none; + } +} diff --git a/src/pages/Address/copy.svg b/src/pages/Address/copy.svg new file mode 100644 index 000000000..259c6ec05 --- /dev/null +++ b/src/pages/Address/copy.svg @@ -0,0 +1 @@ + diff --git a/src/pages/Address/data.svg b/src/pages/Address/data.svg new file mode 100644 index 000000000..2c5add415 --- /dev/null +++ b/src/pages/Address/data.svg @@ -0,0 +1 @@ + diff --git a/src/pages/Address/spore_cell.svg b/src/pages/Address/spore_cell.svg new file mode 100644 index 000000000..5cd799da9 --- /dev/null +++ b/src/pages/Address/spore_cell.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/pages/Address/spore_cluster.svg b/src/pages/Address/spore_cluster.svg new file mode 100644 index 000000000..722bb31c8 --- /dev/null +++ b/src/pages/Address/spore_cluster.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/pages/Address/type_script.svg b/src/pages/Address/type_script.svg new file mode 100644 index 000000000..ebfc36ff4 --- /dev/null +++ b/src/pages/Address/type_script.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/services/ExplorerService/fetcher.ts b/src/services/ExplorerService/fetcher.ts index d7a9badb9..e2681c6bd 100644 --- a/src/services/ExplorerService/fetcher.ts +++ b/src/services/ExplorerService/fetcher.ts @@ -71,6 +71,42 @@ export const apiFetcher = { }), ), + // sort field, block_timestamp, capacity + // sort type, asc, desc + fetchAddressLiveCells: (address: string, page: number, size: number, sort?: string) => { + return v1GetUnwrappedPagedList<{ + cellType: 'spore_cell' + txHash: string + cellIndex: number + typeHash: string + data: string + capacity: string + occupiedCapacity: string + blockTimestamp: string + blockNumber: string + typeScript: Script + lockScript: Script + extraInfo: { + symbol: string + amount: string + decimal: string + typeHash: string + published: boolean + displayName: string + uan: string + type: 'ckb' | 'udt' | 'nrc_721' | 'm_nft' + className: string + tokenId: string + } + }>(`address_live_cells/${address}`, { + params: { + page, + page_size: size, + sort, + }, + }) + }, + fetchTransactionsByAddress: (address: string, page: number, size: number, sort?: string, txTypeFilter?: string) => v1GetUnwrappedPagedList(`address_transactions/${address}`, { params: {