Skip to content

Commit

Permalink
Merge pull request #909 from oasisprotocol/csillag/nft-instance-details
Browse files Browse the repository at this point in the history
Add NFT instance details page
  • Loading branch information
buberdds authored Nov 28, 2023
2 parents 9b6f148 + bf47a6d commit 47c9f3d
Show file tree
Hide file tree
Showing 13 changed files with 438 additions and 0 deletions.
1 change: 1 addition & 0 deletions .changelog/909.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add NFT feature
95 changes: 95 additions & 0 deletions src/app/pages/NFTInstanceDashboardPage/InstanceDetailsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { FC } from 'react'
import Card from '@mui/material/Card'
import { useAccount } from '../AccountDetailsPage/hook'
import { TextSkeleton } from '../../components/Skeleton'
import { StyledDescriptionList } from '../../components/StyledDescriptionList'
import { useScreenSize } from '../../hooks/useScreensize'
import { useTranslation } from 'react-i18next'
import { AccountLink } from '../../components/Account/AccountLink'
import { CopyToClipboard } from '../../components/CopyToClipboard'
import { VerificationIcon } from '../../components/ContractVerificationIcon'
import CardContent from '@mui/material/CardContent'
import { TokenTypeTag } from '../../components/Tokens/TokenList'
import { SearchScope } from '../../../types/searchScope'
import { TokenLink } from '../../components/Tokens/TokenLink'
import { EvmNft } from 'oasis-nexus/api'

type InstanceDetailsCardProps = {
nft: EvmNft | undefined
isFetched: boolean
isLoading: boolean
scope: SearchScope
contractAddress: string
}

export const InstanceDetailsCard: FC<InstanceDetailsCardProps> = ({
contractAddress,
isFetched: isNftFetched,
isLoading: isNftLoading,
nft,
scope,
}) => {
const { t } = useTranslation()
const { isMobile } = useScreenSize()
const {
account,
isFetched: isAccountFetched,
isLoading: accountIsLoading,
} = useAccount(scope, contractAddress)
const token = nft?.token
const isLoading = isNftLoading || accountIsLoading
const isFetched = isNftFetched || isAccountFetched
const owner = nft?.owner_eth ?? nft?.owner

return (
<Card>
<CardContent>
{isLoading && <TextSkeleton numberOfRows={7} />}
{isFetched && account && nft && (
<StyledDescriptionList titleWidth={isMobile ? '100px' : '200px'}>
{nft.name && (
<>
<dt>{t('common.name')}</dt>
<dd>{nft.name}</dd>
</>
)}
<dt>{t('nft.instanceTokenId')}</dt>
<dd>{nft.id}</dd>
<dt>{t('nft.collection')} </dt>
<dd>
<TokenLink scope={scope} address={contractAddress} name={token?.name} />
</dd>
<dt>{t('common.type')} </dt>
<dd>
<TokenTypeTag tokenType={token?.type} />
</dd>
{owner && (
<>
<dt>{t('nft.owner')}</dt>
<dd>
<AccountLink scope={scope} address={owner} />
<CopyToClipboard value={owner} />
</dd>
</>
)}
{nft?.token?.num_transfers && (
<>
<dt>{t('nft.transfers')}</dt>
<dd>{nft.token.num_transfers!.toLocaleString()}</dd>
</>
)}
<dt>{t(isMobile ? 'common.smartContract_short' : 'common.smartContract')}</dt>
<dd>
<AccountLink scope={account} address={account.address_eth || account.address} />
<CopyToClipboard value={account.address_eth || account.address} />
</dd>
<dt>{t('contract.verification.title')}</dt>
<dd>
<VerificationIcon address_eth={token?.eth_contract_addr!} verified={!!token?.is_verified} />
</dd>
</StyledDescriptionList>
)}
</CardContent>
</Card>
)
}
145 changes: 145 additions & 0 deletions src/app/pages/NFTInstanceDashboardPage/InstanceImageCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Box from '@mui/material/Box'
import { Button } from '@mui/base/Button'
import CardContent from '@mui/material/CardContent'
import Card from '@mui/material/Card'
import ContrastIcon from '@mui/icons-material/Contrast'
import Link from '@mui/material/Link'
import Skeleton from '@mui/material/Skeleton'
import Tooltip from '@mui/material/Tooltip'
import OpenInFullIcon from '@mui/icons-material/OpenInFull'
import NotInterestedIcon from '@mui/icons-material/NotInterested'
import { styled } from '@mui/material/styles'
import { EvmNft } from 'oasis-nexus/api'
import { isNftImageUrlValid, processNftImageUrl } from '../../utils/nft-images'
import { COLORS } from '../../../styles/theme/colors'

const maxImageSize = '350px'

export const StyledImage = styled('img')({
maxWidth: maxImageSize,
maxHeight: maxImageSize,
})

const StyledButton = styled(Button, {
shouldForwardProp: prop => prop !== 'darkMode',
})<{ darkMode: boolean }>(({ darkMode }) => ({
cursor: 'pointer',
border: 'none',
width: 36,
height: 36,
borderRadius: 18,
background: darkMode ? COLORS.white : COLORS.grayMedium,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: darkMode ? COLORS.grayMedium : COLORS.white,
}))

const DarkModeSwitch: FC<{ darkMode: boolean; onSetDarkMode: (darkMode: boolean) => void }> = ({
darkMode,
onSetDarkMode,
}) => {
const { t } = useTranslation()
return (
<Tooltip title={t('nft.switchBackgroundColor')}>
<StyledButton
darkMode={darkMode}
onClick={() => onSetDarkMode(!darkMode)}
aria-label={t('nft.switchBackgroundColor')}
>
<ContrastIcon />
</StyledButton>
</Tooltip>
)
}

// Temporary solution until we have a proper image modal viewer
const FullScreenButton: FC<{ darkMode: boolean; imageUrl: string }> = ({ darkMode, imageUrl }) => {
const { t } = useTranslation()
return (
<Tooltip title={t('nft.openInFullscreen')}>
<StyledButton
darkMode={darkMode}
component={Link}
href={imageUrl}
rel="noopener noreferrer"
target="_blank"
>
<OpenInFullIcon />
</StyledButton>
</Tooltip>
)
}

type InstanceImageCardProps = {
nft: EvmNft | undefined
isFetched: boolean
isLoading: boolean
}

export const InstanceImageCard: FC<InstanceImageCardProps> = ({ isFetched, isLoading, nft }) => {
const { t } = useTranslation()
const [darkMode, setDarkMode] = useState(false)

return (
<Card
sx={{
background: darkMode ? COLORS.grayExtraDark : COLORS.white,
}}
>
<CardContent>
<Box
sx={{
background: darkMode ? COLORS.grayExtraDark : COLORS.white,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{isLoading && <Skeleton variant="rectangular" width={maxImageSize} height={maxImageSize} />}
{isFetched && nft && !isNftImageUrlValid(nft.image) && (
<Box
paddingY={6}
gap={4}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: COLORS.grayMedium2,
}}
>
<NotInterestedIcon sx={{ fontSize: '72px' }} />
{t('nft.noPreview')}
</Box>
)}
{isFetched && nft && isNftImageUrlValid(nft.image) && (
<>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<StyledImage src={processNftImageUrl(nft.image)} alt={nft.name} />
</Box>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'right',
gap: 3,
}}
>
<FullScreenButton darkMode={darkMode} imageUrl={processNftImageUrl(nft.image)} />
<DarkModeSwitch darkMode={darkMode} onSetDarkMode={setDarkMode} />
</Box>
</>
)}
</Box>
</CardContent>
</Card>
)
}
77 changes: 77 additions & 0 deletions src/app/pages/NFTInstanceDashboardPage/InstanceTitleCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Skeleton from '@mui/material/Skeleton'
import Typography from '@mui/material/Typography'
import { EvmNft } from 'oasis-nexus/api'
import { COLORS } from '../../../styles/theme/colors'
import { VerificationIcon } from '../../components/ContractVerificationIcon'
import { AccountLink } from '../../components/Account/AccountLink'
import { CopyToClipboard } from '../../components/CopyToClipboard'
import { SearchScope } from '../../../types/searchScope'
import { getNftInstanceLabel } from '../../utils/nft'

type InstanceTitleCardProps = {
isFetched: boolean
isLoading: boolean
nft: EvmNft | undefined
scope: SearchScope
}

export const InstanceTitleCard: FC<InstanceTitleCardProps> = ({ isFetched, isLoading, nft, scope }) => {
const { t } = useTranslation()
const token = nft?.token
const displayAddress = token ? token.eth_contract_addr || token.contract_addr : undefined

return (
<Card>
<CardContent>
{isLoading && <Skeleton variant="text" />}
{isFetched && token && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'bottom',
}}
>
<Typography
variant="h2"
sx={{
fontWeight: 600,
paddingBottom: 3,
}}
>
{getNftInstanceLabel(nft)}
&nbsp;
<Typography
component="span"
noWrap
sx={{
color: COLORS.grayMedium,
fontWeight: 400,
}}
>
{t('nft.instanceTitleSuffix')}
</Typography>
</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<VerificationIcon address_eth={token.eth_contract_addr} verified={token.is_verified} noLink />
<AccountLink scope={scope} address={displayAddress!} />
<CopyToClipboard value={displayAddress!} />
</Box>
</Box>
)}
</CardContent>
</Card>
)
}
44 changes: 44 additions & 0 deletions src/app/pages/NFTInstanceDashboardPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FC } from 'react'
import { useParams } from 'react-router-dom'
import { useRequiredScopeParam } from '../../hooks/useScopeParam'
import { PageLayout } from '../../components/PageLayout'
import { InstanceTitleCard } from './InstanceTitleCard'
import { InstanceDetailsCard } from './InstanceDetailsCard'
import { InstanceImageCard } from './InstanceImageCard'
import { Layer, Runtime, useGetRuntimeEvmTokensAddressNftsId } from '../../../oasis-nexus/api'
import { AppErrors } from '../../../types/errors'

export const NFTInstanceDashboardPage: FC = () => {
const scope = useRequiredScopeParam()
const { address, instanceId } = useParams()
if (scope.layer === Layer.consensus) {
// There can be no ERC-20 or ERC-721 tokens on consensus
throw AppErrors.UnsupportedLayer
}

if (!address || !instanceId) {
throw AppErrors.InvalidUrl
}

const { data, isFetched, isLoading } = useGetRuntimeEvmTokensAddressNftsId(
scope.network,
scope.layer as Runtime,
address,
instanceId,
)
const nft = data?.data

return (
<PageLayout>
<InstanceTitleCard isFetched={isFetched} isLoading={isLoading} nft={nft} scope={scope} />
<InstanceImageCard isFetched={isFetched} isLoading={isLoading} nft={nft} />
<InstanceDetailsCard
isFetched={isFetched}
isLoading={isLoading}
nft={nft}
scope={scope}
contractAddress={address!}
/>
</PageLayout>
)
}
1 change: 1 addition & 0 deletions src/app/utils/__tests__/externalLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ onlyRunOnCI('externalLinks', () => {
if (url.startsWith(externalLinksModule.referrals.coinGecko)) continue // CoinGecko has CloudFlare DDOS protection
if (url.startsWith(externalLinksModule.github.commit)) continue // We store only partial url in constants
if (url.startsWith(externalLinksModule.github.releaseTag)) continue // We store only partial url in constants
if (url.startsWith(externalLinksModule.ipfs.proxyPrefix)) continue // We store only partial url in constants

it.concurrent(`${linksGroupName} ${linkName} ${url}`, async () => {
const response = await nodeFetch(url, { method: 'GET' })
Expand Down
9 changes: 9 additions & 0 deletions src/app/utils/__tests__/ipfs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { accessIpfsUrl } from '../ipfs'

describe('accessIpfsUrl', () => {
it('should return valid https url', () => {
expect(
accessIpfsUrl('ipfs://QmbaHVxK6Ru8p4SL3cZuzNmPNH6kE9y5TDFHWuGmKtzpto/TRAILBLAZER_Stanford.png'),
).toEqual('https://ipfs.io/ipfs/QmbaHVxK6Ru8p4SL3cZuzNmPNH6kE9y5TDFHWuGmKtzpto/TRAILBLAZER_Stanford.png')
})
})
4 changes: 4 additions & 0 deletions src/app/utils/externalLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ export const testnet = {
export const api = {
spec: `${process.env.REACT_APP_API}spec/v1.html`,
}

export const ipfs = {
proxyPrefix: 'https://ipfs.io/ipfs/',
}
Loading

0 comments on commit 47c9f3d

Please sign in to comment.