feat: cards v2 (#6048)

* initial setup

* forgot border

* cta

* rough draft

* adding marketplace container and selector

* removing last sale

* details link

* marketplace icons

* removing hovered

* adding icons

* removing unused exports

* fixing hover

* not linking to details

* linting

* mobile

* moving cards to component

* linting

* profile cards

* deleting cards

* fixing imports

* actually fixing imports

* tryingn to fix linting errors

* disabling module export for this file

* fixing build

* seems to hate uppercase C

* fixing tests

* passing data-testid correctly

* tertiary info

* removing the extra times

* button states

* adjusting tertiary

* pointer-events

* border animation

* variance bug

* cryptopunks

* unavailable for listing

* disabled cta

* set heihgt

* animated slide up

* animation

* badge changes

* shadows

* ran yarn

* removing eslint comment

* removing types and hooks

* removing from cache

* small tweaks

* removing unused tertiary info

* initial comment addressing

* more comments

* translations

* refactoring file structure

* removing trans tag

* reverting to what it prev was

* text-shadow

* eslint ignore

* updating size test
This commit is contained in:
Jack Short 2023-03-14 15:38:39 -04:00 committed by GitHub
parent 799edfb493
commit b963d3b27b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1190 additions and 1246 deletions

@ -33,7 +33,7 @@ describe('Testing nfts', () => {
cy.visit(`/#/nfts/collection/${PUDGY_COLLECTION_ADDRESS}`)
cy.get(getTestSelector('nft-filter')).first().click()
cy.get(getTestSelector('nft-collection-filter-buy-now')).click()
cy.get(getTestSelector('nft-details-link')).first().click()
cy.get(getTestSelector('nft-collection-asset')).first().click()
cy.get(getTestSelector('nft-details-traits')).should('exist')
cy.get(getTestSelector('nft-details-activity')).should('exist')
cy.get(getTestSelector('nft-details-description')).should('exist')

@ -16,12 +16,12 @@ try {
}
// The last recorded size for these assets, as reported by `yarn build`.
const LAST_SIZE_MAIN_KB = 374
const LAST_SIZE_MAIN_KB = 381
// This is the async-loaded js, called <number>.<hash>.js, with a matching css file.
const LAST_SIZE_ENTRY_KB = 1432
const SIZE_TOLERANCE_KB = 5
const SIZE_TOLERANCE_KB = 10
const jsEntrypoints = entrypoints.filter((entrypoint) => entrypoint.endsWith('js'))
assert(jsEntrypoints.length === 3)

@ -495,8 +495,6 @@ export const BagFooter = ({ totalEthPrice, fetchAssets, eventProperties }: BagFo
...eventProperties,
}
console.log(bagStatus)
return (
<FooterContainer>
<Footer>

@ -4,7 +4,7 @@ import clsx from 'clsx'
import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button'
import { TimedLoader } from 'nft/components/bag/TimedLoader'
import { Box } from 'nft/components/Box'
import { Suspicious } from 'nft/components/collection/Card'
import { Suspicious } from 'nft/components/card/icons'
import { Column, Row } from 'nft/components/Flex'
import {
ChevronDownBagIcon,

@ -0,0 +1,297 @@
import Column from 'components/Column'
import Row from 'components/Row'
import { StyledImage } from 'nft/components/card/media'
import { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ThemedText } from 'theme'
const BORDER_RADIUS = '12'
const StyledDetailsRelativeContainer = styled.div`
position: relative;
height: 84px;
`
const StyledDetailsContainer = styled(Column)`
position: absolute;
width: 100%;
padding: 16px 8px 0px;
justify-content: space-between;
gap: 8px;
height: 84px;
background: ${({ theme }) => theme.backgroundSurface};
will-change: transform;
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} transform`};
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
height: 112px;
transform: translateY(-28px);
}
`
const StyledActionButton = styled(ThemedText.BodySmall)<{
selected: boolean
isDisabled: boolean
}>`
position: absolute;
display: flex;
padding: 8px 0px;
bottom: -32px;
left: 8px;
right: 8px;
color: ${({ theme, isDisabled }) => (isDisabled ? theme.textPrimary : theme.accentTextLightPrimary)};
background: ${({ theme, selected, isDisabled }) =>
selected ? theme.accentCritical : isDisabled ? theme.backgroundInteractive : theme.accentAction};
transition: ${({ theme }) =>
`${theme.transition.duration.medium} ${theme.transition.timing.ease} bottom, ${theme.transition.duration.medium} ${theme.transition.timing.ease} visibility`};
will-change: transform;
border-radius: 8px;
justify-content: center;
font-weight: 600 !important;
line-height: 16px;
visibility: hidden;
cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'pointer')};
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
visibility: visible;
bottom: 8px;
}
&:before {
background-size: 100%;
border-radius: inherit;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
}
&:hover:before {
background-color: ${({ theme, isDisabled }) => !isDisabled && theme.stateOverlayHover};
}
&:active:before {
background-color: ${({ theme, isDisabled }) => !isDisabled && theme.stateOverlayPressed};
}
`
const ActionButton = ({
isDisabled,
isSelected,
clickActionButton,
children,
}: {
isDisabled: boolean
isSelected: boolean
clickActionButton: (e: React.MouseEvent) => void
children: ReactNode
}) => {
return (
<StyledActionButton
selected={isSelected}
isDisabled={isDisabled}
onClick={(e: React.MouseEvent) => (isDisabled ? undefined : clickActionButton(e))}
>
{children}
</StyledActionButton>
)
}
const StyledCardContainer = styled.div<{ selected: boolean; isDisabled: boolean }>`
position: relative;
border-radius: ${BORDER_RADIUS}px;
background-color: ${({ theme }) => theme.backgroundSurface};
overflow: hidden;
box-shadow: 0px 0px 8px rgba(51, 53, 72, 0.04), 1px 2px 4px rgba(51, 53, 72, 0.12);
box-sizing: border-box;
-webkit-box-sizing: border-box;
isolation: isolate;
:after {
content: '';
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
border: ${({ selected, theme }) => (selected ? '3px' : !theme.darkMode ? '0px' : '1px')} solid;
border-radius: ${BORDER_RADIUS}px;
border-color: ${({ theme, selected }) => (selected ? theme.accentAction : theme.backgroundOutline)};
pointer-events: none;
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} border`};
will-change: border;
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
${({ selected, theme }) => selected && `border-color: ${theme.accentCritical}`};
}
}
:hover::after {
${({ selected, theme }) => selected && `border-color: ${theme.accentCritical}`};
}
:hover {
${StyledActionButton} {
visibility: visible;
bottom: 8px;
}
${StyledDetailsContainer} {
height: 112px;
transform: translateY(-28px);
}
${StyledImage} {
transform: scale(1.15);
}
}
`
const CardContainer = ({
isSelected,
isDisabled,
children,
testId,
onClick,
}: {
isSelected: boolean
isDisabled: boolean
children: ReactNode
testId?: string
onClick?: (e: React.MouseEvent) => void
}) => {
return (
<StyledCardContainer
selected={isSelected}
isDisabled={isDisabled}
draggable={false}
data-testid={testId}
onClick={onClick}
>
{children}
</StyledCardContainer>
)
}
const StyledLink = styled(Link)`
text-decoration: none;
`
const Container = ({
isSelected,
isDisabled,
detailsHref,
doNotLinkToDetails = false,
testId,
onClick,
children,
}: {
isSelected: boolean
isDisabled: boolean
detailsHref: string
doNotLinkToDetails: boolean
testId?: string
children: ReactNode
onClick?: (e: React.MouseEvent) => void
}) => {
return (
<CardContainer isSelected={isSelected} isDisabled={isDisabled} testId={testId} onClick={onClick}>
<StyledLink to={doNotLinkToDetails ? '' : detailsHref}>{children}</StyledLink>
</CardContainer>
)
}
const DetailsRelativeContainer = ({ children }: { children: ReactNode }) => {
return <StyledDetailsRelativeContainer>{children}</StyledDetailsRelativeContainer>
}
const DetailsContainer = ({ children }: { children: ReactNode }) => {
return <StyledDetailsContainer>{children}</StyledDetailsContainer>
}
const StyledInfoContainer = styled(Column)`
gap: 4px;
overflow: hidden;
width: 100%;
padding: 0px 8px;
height: 48px;
`
const InfoContainer = ({ children }: { children: ReactNode }) => {
return <StyledInfoContainer>{children}</StyledInfoContainer>
}
const StyledPrimaryRow = styled(Row)`
gap: 8px;
justify-content: space-between;
`
const PrimaryRow = ({ children }: { children: ReactNode }) => <StyledPrimaryRow>{children}</StyledPrimaryRow>
const StyledPrimaryDetails = styled(Row)`
justify-items: center;
overflow: hidden;
white-space: nowrap;
gap: 8px;
`
const PrimaryDetails = ({ children }: { children: ReactNode }) => (
<StyledPrimaryDetails>{children}</StyledPrimaryDetails>
)
const PrimaryInfoContainer = styled(ThemedText.BodySmall)`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 600 !important;
line-height: 20px;
`
const PrimaryInfo = ({ children }: { children: ReactNode }) => {
return <PrimaryInfoContainer>{children}</PrimaryInfoContainer>
}
const StyledSecondaryRow = styled(Row)`
justify-content: space-between;
`
const SecondaryRow = ({ children }: { children: ReactNode }) => <StyledSecondaryRow>{children}</StyledSecondaryRow>
const StyledSecondaryDetails = styled(Row)`
overflow: hidden;
white-space: nowrap;
`
const SecondaryDetails = ({ children }: { children: ReactNode }) => (
<StyledSecondaryDetails>{children}</StyledSecondaryDetails>
)
const SecondaryInfoContainer = styled(ThemedText.BodyPrimary)`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 24px;
`
const SecondaryInfo = ({ children }: { children: ReactNode }) => {
return <SecondaryInfoContainer>{children}</SecondaryInfoContainer>
}
export {
ActionButton,
Container,
DetailsContainer,
DetailsRelativeContainer,
InfoContainer,
PrimaryDetails,
PrimaryInfo,
PrimaryRow,
SecondaryDetails,
SecondaryInfo,
SecondaryRow,
}

@ -0,0 +1,157 @@
import { Trans } from '@lingui/macro'
import Row from 'components/Row'
import { MouseoverTooltip } from 'components/Tooltip'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { getMarketplaceIcon } from 'nft/components/card/utils'
import { CollectionSelectedAssetIcon } from 'nft/components/icons'
import { Markets } from 'nft/types'
import { putCommas } from 'nft/utils'
import { AlertTriangle, Check, Tag } from 'react-feather'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
const StyledMarketplaceContainer = styled.div<{ isText?: boolean }>`
position: absolute;
display: flex;
top: 12px;
left: 12px;
height: 32px;
width: ${({ isText }) => (isText ? 'auto' : '32px')};
padding: ${({ isText }) => (isText ? '0px 8px' : '0px')};
background: rgba(93, 103, 133, 0.24);
color: ${({ theme }) => theme.accentTextLightPrimary};
justify-content: center;
align-items: center;
border-radius: 32px;
z-index: 2;
`
const ListPriceRowContainer = styled(Row)`
gap: 6px;
color: ${({ theme }) => theme.accentTextLightPrimary};
font-size: 14px;
font-weight: 600;
line-height: 16px;
text-shadow: 1px 1px 3px rgba(51, 53, 72, 0.54);
`
export const MarketplaceContainer = ({
isSelected,
marketplace,
tokenType,
listedPrice,
}: {
isSelected: boolean
marketplace?: Markets
tokenType?: NftStandard
listedPrice?: string
}) => {
if (isSelected) {
if (!marketplace) {
return (
<StyledMarketplaceContainer>
<Check size={20} />
</StyledMarketplaceContainer>
)
}
return (
<StyledMarketplaceContainer>
<CollectionSelectedAssetIcon width="20px" height="20px" viewBox="0 0 20 20" />
</StyledMarketplaceContainer>
)
}
if (listedPrice) {
return (
<StyledMarketplaceContainer isText={true}>
<ListPriceRowContainer>
<Tag size={20} />
{listedPrice} ETH
</ListPriceRowContainer>
</StyledMarketplaceContainer>
)
}
if (!marketplace || tokenType === NftStandard.Erc1155) {
return null
}
return <StyledMarketplaceContainer>{getMarketplaceIcon(marketplace)}</StyledMarketplaceContainer>
}
const SuspiciousIcon = styled(AlertTriangle)`
width: 16px;
height: 16px;
color: ${({ theme }) => theme.accentFailure};
`
interface RankingProps {
provider: { url?: string; rank?: number }
}
const RarityLogoContainer = styled(Row)`
margin-right: 8px;
width: 16px;
`
const RarityText = styled(ThemedText.BodySmall)`
display: flex;
`
const RarityInfo = styled(ThemedText.Caption)`
flex-shrink: 0;
color: ${({ theme }) => theme.textSecondary};
background: ${({ theme }) => theme.backgroundInteractive};
padding: 4px 6px;
border-radius: 4px;
font-weight: 700 !important;
line-height: 12px;
text-align: right;
cursor: pointer;
`
export const Ranking = ({ provider }: RankingProps) => {
if (!provider.rank) {
return null
}
return (
<RarityInfo>
<MouseoverTooltip
text={
<Row>
<RarityLogoContainer>
<img src="/nft/svgs/gem.svg" width={16} height={16} />
</RarityLogoContainer>
<RarityText>Ranking by Rarity Sniper</RarityText>
</Row>
}
placement="top"
>
# {putCommas(provider.rank)}
</MouseoverTooltip>
</RarityInfo>
)
}
const SuspiciousIconContainer = styled(Row)`
flex-shrink: 0;
`
export const Suspicious = () => {
return (
<MouseoverTooltip
text={
<ThemedText.BodySmall>
<Trans>Blocked on OpenSea</Trans>
</ThemedText.BodySmall>
}
placement="top"
>
<SuspiciousIconContainer>
<SuspiciousIcon />
</SuspiciousIconContainer>
</MouseoverTooltip>
)
}

@ -0,0 +1,123 @@
import * as Card from 'nft/components/card/containers'
import { MarketplaceContainer } from 'nft/components/card/icons'
import { MediaContainer } from 'nft/components/card/media'
import { detailsHref, getNftDisplayComponent, useSelectAsset } from 'nft/components/card/utils'
import { useBag } from 'nft/hooks'
import { GenieAsset, UniformAspectRatio, UniformAspectRatios, WalletAsset } from 'nft/types'
import { floorFormatter } from 'nft/utils'
import { ReactNode } from 'react'
import { shallow } from 'zustand/shallow'
interface NftCardProps {
asset: GenieAsset | WalletAsset
display: NftCardDisplayProps
isSelected: boolean
isDisabled: boolean
selectAsset: () => void
unselectAsset: () => void
onClick?: () => void
doNotLinkToDetails?: boolean
mediaShouldBePlaying: boolean
uniformAspectRatio?: UniformAspectRatio
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void
renderedHeight?: number
setRenderedHeight?: (renderedHeight: number | undefined) => void
setCurrentTokenPlayingMedia: (tokenId: string | undefined) => void
testId?: string
}
export interface NftCardDisplayProps {
primaryInfo?: ReactNode
primaryInfoIcon?: ReactNode
primaryInfoRight?: ReactNode
secondaryInfo?: ReactNode
selectedInfo?: ReactNode
notSelectedInfo?: ReactNode
disabledInfo?: ReactNode
}
export const NftCard = ({
asset,
display,
isSelected,
selectAsset,
unselectAsset,
isDisabled,
onClick,
doNotLinkToDetails = false,
mediaShouldBePlaying,
uniformAspectRatio = UniformAspectRatios.square,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight,
setCurrentTokenPlayingMedia,
testId,
}: NftCardProps) => {
const clickActionButton = useSelectAsset(selectAsset, unselectAsset, isSelected, isDisabled, onClick)
const { bagExpanded, setBagExpanded } = useBag(
(state) => ({
bagExpanded: state.bagExpanded,
setBagExpanded: state.setBagExpanded,
}),
shallow
)
const collectionNft = 'marketplace' in asset
const profileNft = 'asset_contract' in asset
const tokenType = collectionNft ? asset.tokenType : profileNft ? asset.asset_contract.tokenType : undefined
const marketplace = collectionNft ? asset.marketplace : undefined
const listedPrice =
profileNft && !isDisabled && asset.floor_sell_order_price ? floorFormatter(asset.floor_sell_order_price) : undefined
return (
<Card.Container
isSelected={isSelected}
isDisabled={isDisabled}
detailsHref={detailsHref(asset)}
doNotLinkToDetails={doNotLinkToDetails}
testId={testId}
onClick={() => {
if (bagExpanded) setBagExpanded({ bagExpanded: false })
}}
>
<MediaContainer isDisabled={isDisabled}>
<MarketplaceContainer
isSelected={isSelected}
marketplace={marketplace}
tokenType={tokenType}
listedPrice={listedPrice}
/>
{getNftDisplayComponent(
asset,
mediaShouldBePlaying,
setCurrentTokenPlayingMedia,
uniformAspectRatio,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight
)}
</MediaContainer>
<Card.DetailsRelativeContainer>
<Card.DetailsContainer>
<Card.InfoContainer>
<Card.PrimaryRow>
<Card.PrimaryDetails>
<Card.PrimaryInfo>{display.primaryInfo}</Card.PrimaryInfo>
{display.primaryInfoIcon}
</Card.PrimaryDetails>
{display.primaryInfoRight}
</Card.PrimaryRow>
<Card.SecondaryRow>
<Card.SecondaryDetails>
<Card.SecondaryInfo>{display.secondaryInfo}</Card.SecondaryInfo>
</Card.SecondaryDetails>
</Card.SecondaryRow>
</Card.InfoContainer>
</Card.DetailsContainer>
</Card.DetailsRelativeContainer>
<Card.ActionButton clickActionButton={clickActionButton} isDisabled={isDisabled} isSelected={isSelected}>
{isSelected ? display.selectedInfo : isDisabled ? display.disabledInfo : display.notSelectedInfo}
</Card.ActionButton>
</Card.Container>
)
}

@ -0,0 +1,256 @@
import { Trans } from '@lingui/macro'
import Row from 'components/Row'
import { getHeightFromAspectRatio, getMediaAspectRatio, handleUniformAspectRatio } from 'nft/components/card/utils'
import { UniformAspectRatio, UniformAspectRatios } from 'nft/types'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { Pause, Play } from 'react-feather'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ThemedText } from 'theme'
import { colors } from 'theme/colors'
const StyledImageContainer = styled.div<{ isDisabled?: boolean }>`
position: relative;
pointer-events: auto;
&:hover {
opacity: ${({ isDisabled, theme }) => (isDisabled ? theme.opacity.disabled : theme.opacity.enabled)};
}
cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'pointer')};
`
export const MediaContainer = ({ isDisabled, children }: { isDisabled: boolean; children: ReactNode }) => {
return <StyledImageContainer isDisabled={isDisabled}>{children}</StyledImageContainer>
}
interface ImageProps {
src?: string
uniformAspectRatio?: UniformAspectRatio
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void
renderedHeight?: number
setRenderedHeight?: (renderedHeight: number | undefined) => void
}
const StyledMediaContainer = styled(Row)`
overflow: hidden;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
`
export const StyledImage = styled.img<{
imageLoading: boolean
$aspectRatio?: string
$hidden?: boolean
}>`
width: 100%;
aspect-ratio: ${({ $aspectRatio }) => $aspectRatio};
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} transform`};
will-change: transform;
object-fit: contain;
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
background: ${({ theme, imageLoading }) =>
imageLoading && `linear-gradient(270deg, ${theme.backgroundOutline} 0%, ${theme.backgroundSurface} 100%)`};
`
export const NftImage = ({
src,
uniformAspectRatio = UniformAspectRatios.square,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight,
}: ImageProps) => {
const [noContent, setNoContent] = useState(!src)
const [loaded, setLoaded] = useState(false)
if (noContent) {
return <NoContentContainer height={getHeightFromAspectRatio(uniformAspectRatio, renderedHeight)} />
}
return (
<StyledMediaContainer>
<StyledImage
src={src}
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
imageLoading={!loaded}
draggable={false}
onError={() => setNoContent(true)}
onLoad={(e) => {
handleUniformAspectRatio(uniformAspectRatio, e, setUniformAspectRatio, renderedHeight, setRenderedHeight)
setLoaded(true)
}}
/>
</StyledMediaContainer>
)
}
interface MediaProps {
isAudio?: boolean
mediaSrc?: string
tokenId?: string
shouldPlay: boolean
setCurrentTokenPlayingMedia: (tokenId: string | undefined) => void
}
const PlaybackButton = styled.div<{ pauseButton?: boolean }>`
display: ${({ pauseButton }) => (pauseButton ? 'block' : 'none')};
color: ${({ theme }) => theme.accentAction};
position: absolute;
height: 40px;
width: 40px;
z-index: 1;
margin-left: calc(100% - 50px);
transform: translateY(-76px);
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
display: block;
}
${StyledImageContainer}:hover & {
display: block;
}
`
const StyledVideo = styled.video<{
$aspectRatio?: string
}>`
width: 100%;
aspect-ratio: ${({ $aspectRatio }) => $aspectRatio};
`
const StyledInnerMediaContainer = styled(Row)`
position: absolute;
left: 0px;
top: 0px;
`
const StyledAudio = styled.audio`
width: 100%;
height: 100%;
`
export const NftPlayableMedia = ({
isAudio,
src,
mediaSrc,
tokenId,
uniformAspectRatio = UniformAspectRatios.square,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight,
shouldPlay,
setCurrentTokenPlayingMedia,
}: MediaProps & ImageProps) => {
const mediaRef = useRef<HTMLVideoElement>(null)
const [noContent, setNoContent] = useState(!src)
const [imageLoaded, setImageLoaded] = useState(false)
useEffect(() => {
if (shouldPlay && mediaRef.current) {
mediaRef.current.play()
} else if (!shouldPlay && mediaRef.current) {
mediaRef.current.pause()
}
}, [shouldPlay])
if (noContent) {
return <NoContentContainer height={getHeightFromAspectRatio(uniformAspectRatio, renderedHeight)} />
}
return (
<>
<StyledMediaContainer>
<StyledImage
src={src}
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
imageLoading={!imageLoaded}
draggable={false}
onError={() => setNoContent(true)}
onLoad={(e) => {
handleUniformAspectRatio(uniformAspectRatio, e, setUniformAspectRatio, renderedHeight, setRenderedHeight)
setImageLoaded(true)
}}
$hidden={shouldPlay && !isAudio}
/>
</StyledMediaContainer>
{shouldPlay ? (
<>
<PlaybackButton pauseButton={true}>
<Pause
size="24px"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrentTokenPlayingMedia(undefined)
}}
/>
</PlaybackButton>
<StyledInnerMediaContainer>
{isAudio ? (
<StyledAudio
ref={mediaRef}
onEnded={(e) => {
e.preventDefault()
setCurrentTokenPlayingMedia(undefined)
}}
>
<source src={mediaSrc} />
</StyledAudio>
) : (
<StyledVideo
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
ref={mediaRef}
onEnded={(e) => {
e.preventDefault()
setCurrentTokenPlayingMedia(undefined)
}}
loop
playsInline
>
<source src={mediaSrc} />
</StyledVideo>
)}
</StyledInnerMediaContainer>
</>
) : (
<PlaybackButton>
<Play
size="24px"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrentTokenPlayingMedia(tokenId)
}}
/>
</PlaybackButton>
)}
</>
)
}
const NoContentContainerBackground = styled.div<{ $height?: number }>`
position: relative;
width: 100%;
height: ${({ $height }) => ($height ? `${$height}px` : 'auto')};
padding-top: 100%;
background: ${({ theme }) =>
`linear-gradient(90deg, ${theme.backgroundSurface} 0%, ${theme.backgroundInteractive} 95.83%)`};
`
const NoContentText = styled(ThemedText.BodyPrimary)`
position: absolute;
text-align: center;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
color: ${colors.gray500};
`
const NoContentContainer = ({ height }: { height?: number }) => (
<>
<NoContentContainerBackground $height={height}>
<NoContentText>
<Trans>Content not</Trans>
<br />
<Trans>available yet</Trans>
</NoContentText>
</NoContentContainerBackground>
</>
)

@ -0,0 +1,192 @@
import { NftImage, NftPlayableMedia } from 'nft/components/card/media'
import {
LarvaLabsMarketplaceIcon,
LooksRareIcon,
Nft20Icon,
NftXIcon,
OpenSeaMarketplaceIcon,
SudoSwapIcon,
X2y2Icon,
} from 'nft/components/icons'
import { GenieAsset, Markets, UniformAspectRatio, UniformAspectRatios, WalletAsset } from 'nft/types'
import { isAudio, isVideo } from 'nft/utils'
import { ReactNode, useCallback } from 'react'
enum AssetMediaType {
Image,
Video,
Audio,
}
function getAssetImageUrl(asset: GenieAsset | WalletAsset) {
return asset.imageUrl || asset.smallImageUrl
}
function getAssetMediaUrl(asset: GenieAsset | WalletAsset) {
return asset.animationUrl
}
export function detailsHref(asset: GenieAsset | WalletAsset) {
if ('address' in asset) return `/nfts/asset/${asset.address}/${asset.tokenId}?origin=collection`
if ('asset_contract' in asset) return `/nfts/asset/${asset.asset_contract.address}/${asset.tokenId}?origin=profile`
return '/nfts/profile'
}
function getAssetMediaType(asset: GenieAsset | WalletAsset) {
let assetMediaType = AssetMediaType.Image
if (asset.animationUrl) {
if (isAudio(asset.animationUrl)) {
assetMediaType = AssetMediaType.Audio
} else if (isVideo(asset.animationUrl)) {
assetMediaType = AssetMediaType.Video
}
}
return assetMediaType
}
export function getNftDisplayComponent(
asset: GenieAsset | WalletAsset,
mediaShouldBePlaying: boolean,
setCurrentTokenPlayingMedia: (tokenId: string | undefined) => void,
uniformAspectRatio?: UniformAspectRatio,
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void,
renderedHeight?: number,
setRenderedHeight?: (renderedHeight: number | undefined) => void
) {
switch (getAssetMediaType(asset)) {
case AssetMediaType.Image:
return (
<NftImage
src={getAssetImageUrl(asset)}
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
/>
)
case AssetMediaType.Video:
return (
<NftPlayableMedia
src={getAssetImageUrl(asset)}
mediaSrc={getAssetMediaUrl(asset)}
tokenId={asset.tokenId}
shouldPlay={mediaShouldBePlaying}
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
/>
)
case AssetMediaType.Audio:
return (
<NftPlayableMedia
isAudio={true}
src={getAssetImageUrl(asset)}
mediaSrc={getAssetMediaUrl(asset)}
tokenId={asset.tokenId}
shouldPlay={mediaShouldBePlaying}
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
/>
)
}
}
export function useSelectAsset(
selectAsset: () => void,
unselectAsset: () => void,
isSelected: boolean,
isDisabled: boolean,
onClick?: () => void
) {
return useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
if (isDisabled) {
return
}
if (onClick) {
onClick()
return
}
return isSelected ? unselectAsset() : selectAsset()
},
[selectAsset, isDisabled, onClick, unselectAsset, isSelected]
)
}
export function getMarketplaceIcon(market: Markets): ReactNode {
switch (market) {
case Markets.Opensea:
return <OpenSeaMarketplaceIcon />
case Markets.LooksRare:
return <LooksRareIcon />
case Markets.X2Y2:
return <X2y2Icon />
case Markets.Sudoswap:
return <SudoSwapIcon />
case Markets.NFT20:
return <Nft20Icon />
case Markets.NFTX:
return <NftXIcon />
case Markets.Cryptopunks:
return <LarvaLabsMarketplaceIcon />
default:
return null
}
}
export const handleUniformAspectRatio = (
uniformAspectRatio: UniformAspectRatio,
e: React.SyntheticEvent<HTMLElement, Event>,
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void,
renderedHeight?: number,
setRenderedHeight?: (renderedHeight: number | undefined) => void
) => {
if (uniformAspectRatio !== UniformAspectRatios.square && setUniformAspectRatio) {
const height = e.currentTarget.clientHeight
const width = e.currentTarget.clientWidth
const aspectRatio = width / height
if (
(!renderedHeight || renderedHeight !== height) &&
aspectRatio < 1 &&
uniformAspectRatio !== UniformAspectRatios.square &&
setRenderedHeight
) {
setRenderedHeight(height)
}
const variance = 0.05
if (uniformAspectRatio === UniformAspectRatios.unset) {
setUniformAspectRatio(aspectRatio >= 1 ? UniformAspectRatios.square : aspectRatio)
} else if (aspectRatio > uniformAspectRatio + variance || aspectRatio < uniformAspectRatio - variance) {
setUniformAspectRatio(UniformAspectRatios.square)
setRenderedHeight && setRenderedHeight(undefined)
}
}
}
export function getHeightFromAspectRatio(
uniformAspectRatio: UniformAspectRatio,
renderedHeight?: number
): number | undefined {
return uniformAspectRatio === UniformAspectRatios.square || uniformAspectRatio === UniformAspectRatios.unset
? undefined
: renderedHeight
}
export function getMediaAspectRatio(
uniformAspectRatio?: UniformAspectRatio,
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void
): string {
return uniformAspectRatio === UniformAspectRatios.square || !setUniformAspectRatio ? '1' : 'auto'
}

@ -1,952 +0,0 @@
import { BigNumber } from '@ethersproject/bignumber'
import { t, Trans } from '@lingui/macro'
import Column from 'components/Column'
import { OpacityHoverState } from 'components/Common'
import Row from 'components/Row'
import { MouseoverTooltip } from 'components/Tooltip'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import {
MinusIconLarge,
PauseButtonIcon,
PlayButtonIcon,
PlusIconLarge,
PoolIcon,
RarityVerifiedIcon,
VerifiedIcon,
} from 'nft/components/icons'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset, Rarity, UniformAspectRatio, UniformAspectRatios, WalletAsset } from 'nft/types'
import { fallbackProvider, isAudio, isVideo, putCommas } from 'nft/utils'
import { floorFormatter } from 'nft/utils/numbers'
import {
createContext,
MouseEvent,
ReactNode,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import { AlertTriangle, Pause, Play } from 'react-feather'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { colors } from 'theme/colors'
import { opacify } from 'theme/utils'
/* -------- ASSET CONTEXT -------- */
export interface CardContextProps {
asset: GenieAsset | WalletAsset
hovered: boolean
selected: boolean
href: string
setHref: (href: string) => void
addAssetToBag: () => void
removeAssetFromBag: () => void
}
const CardContext = createContext<CardContextProps | undefined>(undefined)
const BORDER_RADIUS = '12'
const useCardContext = () => {
const context = useContext(CardContext)
if (!context) throw new Error('Must use context inside of provider')
return context
}
export enum AssetMediaType {
Image,
Video,
Audio,
}
const useNotForSale = (asset: GenieAsset) =>
useMemo(() => {
let notForSale = true
notForSale = asset.notForSale || BigNumber.from(asset.priceInfo ? asset.priceInfo.ETHPrice : 0).lt(0)
return notForSale
}, [asset])
const useAssetMediaType = (asset: GenieAsset | WalletAsset) =>
useMemo(() => {
let assetMediaType = AssetMediaType.Image
if (asset.animationUrl) {
if (isAudio(asset.animationUrl)) {
assetMediaType = AssetMediaType.Audio
} else if (isVideo(asset.animationUrl)) {
assetMediaType = AssetMediaType.Video
}
}
return assetMediaType
}, [asset])
const baseHref = (asset: GenieAsset | WalletAsset) => {
if ('address' in asset) return `/#/nfts/asset/${asset.address}/${asset.tokenId}?origin=collection`
if ('asset_contract' in asset) return `/#/nfts/asset/${asset.asset_contract.address}/${asset.tokenId}?origin=profile`
return '/#/nfts/profile'
}
const DetailsLinkContainer = styled.a`
display: flex;
align-items: center;
flex-shrink: 0;
text-decoration: none;
font-size: 14px;
font-weight: 500;
border: 1px solid;
color: ${({ theme }) => theme.accentAction};
border-color: ${({ theme }) => theme.accentActionSoft};
padding: 2px 6px;
border-radius: 6px;
${OpacityHoverState};
`
const SuspiciousIcon = styled(AlertTriangle)`
width: 16px;
height: 16px;
color: ${({ theme }) => theme.accentFailure};
`
const Erc1155ControlsRow = styled.div`
position: absolute;
display: flex;
width: 100%;
bottom: 12px;
z-index: 2;
justify-content: center;
`
const Erc1155ControlsContainer = styled.div`
display: flex;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: ${BORDER_RADIUS}px;
overflow: hidden;
`
const Erc1155ControlsDisplay = styled(ThemedText.HeadlineSmall)`
display: flex;
padding: 6px 8px;
width: 60px;
background: ${({ theme }) => theme.backgroundBackdrop};
justify-content: center;
cursor: default;
`
const Erc1155ControlsInput = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 40px;
background: ${({ theme }) => theme.backgroundInteractive};
color: ${({ theme }) => theme.textPrimary};
:hover {
color: ${({ theme }) => theme.accentAction};
}
`
const RankingContainer = styled.div`
position: absolute;
top: 12px;
left: 12px;
z-index: 2;
`
const StyledImageContainer = styled.div<{ isDisabled?: boolean }>`
position: relative;
pointer-events: auto;
&:hover {
opacity: ${({ isDisabled, theme }) => (isDisabled ? theme.opacity.disabled : theme.opacity.enabled)};
}
cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'pointer')};
`
const CardContainer = styled.div<{ selected: boolean }>`
position: relative;
border-radius: ${BORDER_RADIUS}px;
background-color: ${({ theme }) => theme.backgroundSurface};
overflow: hidden;
padding-bottom: 12px;
border-radius: 16px;
box-shadow: rgba(0, 0, 0, 10%) 0px 4px 12px;
box-sizing: border-box;
-webkit-box-sizing: border-box;
:after {
content: '';
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
border: ${({ selected }) => (selected ? '2px' : '1px')} solid;
border-radius: 16px;
border-color: ${({ theme, selected }) => (selected ? theme.accentAction : opacify(12, colors.gray500))};
pointer-events: none;
}
`
/* -------- ASSET CARD -------- */
interface CardProps {
asset: GenieAsset | WalletAsset
selected: boolean
addAssetToBag: () => void
removeAssetFromBag: () => void
children: ReactNode
isDisabled?: boolean
onClick?: () => void
}
const Container = ({
asset,
selected,
addAssetToBag,
removeAssetFromBag,
children,
isDisabled,
onClick,
}: CardProps) => {
const [hovered, toggleHovered] = useReducer((s) => !s, false)
const [href, setHref] = useState(baseHref(asset))
const providerValue = useMemo(
() => ({
asset,
selected,
hovered,
toggleHovered,
href,
setHref,
addAssetToBag,
removeAssetFromBag,
}),
[asset, hovered, selected, href, addAssetToBag, removeAssetFromBag]
)
const assetRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (hovered && assetRef.current?.matches(':hover') === false) toggleHovered()
}, [hovered])
const handleAssetInBag = (e: MouseEvent) => {
if (!asset.notForSale) {
e.preventDefault()
!selected ? addAssetToBag() : removeAssetFromBag()
}
}
const toggleHover = useCallback(() => toggleHovered(), [])
return (
<CardContext.Provider value={providerValue}>
<CardContainer
selected={selected}
ref={assetRef}
draggable={false}
onMouseEnter={toggleHover}
onMouseLeave={toggleHover}
onClick={isDisabled ? () => null : onClick ?? handleAssetInBag}
>
{children}
</CardContainer>
</CardContext.Provider>
)
}
const ImageContainer = ({ children, isDisabled = false }: { children: ReactNode; isDisabled?: boolean }) => (
<StyledImageContainer isDisabled={isDisabled}>{children}</StyledImageContainer>
)
const handleUniformAspectRatio = (
uniformAspectRatio: UniformAspectRatio,
e: React.SyntheticEvent<HTMLElement, Event>,
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void,
renderedHeight?: number,
setRenderedHeight?: (renderedHeight: number | undefined) => void
) => {
if (uniformAspectRatio !== UniformAspectRatios.square && setUniformAspectRatio) {
const height = e.currentTarget.clientHeight
const width = e.currentTarget.clientWidth
const aspectRatio = width / height
if (
(!renderedHeight || renderedHeight !== height) &&
aspectRatio < 1 &&
uniformAspectRatio !== UniformAspectRatios.square &&
setRenderedHeight
) {
setRenderedHeight(height)
}
if (uniformAspectRatio === UniformAspectRatios.unset) {
setUniformAspectRatio(aspectRatio >= 1 ? UniformAspectRatios.square : aspectRatio)
} else if (uniformAspectRatio !== aspectRatio) {
setUniformAspectRatio(UniformAspectRatios.square)
setRenderedHeight && setRenderedHeight(undefined)
}
}
}
function getHeightFromAspectRatio(uniformAspectRatio: UniformAspectRatio, renderedHeight?: number): number | undefined {
return uniformAspectRatio === UniformAspectRatios.square || uniformAspectRatio === UniformAspectRatios.unset
? undefined
: renderedHeight
}
function getMediaAspectRatio(
uniformAspectRatio?: UniformAspectRatio,
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void
): string {
return uniformAspectRatio === UniformAspectRatios.square || !setUniformAspectRatio ? '1' : 'auto'
}
interface ImageProps {
uniformAspectRatio?: UniformAspectRatio
setUniformAspectRatio?: (uniformAspectRatio: UniformAspectRatio) => void
renderedHeight?: number
setRenderedHeight?: (renderedHeight: number | undefined) => void
}
const StyledMediaContainer = styled(Row)`
overflow: hidden;
border-top-left-radius: ${BORDER_RADIUS}px;
border-top-right-radius: ${BORDER_RADIUS}px;
`
const StyledImage = styled.img<{
hovered: boolean
imageLoading: boolean
$aspectRatio?: string
$hidden?: boolean
}>`
width: 100%;
aspect-ratio: ${({ $aspectRatio }) => $aspectRatio};
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} transform`};
will-change: transform;
object-fit: contain;
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
transform: ${({ hovered }) => hovered && 'scale(1.15)'};
background: ${({ theme, imageLoading }) =>
imageLoading && `linear-gradient(270deg, ${theme.backgroundOutline} 0%, ${theme.backgroundSurface} 100%)`};
`
const Image = ({
uniformAspectRatio = UniformAspectRatios.square,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight,
}: ImageProps) => {
const { hovered, asset } = useCardContext()
const [noContent, setNoContent] = useState(!asset.smallImageUrl && !asset.imageUrl)
const [loaded, setLoaded] = useState(false)
const isMobile = useIsMobile()
if (noContent) {
return <NoContentContainer height={getHeightFromAspectRatio(uniformAspectRatio, renderedHeight)} />
}
return (
<StyledMediaContainer>
<StyledImage
src={asset.imageUrl || asset.smallImageUrl}
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
hovered={hovered && !isMobile}
imageLoading={!loaded}
draggable={false}
onError={() => setNoContent(true)}
onLoad={(e) => {
handleUniformAspectRatio(uniformAspectRatio, e, setUniformAspectRatio, renderedHeight, setRenderedHeight)
setLoaded(true)
}}
/>
</StyledMediaContainer>
)
}
interface MediaProps {
shouldPlay: boolean
setCurrentTokenPlayingMedia: (tokenId: string | undefined) => void
}
const PlaybackButton = styled.div`
position: absolute;
height: 40px;
width: 40px;
z-index: 1;
margin-left: calc(100% - 50px);
transform: translateY(-56px);
`
const StyledVideo = styled.video<{
$aspectRatio?: string
}>`
width: 100%;
aspect-ratio: ${({ $aspectRatio }) => $aspectRatio};
`
const StyledInnerMediaContainer = styled(Row)`
position: absolute;
left: 0px;
top: 0px;
`
const Video = ({
uniformAspectRatio = UniformAspectRatios.square,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight,
shouldPlay,
setCurrentTokenPlayingMedia,
}: MediaProps & ImageProps) => {
const vidRef = useRef<HTMLVideoElement>(null)
const { hovered, asset } = useCardContext()
const [noContent, setNoContent] = useState(!asset.smallImageUrl && !asset.imageUrl)
const [imageLoaded, setImageLoaded] = useState(false)
const isMobile = useIsMobile()
if (shouldPlay) {
vidRef.current?.play()
} else {
vidRef.current?.pause()
}
if (noContent) {
return <NoContentContainer height={getHeightFromAspectRatio(uniformAspectRatio, renderedHeight)} />
}
return (
<>
<StyledMediaContainer>
<StyledImage
src={asset.imageUrl || asset.smallImageUrl}
alt={asset.name || asset.tokenId}
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
hovered={hovered && !isMobile}
imageLoading={!imageLoaded}
draggable={false}
onError={() => setNoContent(true)}
onLoad={(e) => {
handleUniformAspectRatio(uniformAspectRatio, e, setUniformAspectRatio, renderedHeight, setRenderedHeight)
setImageLoaded(true)
}}
$hidden={shouldPlay}
/>
</StyledMediaContainer>
{shouldPlay ? (
<>
<PlaybackButton>
<Pause
size="24px"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrentTokenPlayingMedia(undefined)
}}
/>
</PlaybackButton>
<StyledInnerMediaContainer>
<StyledVideo
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
ref={vidRef}
onEnded={(e) => {
e.preventDefault()
setCurrentTokenPlayingMedia(undefined)
}}
loop
playsInline
>
<source src={asset.animationUrl} />
</StyledVideo>
</StyledInnerMediaContainer>
</>
) : (
<PlaybackButton>
{((!isMobile && hovered) || isMobile) && (
<Play
size="24px"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrentTokenPlayingMedia(asset.tokenId)
}}
/>
)}
</PlaybackButton>
)}
</>
)
}
const StyledAudio = styled.audio`
width: 100%;
height: 100%;
`
const Audio = ({
uniformAspectRatio = UniformAspectRatios.square,
setUniformAspectRatio,
renderedHeight,
setRenderedHeight,
shouldPlay,
setCurrentTokenPlayingMedia,
}: MediaProps & ImageProps) => {
const audRef = useRef<HTMLAudioElement>(null)
const { hovered, asset } = useCardContext()
const [noContent, setNoContent] = useState(!asset.smallImageUrl && !asset.imageUrl)
const [imageLoaded, setImageLoaded] = useState(false)
const isMobile = useIsMobile()
if (shouldPlay) {
audRef.current?.play()
} else {
audRef.current?.pause()
}
if (noContent) {
return <NoContentContainer height={getHeightFromAspectRatio(uniformAspectRatio, renderedHeight)} />
}
return (
<>
<StyledMediaContainer>
<StyledImage
src={asset.imageUrl || asset.smallImageUrl}
alt={asset.name || asset.tokenId}
$aspectRatio={getMediaAspectRatio(uniformAspectRatio, setUniformAspectRatio)}
hovered={hovered && !isMobile}
imageLoading={!imageLoaded}
draggable={false}
onError={() => setNoContent(true)}
onLoad={(e) => {
handleUniformAspectRatio(uniformAspectRatio, e, setUniformAspectRatio, renderedHeight, setRenderedHeight)
setImageLoaded(true)
setImageLoaded(true)
}}
/>
</StyledMediaContainer>
{shouldPlay ? (
<>
<PlaybackButton>
<PauseButtonIcon
width="100%"
height="100%"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrentTokenPlayingMedia(undefined)
}}
/>
</PlaybackButton>
<StyledInnerMediaContainer>
<StyledAudio
ref={audRef}
onEnded={(e) => {
e.preventDefault()
setCurrentTokenPlayingMedia(undefined)
}}
>
<source src={asset.animationUrl} />
</StyledAudio>
</StyledInnerMediaContainer>
</>
) : (
<PlaybackButton>
{((!isMobile && hovered) || isMobile) && (
<PlayButtonIcon
width="100%"
height="100%"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrentTokenPlayingMedia(asset.tokenId)
}}
/>
)}
</PlaybackButton>
)}
</>
)
}
/* -------- CARD DETAILS CONTAINER -------- */
interface CardDetailsContainerProps {
children: ReactNode
}
const StyledDetailsContainer = styled(Column)`
position: relative;
padding: 12px 12px 0px;
justify-content: space-between;
transition: ${({ theme }) => `${theme.transition.duration.medium}`};
`
const DetailsContainer = ({ children }: CardDetailsContainerProps) => {
return <StyledDetailsContainer>{children}</StyledDetailsContainer>
}
const StyledInfoContainer = styled.div`
overflow: hidden;
width: 100%;
`
const InfoContainer = ({ children }: { children: ReactNode }) => {
return <StyledInfoContainer>{children}</StyledInfoContainer>
}
const TruncatedTextRow = styled(ThemedText.BodySmall)`
display: flex;
padding: 2px;
white-space: pre;
text-overflow: ellipsis;
display: block;
overflow: hidden;
`
const AssetNameRow = styled(TruncatedTextRow)`
color: ${({ theme }) => theme.textPrimary};
font-size: 16px !important;
font-weight: 400;
`
interface ProfileNftDetailsProps {
asset: WalletAsset
hideDetails: boolean
}
const PrimaryRowContainer = styled.div`
overflow: hidden;
width: 100%;
flex-wrap: nowrap;
`
const FloorPriceRow = styled(TruncatedTextRow)`
font-size: 16px;
font-weight: 600;
line-height: 20px;
`
const ProfileNftDetails = ({ asset, hideDetails }: ProfileNftDetailsProps) => {
const assetName = () => {
if (!asset.name && !asset.tokenId) return
return asset.name ? asset.name : `#${asset.tokenId}`
}
const shouldShowUserListedPrice = !asset.notForSale && asset.asset_contract.tokenType !== NftStandard.Erc1155
return (
<PrimaryRowContainer>
<PrimaryRow>
<PrimaryDetails>
<TruncatedTextRow color="textSecondary">
{!!asset.asset_contract.name && <span>{asset.asset_contract.name}</span>}
</TruncatedTextRow>
{asset.collectionIsVerified && <VerifiedIcon height="18px" width="18px" />}
</PrimaryDetails>
{!hideDetails && <DetailsLink />}
</PrimaryRow>
<Row>
<AssetNameRow>{assetName()}</AssetNameRow>
{asset.susFlag && <Suspicious />}
</Row>
<FloorPriceRow>
{shouldShowUserListedPrice && asset.floor_sell_order_price
? `${floorFormatter(asset.floor_sell_order_price)} ETH`
: ' '}
</FloorPriceRow>
</PrimaryRowContainer>
)
}
const StyledPrimaryRow = styled(Row)`
gap: 8px;
justify-content: space-between;
`
const PrimaryRow = ({ children }: { children: ReactNode }) => <StyledPrimaryRow>{children}</StyledPrimaryRow>
const StyledPrimaryDetails = styled(Row)`
justify-items: center;
overflow: hidden;
white-space: nowrap;
`
const PrimaryDetails = ({ children }: { children: ReactNode }) => (
<StyledPrimaryDetails>{children}</StyledPrimaryDetails>
)
const PrimaryInfoContainer = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 400;
font-size: 16px;
line-height: 24px;
`
const PrimaryInfo = ({ children }: { children: ReactNode }) => {
return <PrimaryInfoContainer>{children}</PrimaryInfoContainer>
}
const StyledSecondaryRow = styled(Row)`
height: 20px;
justify-content: space-between;
margin-top: 6px;
`
const SecondaryRow = ({ children }: { children: ReactNode }) => <StyledSecondaryRow>{children}</StyledSecondaryRow>
const StyledSecondaryDetails = styled(Row)`
overflow: hidden;
white-space: nowrap;
`
const SecondaryDetails = ({ children }: { children: ReactNode }) => (
<StyledSecondaryDetails>{children}</StyledSecondaryDetails>
)
const SecondaryInfoContainer = styled.div`
color: ${({ theme }) => theme.textPrimary};
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 20px;
`
const SecondaryInfo = ({ children }: { children: ReactNode }) => {
return <SecondaryInfoContainer>{children}</SecondaryInfoContainer>
}
const TertiaryInfoContainer = styled.div`
color: ${({ theme }) => theme.textSecondary};
margin-top: 8px;
`
const TertiaryInfo = ({ children }: { children: ReactNode }) => {
return <TertiaryInfoContainer>{children}</TertiaryInfoContainer>
}
interface Erc1155ControlsInterface {
quantity: string
}
const Erc1155Controls = ({ quantity }: Erc1155ControlsInterface) => {
const { addAssetToBag, removeAssetFromBag } = useCardContext()
return (
<Erc1155ControlsRow>
<Erc1155ControlsContainer>
<Erc1155ControlsInput
onClick={(e: MouseEvent) => {
e.stopPropagation()
removeAssetFromBag()
}}
>
<MinusIconLarge width="24px" height="24px" />
</Erc1155ControlsInput>
<Erc1155ControlsDisplay>{quantity}</Erc1155ControlsDisplay>
<Erc1155ControlsInput
onClick={(e: MouseEvent) => {
e.stopPropagation()
addAssetToBag()
}}
>
<PlusIconLarge width="24px" height="24px" />
</Erc1155ControlsInput>
</Erc1155ControlsContainer>
</Erc1155ControlsRow>
)
}
const StyledMarketplaceIcon = styled.img`
display: inline-block;
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
margin-left: 8px;
vertical-align: top;
`
const MarketplaceIcon = ({ marketplace }: { marketplace: string }) => {
return <StyledMarketplaceIcon alt={marketplace} src={`/nft/svgs/marketplaces/${marketplace}.svg`} />
}
const DetailsLink = () => {
const { asset } = useCardContext()
return (
<DetailsLinkContainer
href={baseHref(asset)}
onClick={(e: MouseEvent) => {
e.stopPropagation()
}}
>
<div data-testid="nft-details-link">Details</div>
</DetailsLinkContainer>
)
}
/* -------- RANKING CARD -------- */
interface RankingProps {
rarity: Rarity
provider: { url?: string; rank?: number }
rarityVerified: boolean
rarityLogo?: string
}
const RarityLogoContainer = styled(Row)`
margin-right: 4px;
width: 16px;
`
const RarityText = styled(ThemedText.BodySmall)`
display: flex;
`
const RarityInfo = styled(Row)`
height: 16px;
border-radius: 4px;
color: ${({ theme }) => theme.textPrimary};
background: ${({ theme }) => theme.backgroundInteractive};
font-size: 10px;
font-weight: 600;
padding: 0px 4px;
line-height: 12px;
letter-spacing: 0.04em;
backdrop-filter: blur(6px);
`
const Ranking = ({ rarity, provider, rarityVerified, rarityLogo }: RankingProps) => {
const { asset } = useCardContext()
return (
<>
{provider.rank && (
<RankingContainer>
<MouseoverTooltip
text={
<Row>
<RarityLogoContainer>
<img src={rarityLogo} alt="cardLogo" width={16} height={16} />
</RarityLogoContainer>
<RarityText>
{rarityVerified
? `Verified by ${
('collectionName' in asset && asset.collectionName) ||
('asset_contract' in asset && asset.asset_contract?.name)
}`
: `Ranking by ${rarity.primaryProvider === 'Genie' ? fallbackProvider : rarity.primaryProvider}`}
</RarityText>
</Row>
}
placement="top"
>
<RarityInfo>
<Row padding="2px 0px">{putCommas(provider.rank)}</Row>
<Row>{rarityVerified ? <RarityVerifiedIcon /> : null}</Row>
</RarityInfo>
</MouseoverTooltip>
</RankingContainer>
)}
</>
)
}
const SUSPICIOUS_TEXT = t`Blocked on OpenSea`
const SuspiciousIconContainer = styled(Row)`
flex-shrink: 0;
margin-left: 4px;
`
const PoolIconContainer = styled(SuspiciousIconContainer)`
color: ${({ theme }) => theme.textSecondary};
`
const Suspicious = () => {
return (
<MouseoverTooltip text={<ThemedText.BodySmall>{SUSPICIOUS_TEXT}</ThemedText.BodySmall>} placement="top">
<SuspiciousIconContainer>
<SuspiciousIcon />
</SuspiciousIconContainer>
</MouseoverTooltip>
)
}
const Pool = () => {
return (
<MouseoverTooltip
text={
<ThemedText.BodySmall>
This NFT is part of a liquidity pool. Buying this will increase the price of the remaining pooled NFTs.
</ThemedText.BodySmall>
}
placement="top"
>
<PoolIconContainer>
<PoolIcon width="20" height="20" />
</PoolIconContainer>
</MouseoverTooltip>
)
}
const NoContentContainerBackground = styled.div<{ height?: number }>`
position: relative;
width: 100%;
height: ${({ height }) => (height ? `${height}px` : 'auto')};
padding-top: 100%;
background: ${({ theme }) =>
`linear-gradient(90deg, ${theme.backgroundSurface} 0%, ${theme.backgroundInteractive} 95.83%)`};
`
const NoContentText = styled(ThemedText.BodyPrimary)`
position: absolute;
text-align: center;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
color: ${colors.gray500};
`
const NoContentContainer = ({ height }: { height?: number }) => (
<>
<NoContentContainerBackground height={height}>
<NoContentText>
<Trans>Content not</Trans>
<br />
<Trans>available yet</Trans>
</NoContentText>
</NoContentContainerBackground>
</>
)
export {
Audio,
Container,
DetailsContainer,
DetailsLink,
Erc1155Controls,
Image,
ImageContainer,
InfoContainer,
MarketplaceIcon,
Pool,
PrimaryDetails,
PrimaryInfo,
PrimaryRow,
ProfileNftDetails,
Ranking,
SecondaryDetails,
SecondaryInfo,
SecondaryRow,
Suspicious,
SUSPICIOUS_TEXT,
TertiaryInfo,
useAssetMediaType,
useNotForSale,
Video,
}

@ -2,20 +2,12 @@ import { BigNumber } from '@ethersproject/bignumber'
import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent, useTrace } from '@uniswap/analytics'
import { InterfacePageName, NFTEventName } from '@uniswap/analytics-events'
import { MouseoverTooltip } from 'components/Tooltip'
import Tooltip from 'components/Tooltip'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { Box } from 'nft/components/Box'
import { bodySmall } from 'nft/css/common.css'
import { NftCard, NftCardDisplayProps } from 'nft/components/card'
import { Ranking as RankingContainer, Suspicious as SuspiciousContainer } from 'nft/components/card/icons'
import { useBag } from 'nft/hooks'
import { GenieAsset, isPooledMarket, UniformAspectRatio } from 'nft/types'
import { formatWeiToDecimal, rarityProviderLogo } from 'nft/utils'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components/macro'
import { useAssetMediaType, useNotForSale } from './Card'
import { AssetMediaType } from './Card'
import * as Card from './Card'
import { GenieAsset, UniformAspectRatio } from 'nft/types'
import { formatWeiToDecimal } from 'nft/utils'
import { useCallback, useMemo } from 'react'
interface CollectionAssetProps {
asset: GenieAsset
@ -29,25 +21,11 @@ interface CollectionAssetProps {
setRenderedHeight: (renderedHeight: number | undefined) => void
}
const TOOLTIP_TIMEOUT = 2000
const StyledContainer = styled.div`
position: absolute;
bottom: 12px;
left: 0px;
display: flex;
justify-content: center;
width: 100%;
z-index: 2;
pointer-events: none;
`
export const CollectionAsset = ({
asset,
isMobile,
mediaShouldBePlaying,
setCurrentTokenPlayingMedia,
rarityVerified,
uniformAspectRatio,
setUniformAspectRatio,
renderedHeight,
@ -56,7 +34,6 @@ export const CollectionAsset = ({
const bagManuallyClosed = useBag((state) => state.bagManuallyClosed)
const addAssetsToBag = useBag((state) => state.addAssetsToBag)
const removeAssetsFromBag = useBag((state) => state.removeAssetsFromBag)
const usedSweep = useBag((state) => state.usedSweep)
const itemsInBag = useBag((state) => state.itemsInBag)
const bagExpanded = useBag((state) => state.bagExpanded)
const setBagExpanded = useBag((state) => state.setBagExpanded)
@ -73,21 +50,8 @@ export const CollectionAsset = ({
}
}, [asset, itemsInBag])
const [showTooltip, setShowTooltip] = useState(false)
const isSelectedRef = useRef(isSelected)
const notForSale = useNotForSale(asset)
const assetMediaType = useAssetMediaType(asset)
const { provider, rarityLogo } = useMemo(() => {
return {
provider: asset?.rarity?.providers?.find(
({ provider: _provider }) => _provider === asset.rarity?.primaryProvider
),
rarityLogo: rarityProviderLogo[asset.rarity?.primaryProvider ?? 0] ?? '',
}
}, [asset])
const notForSale = asset.notForSale || BigNumber.from(asset.priceInfo ? asset.priceInfo.ETHPrice : 0).lt(0)
const provider = asset?.rarity?.providers ? asset.rarity.providers[0] : undefined
const handleAddAssetToBag = useCallback(() => {
if (BigNumber.from(asset.priceInfo?.ETHPrice ?? 0).gt(0)) {
addAssetsToBag([asset])
@ -103,122 +67,37 @@ export const CollectionAsset = ({
}
}, [addAssetsToBag, asset, bagExpanded, bagManuallyClosed, isMobile, setBagExpanded, trace])
useEffect(() => {
if (isSelected !== isSelectedRef.current && !usedSweep) {
setShowTooltip(true)
isSelectedRef.current = isSelected
const tooltipTimer = setTimeout(() => {
setShowTooltip(false)
}, TOOLTIP_TIMEOUT)
return () => {
clearTimeout(tooltipTimer)
}
}
isSelectedRef.current = isSelected
return undefined
}, [isSelected, isSelectedRef, usedSweep])
const handleRemoveAssetFromBag = useCallback(() => {
removeAssetsFromBag([asset])
}, [asset, removeAssetsFromBag])
const display: NftCardDisplayProps = useMemo(() => {
return {
primaryInfo: asset.name ? asset.name : `#${asset.tokenId}`,
primaryInfoIcon: asset.susFlag ? <SuspiciousContainer /> : null,
primaryInfoRight: asset.rarity && provider ? <RankingContainer provider={provider} /> : null,
secondaryInfo: notForSale ? '' : `${formatWeiToDecimal(asset.priceInfo.ETHPrice, true)} ETH`,
selectedInfo: <Trans>Remove from bag</Trans>,
notSelectedInfo: <Trans>Add to bag</Trans>,
disabledInfo: <Trans>Not listed</Trans>,
}
}, [asset.name, asset.priceInfo.ETHPrice, asset.rarity, asset.susFlag, asset.tokenId, notForSale, provider])
return (
<Card.Container
<NftCard
asset={asset}
selected={isSelected}
addAssetToBag={handleAddAssetToBag}
removeAssetFromBag={handleRemoveAssetFromBag}
>
<Card.ImageContainer isDisabled={asset.notForSale}>
<StyledContainer data-testid="nft-collection-asset">
<Tooltip
text={
<Box as="span" className={bodySmall} color="textPrimary">
{isSelected ? <Trans>Added to bag</Trans> : <Trans>Removed from bag</Trans>}
</Box>
}
show={showTooltip}
style={{ display: 'block' }}
offsetX={0}
offsetY={0}
hideArrow={true}
placement="bottom"
showInline
/>
</StyledContainer>
{asset.rarity && provider && (
<Card.Ranking
rarity={asset.rarity}
provider={provider}
rarityVerified={!!rarityVerified}
rarityLogo={rarityLogo}
/>
)}
<MouseoverTooltip
text={
<Box as="span" className={bodySmall} color="textPrimary">
<Trans>This item is not for sale</Trans>
</Box>
}
placement="bottom"
offsetX={0}
offsetY={-50}
style={{ display: 'block' }}
hideArrow={true}
disableHover={!asset.notForSale}
timeout={isMobile ? TOOLTIP_TIMEOUT : undefined}
>
{assetMediaType === AssetMediaType.Image ? (
<Card.Image
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
/>
) : assetMediaType === AssetMediaType.Video ? (
<Card.Video
shouldPlay={mediaShouldBePlaying}
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
/>
) : (
<Card.Audio
shouldPlay={mediaShouldBePlaying}
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
/>
)}
</MouseoverTooltip>
</Card.ImageContainer>
<Card.DetailsContainer>
<Card.InfoContainer>
<Card.PrimaryRow>
<Card.PrimaryDetails>
<Card.PrimaryInfo>{asset.name ? asset.name : `#${asset.tokenId}`}</Card.PrimaryInfo>
{asset.susFlag && <Card.Suspicious />}
</Card.PrimaryDetails>
<Card.DetailsLink />
</Card.PrimaryRow>
<Card.SecondaryRow>
<Card.SecondaryDetails>
<Card.SecondaryInfo>
{notForSale ? '' : `${formatWeiToDecimal(asset.priceInfo.ETHPrice, true)} ETH`}
</Card.SecondaryInfo>
{isPooledMarket(asset.marketplace) && <Card.Pool />}
</Card.SecondaryDetails>
{asset.tokenType !== NftStandard.Erc1155 && asset.marketplace && (
<Card.MarketplaceIcon marketplace={asset.marketplace} />
)}
</Card.SecondaryRow>
</Card.InfoContainer>
</Card.DetailsContainer>
</Card.Container>
display={display}
isSelected={isSelected}
isDisabled={Boolean(asset.notForSale)}
selectAsset={handleAddAssetToBag}
unselectAsset={handleRemoveAssetFromBag}
mediaShouldBePlaying={mediaShouldBePlaying}
uniformAspectRatio={uniformAspectRatio}
setUniformAspectRatio={setUniformAspectRatio}
renderedHeight={renderedHeight}
setRenderedHeight={setRenderedHeight}
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
testId="nft-collection-asset"
/>
)
}

File diff suppressed because one or more lines are too long

@ -2,19 +2,12 @@ import { Trans } from '@lingui/macro'
import { useTrace } from '@uniswap/analytics'
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { NFTEventName } from '@uniswap/analytics-events'
import { MouseoverTooltip } from 'components/Tooltip'
import Tooltip from 'components/Tooltip'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { Box } from 'nft/components/Box'
import * as Card from 'nft/components/collection/Card'
import { AssetMediaType } from 'nft/components/collection/Card'
import { bodySmall } from 'nft/css/common.css'
import { themeVars } from 'nft/css/sprinkles.css'
import { NftCard, NftCardDisplayProps } from 'nft/components/card'
import { VerifiedIcon } from 'nft/components/icons'
import { useBag, useIsMobile, useSellAsset } from 'nft/hooks'
import { WalletAsset } from 'nft/types'
import { useEffect, useMemo, useRef, useState } from 'react'
const TOOLTIP_TIMEOUT = 2000
import { useMemo } from 'react'
interface ViewMyNftsAssetProps {
asset: WalletAsset
@ -23,31 +16,6 @@ interface ViewMyNftsAssetProps {
hideDetails: boolean
}
const getNftDisplayComponent = (
assetMediaType: AssetMediaType,
mediaShouldBePlaying: boolean,
setCurrentTokenPlayingMedia: (tokenId: string | undefined) => void
) => {
switch (assetMediaType) {
case AssetMediaType.Image:
return <Card.Image />
case AssetMediaType.Video:
return <Card.Video shouldPlay={mediaShouldBePlaying} setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia} />
case AssetMediaType.Audio:
return <Card.Audio shouldPlay={mediaShouldBePlaying} setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia} />
}
}
const getUnsupportedNftTextComponent = (asset: WalletAsset) => (
<Box as="span" className={bodySmall} style={{ color: themeVars.colors.textPrimary }}>
{asset.asset_contract.tokenType === NftStandard.Erc1155 ? (
<Trans>Selling ERC-1155s coming soon</Trans>
) : (
<Trans>Blocked from trading</Trans>
)}
</Box>
)
export const ViewMyNftsAsset = ({
asset,
mediaShouldBePlaying,
@ -67,8 +35,6 @@ export const ViewMyNftsAsset = ({
)
}, [asset, sellAssets])
const [showTooltip, setShowTooltip] = useState(false)
const isSelectedRef = useRef(isSelected)
const trace = useTrace()
const onCardClick = () => handleSelect(isSelected)
@ -93,65 +59,32 @@ export const ViewMyNftsAsset = ({
toggleCart()
}
useEffect(() => {
if (isSelected !== isSelectedRef.current) {
setShowTooltip(true)
isSelectedRef.current = isSelected
const tooltipTimer = setTimeout(() => {
setShowTooltip(false)
}, TOOLTIP_TIMEOUT)
return () => {
clearTimeout(tooltipTimer)
}
}
isSelectedRef.current = isSelected
return undefined
}, [isSelected, isSelectedRef])
const assetMediaType = Card.useAssetMediaType(asset)
const isDisabled = asset.asset_contract.tokenType === NftStandard.Erc1155 || asset.susFlag
const display: NftCardDisplayProps = useMemo(() => {
return {
primaryInfo: !!asset.asset_contract.name && asset.asset_contract.name,
primaryInfoIcon: asset.collectionIsVerified && <VerifiedIcon height="16px" width="16px" />,
secondaryInfo: asset.name || asset.tokenId ? asset.name ?? `#${asset.tokenId}` : null,
selectedInfo: <Trans>Remove from bag</Trans>,
notSelectedInfo: <Trans>List for sale</Trans>,
disabledInfo: <Trans>Unavailable for listing</Trans>,
}
}, [asset.asset_contract.name, asset.collectionIsVerified, asset.name, asset.tokenId])
return (
<Card.Container
<NftCard
asset={asset}
selected={isSelected}
addAssetToBag={() => handleSelect(false)}
removeAssetFromBag={() => handleSelect(true)}
display={display}
isSelected={isSelected}
isDisabled={Boolean(isDisabled)}
selectAsset={() => handleSelect(false)}
unselectAsset={() => handleSelect(true)}
onClick={onCardClick}
isDisabled={isDisabled}
>
<Card.ImageContainer isDisabled={isDisabled}>
<Tooltip
text={
<Box as="span" className={bodySmall} color="textPrimary">
{isSelected ? <Trans>Added to bag</Trans> : <Trans>Removed from bag</Trans>}
</Box>
}
show={showTooltip}
style={{ display: 'block' }}
offsetX={0}
offsetY={-68}
hideArrow={true}
placement="bottom"
>
<MouseoverTooltip
text={getUnsupportedNftTextComponent(asset)}
placement="bottom"
offsetX={0}
offsetY={-60}
hideArrow={true}
style={{ display: 'block' }}
disableHover={!isDisabled}
timeout={isMobile ? TOOLTIP_TIMEOUT : undefined}
>
{getNftDisplayComponent(assetMediaType, mediaShouldBePlaying, setCurrentTokenPlayingMedia)}
</MouseoverTooltip>
</Tooltip>
</Card.ImageContainer>
<Card.DetailsContainer>
<Card.ProfileNftDetails asset={asset} hideDetails={hideDetails} />
</Card.DetailsContainer>
</Card.Container>
mediaShouldBePlaying={mediaShouldBePlaying}
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
testId="nft-profile-asset"
doNotLinkToDetails={hideDetails}
/>
)
}