feat: collection asset cards (#4422)

* removing differentiating mobile and desktop collections

* feat: asset cards

* changed card to module + addressed other comments

* todo
This commit is contained in:
Jack Short 2022-08-22 12:15:17 -04:00 committed by GitHub
parent 293e56758c
commit c25971e5d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 837 additions and 74 deletions

@ -197,6 +197,7 @@
"react-dom": "^18.2.0",
"react-feather": "^2.0.8",
"react-ga4": "^1.4.1",
"react-infinite-scroll-component": "^6.1.0",
"react-is": "^17.0.2",
"react-markdown": "^4.3.1",
"react-popper": "^2.2.3",

@ -0,0 +1,132 @@
import { style } from '@vanilla-extract/css'
import { breakpoints, sprinkles, themeVars } from 'nft/css/sprinkles.css'
export const card = style([
sprinkles({
overflow: 'hidden',
borderStyle: 'solid',
borderWidth: '1px',
}),
{
boxSizing: 'border-box',
WebkitBoxSizing: 'border-box',
'@media': {
[`(max-width: ${breakpoints.tabletSm - 1}px)`]: {
':hover': {
borderColor: themeVars.colors.medGray,
cursor: 'pointer',
background: themeVars.colors.lightGrayOverlay,
},
},
},
},
])
export const notSelectedCard = style([
card,
sprinkles({
backgroundColor: 'lightGray',
borderColor: 'transparent',
}),
])
export const cardImageHover = style({
transform: 'scale(1.15)',
})
export const selectedCard = style([
card,
sprinkles({
background: 'lightGrayOverlay',
borderColor: 'medGray',
}),
])
export const button = style([
sprinkles({
display: 'flex',
width: 'full',
position: 'relative',
paddingY: '8',
marginTop: { mobile: '8', tabletSm: '10' },
marginBottom: '12',
borderRadius: '12',
border: 'none',
justifyContent: 'center',
transition: '250',
cursor: 'pointer',
}),
{
lineHeight: '16px',
},
])
export const marketplaceIcon = style([
sprinkles({
display: 'inline-block',
width: '16',
height: '16',
borderRadius: '4',
flexShrink: '0',
marginLeft: '8',
}),
{
verticalAlign: 'top',
},
])
export const erc1155ButtonRow = sprinkles({
flexShrink: '0',
gap: '12',
marginTop: { mobile: '8', tabletSm: '10' },
marginBottom: '12',
})
export const erc1155QuantityText = style([
sprinkles({
color: 'blackBlue',
}),
{
lineHeight: '20px',
userSelect: 'none',
},
])
export const erc1155Button = sprinkles({
display: 'flex',
justifyContent: 'center',
backgroundColor: 'white90',
textAlign: 'center',
background: 'none',
border: 'none',
borderRadius: 'round',
cursor: 'pointer',
padding: '0',
transition: '250',
})
export const erc1155PlusButton = style([
erc1155Button,
sprinkles({
color: 'magicGradient',
}),
{
':hover': {
backgroundColor: themeVars.colors.magicGradient,
color: themeVars.colors.blackBlue,
},
},
])
export const erc1155MinusButton = style([
erc1155Button,
sprinkles({
color: 'error',
}),
{
':hover': {
backgroundColor: themeVars.colors.error,
color: themeVars.colors.blackBlue,
},
},
])

@ -0,0 +1,358 @@
import clsx from 'clsx'
import Column from 'components/Column'
import { Box } from 'nft/components/Box'
import { Row } from 'nft/components/Flex'
import { MinusIconLarge, PlusIconLarge } from 'nft/components/icons'
import { body, subheadSmall } from 'nft/css/common.css'
import { themeVars, vars } from 'nft/css/sprinkles.css'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset } from 'nft/types'
import {
createContext,
MouseEvent,
ReactNode,
useContext,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import * as styles from './Card.css'
/* -------- ASSET CONTEXT -------- */
export interface CardContextProps {
asset: GenieAsset
hovered: boolean
selected: boolean
href: string
setHref: (href: string) => void
}
const CardContext = createContext<CardContextProps | undefined>(undefined)
const useCardContext = () => {
const context = useContext(CardContext)
if (!context) throw new Error('Must use context inside of provider')
return context
}
const baseHref = (asset: GenieAsset) => `/#/nft/asset/${asset.address}/${asset.tokenId}?origin=collection`
/* -------- ASSET CARD -------- */
interface CardProps {
asset: GenieAsset
children: ReactNode
}
const Container = ({ asset, children }: CardProps) => {
const [hovered, toggleHovered] = useReducer((s) => !s, false)
const [href, setHref] = useState(baseHref(asset))
const providerValue = useMemo(
() => ({
asset,
selected: false,
hovered,
toggleHovered,
href,
setHref,
}),
[asset, hovered, href]
)
const assetRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (hovered && assetRef.current?.matches(':hover') === false) toggleHovered()
}, [hovered])
return (
<CardContext.Provider value={providerValue}>
<Box
as="a"
href={href ? href : baseHref(asset)}
position={'relative'}
ref={assetRef}
borderRadius={'20'}
className={styles.notSelectedCard}
draggable={false}
onMouseEnter={() => toggleHovered()}
onMouseLeave={() => toggleHovered()}
transition="250"
>
{children}
</Box>
</CardContext.Provider>
)
}
/* -------- CARD IMAGE -------- */
const Image = () => {
const { hovered, asset } = useCardContext()
const [noContent, setNoContent] = useState(!asset.smallImageUrl && !asset.imageUrl)
const [loaded, setLoaded] = useState(false)
return (
<>
{!noContent ? (
<Box display="flex" overflow="hidden">
<Box
as={'img'}
alt={asset.name || asset.tokenId}
width="full"
style={{
aspectRatio: 'auto',
transition: 'transform 0.4s ease 0s',
background: loaded
? 'none'
: `linear-gradient(270deg, ${themeVars.colors.medGray} 0%, ${themeVars.colors.lightGray} 100%)`,
}}
src={asset.imageUrl || asset.smallImageUrl}
objectFit={'contain'}
draggable={false}
onError={() => setNoContent(true)}
onLoad={() => {
setLoaded(true)
}}
className={clsx(hovered && styles.cardImageHover)}
/>
</Box>
) : (
<NoContentContainer />
)}
</>
)
}
/* -------- CARD DETAILS CONTAINER -------- */
interface CardDetailsContainerProps {
children: ReactNode
}
const DetailsContainer = ({ children }: CardDetailsContainerProps) => {
return (
<Row
position="relative"
paddingX="12"
paddingTop="12"
justifyContent="space-between"
flexDirection="column"
transition="250"
>
{children}
</Row>
)
}
const InfoContainer = ({ children }: { children: ReactNode }) => {
return (
<Box overflow="hidden" width="full">
{children}
</Box>
)
}
const PrimaryRow = ({ children }: { children: ReactNode }) => <Row justifyContent="space-between">{children}</Row>
const PrimaryDetails = ({ children }: { children: ReactNode }) => (
<Row overflow="hidden" whiteSpace="nowrap">
{children}
</Row>
)
const PrimaryInfo = ({ children }: { children: ReactNode }) => {
return (
<Box
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
color="blackBlue"
fontWeight="medium"
fontSize="14"
style={{ lineHeight: '20px' }}
>
{children}
</Box>
)
}
const SecondaryRow = ({ children }: { children: ReactNode }) => (
<Row height="20" justifyContent="space-between" marginTop="6">
{children}
</Row>
)
const SecondaryDetails = ({ children }: { children: ReactNode }) => <Row>{children}</Row>
const SecondaryInfo = ({ children }: { children: ReactNode }) => {
return (
<Box
color="blackBlue"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
fontSize="16"
fontWeight="medium"
style={{ lineHeight: '20px' }}
>
{children}
</Box>
)
}
const TertiaryInfo = ({ children }: { children: ReactNode }) => {
return (
<Box marginTop={'8'} color="darkGray">
{children}
</Box>
)
}
interface ButtonProps {
children: ReactNode
selectedChildren: ReactNode
onClick: (e: MouseEvent) => void
onSelectedClick: (e: MouseEvent) => void
}
const Button = ({ children, selectedChildren, onClick, onSelectedClick }: ButtonProps) => {
const [buttonHovered, toggleButtonHovered] = useReducer((s) => !s, false)
const { asset, selected, setHref } = useCardContext()
const buttonRef = useRef<HTMLDivElement>(null)
const isMobile = useIsMobile()
useLayoutEffect(() => {
if (buttonHovered && buttonRef.current?.matches(':hover') === false) toggleButtonHovered()
}, [buttonHovered])
return (
<>
{!selected || asset.tokenType !== 'ERC1155' ? (
<Box
as="button"
ref={buttonRef}
color={
buttonHovered || isMobile
? 'explicitWhite'
: selected
? 'error'
: asset.notForSale
? 'placeholder'
: 'blue400'
}
style={{
background: `${
buttonHovered || isMobile
? selected
? vars.color.error
: vars.color.blue400
: selected
? '#FA2B391F'
: '#4C82FB1F'
}`,
}}
className={clsx(styles.button, subheadSmall)}
onClick={(e) =>
selected
? onSelectedClick(e)
: asset.notForSale
? () => {
return true
}
: onClick(e)
}
onMouseEnter={() => {
!asset.notForSale && setHref('')
!buttonHovered && toggleButtonHovered()
}}
onMouseLeave={() => {
!asset.notForSale && setHref(baseHref(asset))
buttonHovered && toggleButtonHovered()
}}
transition="250"
>
{selected
? selectedChildren
: asset.notForSale
? buttonHovered || isMobile
? 'See details'
: 'Not for sale'
: children}
</Box>
) : (
<Row className={styles.erc1155ButtonRow}>
<Column
as="button"
className={styles.erc1155MinusButton}
onClick={(e: MouseEvent<Element, globalThis.MouseEvent>) => onSelectedClick(e)}
>
<MinusIconLarge width="32" height="32" />
</Column>
<Box className={`${styles.erc1155QuantityText} ${subheadSmall}`}></Box>
<Column
as="button"
className={styles.erc1155PlusButton}
onClick={(e: MouseEvent<Element, globalThis.MouseEvent>) => onClick(e)}
>
<PlusIconLarge width="32" height="32" />
</Column>
</Row>
)}
</>
)
}
const MarketplaceIcon = ({ marketplace }: { marketplace: string }) => {
return (
<Box
as="img"
alt={marketplace}
src={`/nft/svgs/marketplaces/${marketplace}.svg`}
className={styles.marketplaceIcon}
/>
)
}
const NoContentContainer = () => (
<Box
position="relative"
width="full"
style={{
paddingTop: '100%',
background: `linear-gradient(270deg, ${themeVars.colors.medGray} 0%, ${themeVars.colors.lightGray} 100%)`,
}}
>
<Box
position="absolute"
textAlign="center"
left="1/2"
top="1/2"
style={{ transform: 'translate3d(-50%, -50%, 0)' }}
fontWeight="normal"
color="grey500"
className={body}
>
Content not
<br />
available yet
</Box>
</Box>
)
export {
Button,
Container,
DetailsContainer,
Image,
InfoContainer,
MarketplaceIcon,
PrimaryDetails,
PrimaryInfo,
PrimaryRow,
SecondaryDetails,
SecondaryInfo,
SecondaryRow,
TertiaryInfo,
}

@ -0,0 +1,142 @@
import { style } from '@vanilla-extract/css'
import { sprinkles, vars } from '../../css/sprinkles.css'
export const assetInnerStyle = style([
sprinkles({
borderRadius: '20',
position: 'relative',
cursor: 'pointer',
}),
{
border: `4px solid ${vars.color.white}`,
},
])
export const hoverAsset = style({
border: `4px solid ${vars.color.genieBlue}`,
boxShadow: '0 4px 16px rgba(70,115,250,0.4)',
})
export const assetSelected = style([
sprinkles({
borderRadius: '20',
}),
{
border: `4px solid ${vars.color.genieBlue}`,
},
])
export const buy = style([
{
top: '-32px',
left: '50%',
transform: 'translateX(-50%)',
},
sprinkles({ color: 'white', position: 'absolute', borderRadius: 'round' }),
])
export const tokenQuantityHovered = style([
{
border: `4px solid ${vars.color.white}`,
display: 'flex',
justifyContent: 'space-between',
},
sprinkles({
backgroundColor: 'genieBlue',
borderRadius: 'round',
}),
])
export const tokenQuantity = style([
{
padding: '10px 17px',
border: `4px solid ${vars.color.white}`,
},
sprinkles({
color: 'genieBlue',
backgroundColor: 'white',
borderRadius: 'round',
textAlign: 'center',
}),
])
export const plusIcon = style([
{
padding: '10px',
border: `4px solid ${vars.color.white}`,
},
sprinkles({
width: '28',
backgroundColor: 'genieBlue',
borderRadius: 'round',
}),
])
export const bagIcon = style([
{
width: '42px',
padding: '9px',
},
sprinkles({
borderRadius: 'round',
backgroundColor: 'white',
}),
])
export const minusIcon = style([
{
width: '11px',
padding: '19px 14px 8px 16px',
},
sprinkles({
position: 'relative',
}),
])
export const plusQuantityIcon = style([
{
width: '12px',
padding: '11px 16px 8px 12px',
},
sprinkles({
position: 'relative',
}),
])
export const quantity = style([
{
padding: '9px 4px 8px',
},
sprinkles({
position: 'relative',
}),
])
export const details = style({ float: 'right' })
export const marketplace = style({
position: 'absolute',
left: '0',
bottom: '12px',
})
export const placeholderImage = style({ width: '50%', padding: '25%' })
export const ethIcon = style({ display: 'inline-block', marginBottom: '-3px', overflow: 'auto' })
export const rarityInfo = style({
background: 'rgba(255, 255, 255, 0.6)',
backdropFilter: 'blur(6px)',
})
export const iconToolTip = style([
sprinkles({
display: 'inline-block',
overflow: 'auto',
marginRight: '4',
}),
{
marginBottom: '-3px',
},
])

@ -0,0 +1,57 @@
import { BigNumber } from '@ethersproject/bignumber'
import * as Card from 'nft/components/collection/Card'
import { MouseEvent, useMemo } from 'react'
import { GenieAsset } from '../../types'
import { formatWeiToDecimal } from '../../utils/currency'
export const CollectionAsset = ({ asset }: { asset: GenieAsset }) => {
// ignore structure more will go inside
const { notForSale } = useMemo(() => {
if (asset) {
return {
notForSale: asset.notForSale || BigNumber.from(asset.currentEthPrice ? asset.currentEthPrice : 0).lt(0),
}
} else {
return {
notForSale: true,
}
}
}, [asset])
return (
<Card.Container asset={asset}>
<Card.Image />
<Card.DetailsContainer>
<Card.InfoContainer>
<Card.PrimaryRow>
<Card.PrimaryDetails>
<Card.PrimaryInfo>{asset.name ? asset.name : `#${asset.tokenId}`}</Card.PrimaryInfo>
</Card.PrimaryDetails>
</Card.PrimaryRow>
<Card.SecondaryRow>
<Card.SecondaryDetails>
<Card.SecondaryInfo>
{notForSale ? '' : `${formatWeiToDecimal(asset.currentEthPrice)} ETH`}
</Card.SecondaryInfo>
</Card.SecondaryDetails>
{asset.tokenType !== 'ERC1155' && asset.marketplace && (
<Card.MarketplaceIcon marketplace={asset.marketplace} />
)}
</Card.SecondaryRow>
</Card.InfoContainer>
<Card.Button
selectedChildren={'Remove'}
onClick={(e: MouseEvent) => {
e.preventDefault()
}}
onSelectedClick={(e: MouseEvent) => {
e.preventDefault()
}}
>
{'Buy now'}
</Card.Button>
</Card.DetailsContainer>
</Card.Container>
)
}

@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css'
import { sprinkles } from '../../css/sprinkles.css'
export const assetList = style([
sprinkles({
display: 'grid',
marginTop: '24',
gap: { mobile: '8', tablet: '12', tabletXl: '20' },
}),
{
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr) )',
'@media': {
'screen and (min-width: 708px)': {
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr) )',
},
'screen and (min-width: 1185px)': {
gridTemplateColumns: 'repeat(auto-fill, minmax(1fr, 280px) )',
},
},
},
])

@ -0,0 +1,83 @@
import clsx from 'clsx'
import { Box } from 'nft/components/Box'
import { CollectionAsset } from 'nft/components/collection/CollectionAsset'
import * as styles from 'nft/components/collection/CollectionNfts.css'
import { Center } from 'nft/components/Flex'
import { bodySmall, buttonTextMedium, header2 } from 'nft/css/common.css'
import { AssetsFetcher } from 'nft/queries'
import { useMemo } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { useInfiniteQuery } from 'react-query'
interface CollectionNftsProps {
contractAddress: string
}
export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
const {
data: collectionAssets,
isSuccess: AssetsFetchSuccess,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery(
[
'collectionNfts',
{
contractAddress,
},
],
async ({ pageParam = 0 }) => {
return await AssetsFetcher({
contractAddress,
pageParam,
})
},
{
getNextPageParam: (lastPage, pages) => {
return lastPage?.flat().length === 25 ? pages.length : null
},
refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: 5000,
}
)
const collectionNfts = useMemo(() => {
if (!collectionAssets || !AssetsFetchSuccess) return undefined
return collectionAssets.pages.flat()
}, [collectionAssets, AssetsFetchSuccess])
if (!collectionNfts) {
// TODO: collection unavailable page
return <div>No CollectionAssets</div>
}
return (
<InfiniteScroll
next={fetchNextPage}
hasMore={hasNextPage ?? false}
loader={hasNextPage ? <p>Loading from scroll...</p> : null}
dataLength={collectionNfts.length}
style={{ overflow: 'unset' }}
>
{collectionNfts.length > 0 ? (
<div className={styles.assetList}>
{collectionNfts.map((asset) => {
return asset ? <CollectionAsset asset={asset} key={asset.address + asset.tokenId} /> : null
})}
</div>
) : (
<Center width="full" color="darkGray" style={{ height: '60vh' }}>
<div style={{ display: 'block', textAlign: 'center' }}>
<p className={header2}>No NFTS found</p>
<Box className={clsx(bodySmall, buttonTextMedium)} color="blue" cursor="pointer">
View full collection
</Box>
</div>
</Center>
)}
</InfiniteScroll>
)
}

@ -1,30 +0,0 @@
import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionStats } from 'nft/components/collection/CollectionStats'
import { Column, Row } from 'nft/components/Flex'
import { CollectionProps } from 'nft/pages/collection/common'
import * as styles from 'nft/pages/collection/common.css'
export const CollectionDesktop = ({ collectionStats }: CollectionProps) => {
return (
<Column width="full">
<Box width="full" height="160">
<Box
as="img"
maxHeight="full"
width="full"
src={collectionStats?.bannerImageUrl}
className={`${styles.bannerImage}`}
/>
</Box>
{collectionStats && (
<Row paddingLeft="32" paddingRight="32">
<CollectionStats stats={collectionStats} isMobile={false} />
</Row>
)}
<Row alignItems="flex-start" position="relative" paddingLeft="32" paddingRight="32">
<AnimatedBox width="full">CollectionNfts</AnimatedBox>
</Row>
</Column>
)
}

@ -1,30 +0,0 @@
import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionStats } from 'nft/components/collection/CollectionStats'
import { Column, Row } from 'nft/components/Flex'
import { CollectionProps } from 'nft/pages/collection/common'
import * as styles from 'nft/pages/collection/common.css'
export const CollectionMobile = ({ collectionStats }: CollectionProps) => {
return (
<Column width="full">
<Box width="full" height="160">
<Box
as="img"
maxHeight="full"
width="full"
src={collectionStats?.bannerImageUrl}
className={`${styles.bannerImage}`}
/>
</Box>
{collectionStats && (
<Row paddingLeft="32" paddingRight="32">
<CollectionStats stats={collectionStats} isMobile={true} />
</Row>
)}
<Row alignItems="flex-start" position="relative" paddingLeft="32" paddingRight="32">
<AnimatedBox width="full">CollectionNfts</AnimatedBox>
</Row>
</Column>
)
}

@ -1,5 +0,0 @@
import { GenieCollection } from 'nft/types'
export interface CollectionProps {
collectionStats: GenieCollection | undefined
}

@ -1,11 +1,13 @@
import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionNfts } from 'nft/components/collection/CollectionNfts'
import { CollectionStats } from 'nft/components/collection/CollectionStats'
import { Column, Row } from 'nft/components/Flex'
import { useIsMobile } from 'nft/hooks/useIsMobile'
import * as styles from 'nft/pages/collection/index.css'
import { CollectionStatsFetcher } from 'nft/queries'
import { useQuery } from 'react-query'
import { useParams } from 'react-router-dom'
import { useIsMobile } from '../../hooks/useIsMobile'
import { CollectionStatsFetcher } from '../../queries'
import { CollectionDesktop } from './CollectionDesktop'
import { CollectionMobile } from './CollectionMobile'
const Collection = () => {
const { contractAddress } = useParams()
@ -15,10 +17,29 @@ const Collection = () => {
CollectionStatsFetcher(contractAddress as string)
)
return isMobile ? (
<CollectionMobile collectionStats={collectionStats} />
) : (
<CollectionDesktop collectionStats={collectionStats} />
return (
<Column width="full">
<Box width="full" height="160">
<Box
as="img"
maxHeight="full"
width="full"
src={collectionStats?.bannerImageUrl}
className={`${styles.bannerImage}`}
/>
</Box>
{collectionStats && (
<Row paddingLeft="32" paddingRight="32">
<CollectionStats stats={collectionStats} isMobile={isMobile} />
</Row>
)}
<Row alignItems="flex-start" position="relative" paddingLeft="32" paddingRight="32">
<AnimatedBox width="full">
{contractAddress && <CollectionNfts contractAddress={contractAddress} />}
</AnimatedBox>
</Row>
</Column>
)
}

@ -14662,6 +14662,13 @@ react-ga4@^1.4.1:
resolved "https://registry.yarnpkg.com/react-ga4/-/react-ga4-1.4.1.tgz#6ee2a2db115ed235b2f2092bc746b4eeeca9e206"
integrity sha512-ioBMEIxd4ePw4YtaloTUgqhQGqz5ebDdC4slEpLgy2sLx1LuZBC9iYCwDymTXzcntw6K1dHX183ulP32nNdG7w==
react-infinite-scroll-component@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f"
integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==
dependencies:
throttle-debounce "^2.1.0"
react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.6:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -16613,6 +16620,11 @@ throat@^5.0.0:
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==
throttle-debounce@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2"
integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==
throttleit@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"