Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Account ERC721 collection #1052

Merged
merged 12 commits into from
Dec 4, 2023
1 change: 1 addition & 0 deletions .changelog/1052.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add NFT feature
157 changes: 157 additions & 0 deletions src/app/pages/AccountDetailsPage/AccountNFTCollectionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLoaderData, Link as RouterLink, To } from 'react-router-dom'
import Box from '@mui/material/Box'
import Breadcrumbs from '@mui/material/Breadcrumbs'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import Link from '@mui/material/Link'
import Typography from '@mui/material/Typography'
import ImageList from '@mui/material/ImageList'
import ImageListItem from '@mui/material/ImageListItem'
import ImageListItemBar from '@mui/material/ImageListItemBar'
import Skeleton from '@mui/material/Skeleton'
import { ErrorBoundary } from '../../components/ErrorBoundary'
import { LinkableDiv } from '../../components/PageLayout/LinkableDiv'
import { AccountDetailsContext } from './index'
import { AccountLink } from 'app/components/Account/AccountLink'
import { CopyToClipboard } from 'app/components/CopyToClipboard'
import { RouteUtils } from 'app/utils/route-utils'
import { COLORS } from 'styles/theme/colors'
import { ImageListItemImage } from '../TokenDashboardPage/ImageListItemImage'
import { CardEmptyState } from '../AccountDetailsPage/CardEmptyState'
import { TablePagination } from '../../components/Table/TablePagination'
import { useAccountTokenInventory } from '../TokenDashboardPage/hook'
import { EvmNft } from 'oasis-nexus/api'
import { SearchScope } from '../../../types/searchScope'
import { NFTCollectionLink, NFTInstanceLink } from '../TokenDashboardPage/NFTLinks'

export const accountNFTCollectionContainerId = 'nftCollection'

export const AccountNFTCollectionCard: FC<AccountDetailsContext> = ({ scope, address }) => {
const { t } = useTranslation()
const oasisContractAddress = useLoaderData() as string
const { inventory, isFetched, isLoading, isTotalCountClipped, pagination, totalCount } =
useAccountTokenInventory(scope, address, oasisContractAddress)
const firstToken = inventory?.length ? inventory?.[0].token : undefined

return (
<Card>
<LinkableDiv id={accountNFTCollectionContainerId}>
<CardHeader
action={
isFetched &&
firstToken && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', paddingY: 3 }}>
<AccountLink scope={scope} address={firstToken?.eth_contract_addr} />
<CopyToClipboard value={firstToken?.eth_contract_addr} />
</Box>
)
}
disableTypography
title={
<Box sx={{ display: 'flex' }} gap={4}>
<Breadcrumbs separator="›" aria-label="breadcrumb">
<Typography fontSize={24}>
<Link
preventScrollReset={true}
component={RouterLink}
to={RouteUtils.getAccountTokensRoute(scope, address, 'ERC721', '')}
>
{t('nft.accountCollection')}
</Link>
</Typography>
{isFetched && (
<Box sx={{ display: 'flex', alignItems: 'baseline' }} gap={2}>
<Typography color={COLORS.brandExtraDark} fontSize={24}>
{firstToken?.name ? inventory?.[0].token.name : t('common.collection')}
</Typography>
{!!totalCount && (
<Typography>({`${isTotalCountClipped ? ' > ' : ''}${totalCount}`})</Typography>
)}
</Box>
)}
</Breadcrumbs>
{isLoading && <Skeleton variant="text" sx={{ width: '50%' }} />}
</Box>
}
/>
</LinkableDiv>
<CardContent>
<ErrorBoundary light={true}>
<AccountNFTCollection
inventory={inventory}
isFetched={isFetched}
isLoading={isLoading}
totalCount={totalCount}
isTotalCountClipped={isTotalCountClipped}
pagination={pagination}
scope={scope}
/>
</ErrorBoundary>
</CardContent>
</Card>
)
}

type AccountNFTCollectionProps = {
inventory: EvmNft[] | undefined
isLoading: boolean
isFetched: boolean
isTotalCountClipped: boolean | undefined
totalCount: number | undefined
pagination: {
rowsPerPage: number
selectedPage: number
linkToPage: (pageNumber: number) => To
}
scope: SearchScope
}

const AccountNFTCollection: FC<AccountNFTCollectionProps> = ({
inventory,
isLoading,
isFetched,
isTotalCountClipped,
pagination,
scope,
totalCount,
}) => {
const { t } = useTranslation()

return (
<>
{isLoading && <Skeleton variant="rectangular" sx={{ height: 200 }} />}
{isFetched && !totalCount && <CardEmptyState label={t('tokens.emptyInventory')} />}
{!!inventory?.length && (
<>
<ImageList gap={10}>
{inventory?.map(instance => {
const to = RouteUtils.getNFTInstanceRoute(scope, instance.token?.contract_addr, instance.id)
return (
<ImageListItem key={instance.id}>
<ImageListItemImage instance={instance} to={to} />
lubej marked this conversation as resolved.
Show resolved Hide resolved
<ImageListItemBar
title={<NFTCollectionLink instance={instance} scope={scope} />}
subtitle={<NFTInstanceLink instance={instance} scope={scope} />}
position="below"
/>
</ImageListItem>
)
})}
</ImageList>
{pagination && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<TablePagination
{...pagination}
totalCount={totalCount}
isTotalCountClipped={isTotalCountClipped}
/>
</Box>
)}
</>
)}
</>
)
}
25 changes: 22 additions & 3 deletions src/app/pages/AccountDetailsPage/AccountTokensCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { useLocation, Link as RouterLink } from 'react-router-dom'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import Link from '@mui/material/Link'
import { CardEmptyState } from './CardEmptyState'
import { Table, TableCellAlign, TableColProps } from '../../components/Table'
import { CopyToClipboard } from '../../components/CopyToClipboard'
Expand Down Expand Up @@ -43,11 +44,20 @@ export const AccountTokensCard: FC<AccountTokensCardProps> = ({ scope, address,
const { t } = useTranslation()
const locationHash = useLocation().hash.replace('#', '')
const tokenListLabel = getTokenTypePluralName(t, type)
const isERC721 = type === EvmTokenType.ERC721
const tableColumns: TableColProps[] = [
{ key: 'name', content: t('common.name') },
{ key: 'name', content: t(isERC721 ? 'common.collection' : 'common.name') },
{ key: 'contract', content: t('common.smartContract') },
{ key: 'balance', align: TableCellAlign.Right, content: t('common.balance') },
{ key: 'balance', align: TableCellAlign.Right, content: t(isERC721 ? 'common.owned' : 'common.balance') },
{ key: 'ticker', align: TableCellAlign.Right, content: t('common.ticker') },
...(isERC721
? [
{
key: 'link',
content: '',
},
]
: []),
]
const { layer } = scope
if (layer === Layer.consensus) {
Expand Down Expand Up @@ -86,6 +96,15 @@ export const AccountTokensCard: FC<AccountTokensCardProps> = ({ scope, address,
content: item.token_symbol || t('common.missing'),
key: 'ticker',
},
{
align: TableCellAlign.Right,
key: 'link',
content: (
<Link component={RouterLink} to={item.token_contract_addr_eth} preventScrollReset={true}>
{t('common.viewAll')}
</Link>
),
},
],
highlight: item.token_contract_addr_eth === locationHash || item.token_contract_addr === locationHash,
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const InstanceDetailsCard: FC<InstanceDetailsCardProps> = ({
)}
<dt>{t('nft.instanceTokenId')}</dt>
<dd>{nft.id}</dd>
<dt>{t('nft.collection')} </dt>
<dt>{t('common.collection')} </dt>
<dd>
<TokenLink scope={scope} address={contractAddress} name={token?.name} />
</dd>
Expand Down
1 change: 1 addition & 0 deletions src/app/pages/TokenDashboardPage/ImageListItemImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const imageSize = '210px'
const StyledImage = styled('img')({
width: imageSize,
height: imageSize,
objectFit: 'cover',
})

type ImageListItemImageProps = {
Expand Down
56 changes: 56 additions & 0 deletions src/app/pages/TokenDashboardPage/NFTLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link as RouterLink } from 'react-router-dom'
import { EvmNft } from 'oasis-nexus/api'
import Link from '@mui/material/Link'
import Typography from '@mui/material/Typography'
import { RouteUtils } from '../../utils/route-utils'
import { SearchScope } from '../../../types/searchScope'
import { trimLongString } from '../../utils/trimLongString'

type NFTLinkProps = {
scope: SearchScope
instance: EvmNft
}

export const NFTCollectionLink: FC<NFTLinkProps> = ({ scope, instance }) => {
const { t } = useTranslation()
const to = RouteUtils.getTokenRoute(scope, instance.token?.contract_addr)

return (
<Typography>
<Trans
i18nKey="nft.collectionLink"
t={t}
components={{
CollectionLink: (
<Link component={RouterLink} to={to}>
{instance.token?.name ?? trimLongString(instance.token?.eth_contract_addr, 5, 5)}
</Link>
),
}}
/>
</Typography>
)
}

export const NFTInstanceLink: FC<NFTLinkProps> = ({ scope, instance }) => {
const { t } = useTranslation()
const to = RouteUtils.getNFTInstanceRoute(scope, instance.token?.contract_addr, instance.id)

return (
<Typography>
<Trans
i18nKey="nft.instanceIdLink"
t={t}
components={{
InstanceLink: (
<Link component={RouterLink} to={to}>
{instance.id}
</Link>
),
}}
/>
</Typography>
)
}
19 changes: 3 additions & 16 deletions src/app/pages/TokenDashboardPage/TokenInventoryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link as RouterLink } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import ImageList from '@mui/material/ImageList'
import ImageListItem from '@mui/material/ImageListItem'
import ImageListItemBar from '@mui/material/ImageListItemBar'
import Link from '@mui/material/Link'
import { ErrorBoundary } from '../../components/ErrorBoundary'
import { LinkableDiv } from '../../components/PageLayout/LinkableDiv'
import { CardEmptyState } from '../AccountDetailsPage/CardEmptyState'
Expand All @@ -18,6 +16,7 @@ import { RouteUtils } from '../../utils/route-utils'
import { TablePagination } from '../../components/Table/TablePagination'
import { useTokenInventory } from './hook'
import { ImageListItemImage } from './ImageListItemImage'
import { NFTInstanceLink } from './NFTLinks'

export const tokenInventoryContainerId = 'inventory'

Expand Down Expand Up @@ -55,19 +54,7 @@ const TokenInventoryView: FC<TokenDashboardContext> = ({ scope, address }) => {
<ImageListItem key={instance.id}>
<ImageListItemImage instance={instance} to={to} />
<ImageListItemBar
title={
<Trans
i18nKey="nft.instanceIdLink"
t={t}
components={{
InstanceLink: (
<Link component={RouterLink} to={to}>
#{instance.id}
</Link>
),
}}
/>
}
title={<NFTInstanceLink scope={scope} instance={instance} />}
subtitle={
owner ? <AccountLink scope={scope} address={owner} alwaysTrim={true} /> : undefined
}
Expand Down
37 changes: 37 additions & 0 deletions src/app/pages/TokenDashboardPage/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useGetRuntimeEvmTokensAddress,
useGetRuntimeEvmTokensAddressHolders,
useGetRuntimeEvmTokensAddressNfts,
useGetRuntimeAccountsAddressNfts,
} from '../../../oasis-nexus/api'
import { AppErrors } from '../../../types/errors'
import { SearchScope } from '../../../types/searchScope'
Expand Down Expand Up @@ -141,3 +142,39 @@ export const useTokenInventory = (scope: SearchScope, address: string) => {
totalCount,
}
}

export const useAccountTokenInventory = (scope: SearchScope, address: string, tokenAddress: string) => {
const { network, layer } = scope
const pagination = useSearchParamsPagination('page')
const offset = (pagination.selectedPage - 1) * NUMBER_OF_INVENTORY_ITEMS
if (layer === Layer.consensus) {
throw AppErrors.UnsupportedLayer
// There are no tokens on the consensus layer.
}
const query = useGetRuntimeAccountsAddressNfts(network, layer, address, {
limit: NUMBER_OF_INVENTORY_ITEMS,
offset: offset,
token_address: tokenAddress,
})
const { isFetched, isLoading, data } = query
const inventory = data?.data.evm_nfts

if (isFetched && pagination.selectedPage > 1 && !inventory?.length) {
throw AppErrors.PageDoesNotExist
}

const totalCount = data?.data.total_count
const isTotalCountClipped = data?.data.is_total_count_clipped

return {
isLoading,
isFetched,
inventory,
pagination: {
...pagination,
rowsPerPage: NUMBER_OF_INVENTORY_ITEMS,
},
isTotalCountClipped,
totalCount,
}
}
7 changes: 7 additions & 0 deletions src/app/utils/route-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export const addressParamLoader = async ({ params }: LoaderFunctionArgs) => {
return address
}

export const contractAddressParamLoader = async ({ params }: LoaderFunctionArgs) => {
lubej marked this conversation as resolved.
Show resolved Hide resolved
validateAddressParam(params.contractAddress!)
// TODO: remove conversion when API supports querying by EVM address
const address = await getOasisAddress(params.contractAddress!)
return address
}

export const blockHeightParamLoader = async ({ params }: LoaderFunctionArgs) => {
return validateBlockHeightParam(params.blockHeight!)
}
Expand Down
Loading
Loading