feat: render blurred collection cover photo in the header (#5233)

* initial commit

* feat: blurred header

* chore: replace with helper

* chore: cleanup

* chore: different extension

* chore: layout tweaks

* chore: tweaks

* chore: prevent weird text selection on double click

* chore: wip for linear gradient/plain color light mode

* feat: linear-gradient when image missing

* chore: clean up post merge

* feat: different opacity for dark/light mode

* chore: fix paddings
This commit is contained in:
Mike Grabowski 2022-11-16 16:58:18 +01:00 committed by GitHub
parent 779a699ff0
commit 78b6ef60ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 159 additions and 86 deletions

@ -2,23 +2,58 @@ import { useLoadCollectionQuery } from 'graphql/data/nft/Collection'
import { useIsMobile } from 'nft/hooks' import { useIsMobile } from 'nft/hooks'
import { fetchTrendingCollections } from 'nft/queries' import { fetchTrendingCollections } from 'nft/queries'
import { TimePeriod } from 'nft/types' import { TimePeriod } from 'nft/types'
import { Suspense, useMemo } from 'react' import { calculateCardIndex } from 'nft/utils'
import { Suspense, useCallback, useMemo, useState } from 'react'
import { useQuery } from 'react-query' import { useQuery } from 'react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { opacify } from 'theme/utils'
import { Carousel } from './Carousel' import { Carousel, LoadingCarousel } from './Carousel'
import { CarouselCard, LoadingCarouselCard } from './CarouselCard' import { CarouselCard, LoadingCarouselCard } from './CarouselCard'
const BannerContainer = styled.div` const BannerContainer = styled.div`
display: flex;
justify-content: center;
width: 100%;
padding: 32px 16px 0 16px;
position: relative;
overflow: hidden;
`
const AbsoluteFill = styled.div`
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
opacity: ${({ theme }) => (theme.darkMode ? 0.4 : 0.2)};
`
const BannerBackground = styled(AbsoluteFill)<{ backgroundImage: string }>`
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: ${(props) => `url(${props.backgroundImage})`};
filter: blur(62px);
`
const PlainBackground = styled(AbsoluteFill)`
background: ${({ theme }) => `linear-gradient(${opacify(10, theme.userThemeColor)}, transparent)`};
`
const BannerMainArea = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
height: 320px; height: 100%;
margin-top: 24px;
gap: 36px; gap: 36px;
max-width: 1200px; max-width: 1200px;
justify-content: space-between; justify-content: space-between;
z-index: 2;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) { @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
flex-direction: column; flex-direction: column;
@ -37,8 +72,10 @@ const HeaderContainer = styled.div`
line-height: 88px; line-height: 88px;
justify-content: start; justify-content: start;
align-items: start; align-items: start;
padding-top: 40px; align-self: center;
flex-shrink: 0; flex-shrink: 0;
padding-bottom: 32px;
color: ${({ theme }) => theme.textPrimary}; color: ${({ theme }) => theme.textPrimary};
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) { @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) {
@ -57,6 +94,7 @@ const HeaderContainer = styled.div`
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding-top: 0px; padding-top: 0px;
padding-bottom: 0px;
} }
` `
@ -93,14 +131,33 @@ const Banner = () => {
const collectionAddresses = useMemo(() => collections?.map(({ address }) => address), [collections]) const collectionAddresses = useMemo(() => collections?.map(({ address }) => address), [collections])
useLoadCollectionQuery(collectionAddresses) useLoadCollectionQuery(collectionAddresses)
const [activeCollectionIdx, setActiveCollectionIdx] = useState(0)
const onToggleNextSlide = useCallback(
(direction: number) => {
if (!collections) return
setActiveCollectionIdx((idx) => calculateCardIndex(idx + direction, collections.length))
},
[collections]
)
const activeCollection = collections?.[activeCollectionIdx]
return ( return (
<BannerContainer> <BannerContainer>
{activeCollection ? (
activeCollection.bannerImageUrl ? (
<BannerBackground backgroundImage={activeCollection.bannerImageUrl} />
) : (
<PlainBackground />
)
) : null}
<BannerMainArea>
<HeaderContainer> <HeaderContainer>
Better prices. {!isMobile && <br />} Better prices. {!isMobile && <br />}
More listings. More listings.
</HeaderContainer> </HeaderContainer>
{collections ? ( {collections ? (
<Carousel> <Carousel activeIndex={activeCollectionIdx} toggleNextSlide={onToggleNextSlide}>
{collections.map((collection) => ( {collections.map((collection) => (
<Suspense fallback={<LoadingCarouselCard collection={collection} />} key={collection.address}> <Suspense fallback={<LoadingCarouselCard collection={collection} />} key={collection.address}>
<CarouselCard <CarouselCard
@ -112,12 +169,11 @@ const Banner = () => {
))} ))}
</Carousel> </Carousel>
) : ( ) : (
<Carousel> <LoadingCarousel>
{[...Array(TRENDING_COLLECTION_SIZE)].map((index) => ( <LoadingCarouselCard />
<LoadingCarouselCard key={'carouselCard' + index} /> </LoadingCarousel>
))}
</Carousel>
)} )}
</BannerMainArea>
</BannerContainer> </BannerContainer>
) )
} }

@ -1,60 +1,69 @@
import { useWindowSize } from 'hooks/useWindowSize' import { useWindowSize } from 'hooks/useWindowSize'
import { ChevronLeftIcon } from 'nft/components/icons' import { ChevronLeftIcon, ChevronRightIcon } from 'nft/components/icons'
import { calculateCardIndex, calculateFirstCardIndex, calculateRank } from 'nft/utils' import { calculateCardIndex, calculateFirstCardIndex, calculateRank } from 'nft/utils'
import { ReactNode, useCallback, useEffect, useReducer, useRef, useState } from 'react' import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { a, useSprings } from 'react-spring' import { a, useSprings } from 'react-spring'
import styled from 'styled-components/macro' import styled, { css } from 'styled-components/macro'
const CarouselContainer = styled.div` const MAX_CARD_WIDTH = 530
display: flex;
max-width: 592px;
width: 100%;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) { const carouselHeightStyle = css`
height: 320px; height: 315px;
}
`
const CarouselCardContainer = styled.div`
max-width: 512px;
position: relative;
width: 100%;
overflow-x: hidden;
`
const CarouselCard = styled(a.div)`
position: absolute;
padding-left: 16px;
padding-right: 16px;
display: flex;
top: 3px;
height: 280px;
will-change: transform;
justify-content: center;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) { @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
height: 296px; height: 296px;
} }
` `
const IconContainer = styled.div<{ right?: boolean }>` const carouselItemStyle = css`
padding-top: 3px;
padding-bottom: 32px;
`
const CarouselContainer = styled.div`
display: flex;
width: 100%;
justify-content: flex-end;
`
const CarouselCardContainer = styled.div`
${carouselHeightStyle}
position: relative;
width: 100%;
max-width: ${MAX_CARD_WIDTH}px;
overflow-x: hidden;
`
const CarouselItemCard = styled(a.div)`
${carouselHeightStyle}
${carouselItemStyle}
display: flex; display: flex;
height: 280px;
justify-content: center; justify-content: center;
padding: 3px 32px 32px 32px;
position: absolute;
will-change: transform;
`
const CarouselItemIcon = styled.div`
${carouselHeightStyle}
${carouselItemStyle}
display: flex;
align-items: center; align-items: center;
padding-left: 8px;
padding-right: 8px;
cursor: pointer; cursor: pointer;
padding: 8px; user-select: none;
${({ right }) => (right ? 'transform: rotate(180deg)' : undefined)};
color: ${({ theme }) => theme.textPrimary}; color: ${({ theme }) => theme.textPrimary};
:hover { :hover {
opacity: ${({ theme }) => theme.opacity.hover}; opacity: ${({ theme }) => theme.opacity.hover};
} }
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
height: 296px;
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) { @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
display: none; display: none;
} }
@ -62,16 +71,16 @@ const IconContainer = styled.div<{ right?: boolean }>`
interface CarouselProps { interface CarouselProps {
children: ReactNode[] children: ReactNode[]
activeIndex: number
toggleNextSlide: (idx: number) => void
} }
const FIRST_CARD_OFFSET = 0 const FIRST_CARD_OFFSET = 0
const MAX_CARD_WIDTH = 512
export const Carousel = ({ children }: CarouselProps) => { export const Carousel = ({ children, activeIndex, toggleNextSlide }: CarouselProps) => {
const { width } = useWindowSize() const { width } = useWindowSize()
const carouselCardContainerRef = useRef<HTMLDivElement>(null) const carouselCardContainerRef = useRef<HTMLDivElement>(null)
const [cardWidth, setCardWidth] = useState(MAX_CARD_WIDTH) const [cardWidth, setCardWidth] = useState(MAX_CARD_WIDTH)
const [resetTimer, toggleResetTimer] = useReducer((state) => !state, false)
useEffect(() => { useEffect(() => {
if (carouselCardContainerRef.current) { if (carouselCardContainerRef.current) {
@ -108,21 +117,18 @@ export const Carousel = ({ children }: CarouselProps) => {
[idx, getPos, set, cardWidth, children.length] [idx, getPos, set, cardWidth, children.length]
) )
useEffect(() => { const direction = useRef(0)
runSprings(index.current, 0)
}, [runSprings])
const index = useRef(0) useEffect(() => {
runSprings(activeIndex * cardWidth, direction.current)
}, [activeIndex, cardWidth, runSprings])
const toggleSlide = useCallback( const toggleSlide = useCallback(
(next: -1 | 1) => { (next: -1 | 1) => {
const offset = cardWidth * next direction.current = next
index.current += offset toggleNextSlide(next)
runSprings(index.current, next)
toggleResetTimer()
}, },
[runSprings, cardWidth] [toggleNextSlide]
) )
useEffect(() => { useEffect(() => {
@ -132,16 +138,16 @@ export const Carousel = ({ children }: CarouselProps) => {
return () => { return () => {
clearInterval(interval) clearInterval(interval)
} }
}, [toggleSlide, resetTimer]) }, [toggleSlide, activeIndex])
return ( return (
<CarouselContainer> <CarouselContainer>
<IconContainer onClick={() => toggleSlide(-1)}> <CarouselItemIcon onClick={() => toggleSlide(-1)}>
<ChevronLeftIcon width="16px" height="16px" /> <ChevronLeftIcon width="16px" height="16px" />
</IconContainer> </CarouselItemIcon>
<CarouselCardContainer ref={carouselCardContainerRef}> <CarouselCardContainer ref={carouselCardContainerRef}>
{springs.map(({ x }, i) => ( {springs.map(({ x }, i) => (
<CarouselCard <CarouselItemCard
key={i} key={i}
style={{ style={{
width: cardWidth, width: cardWidth,
@ -149,12 +155,18 @@ export const Carousel = ({ children }: CarouselProps) => {
}} }}
> >
{children[i]} {children[i]}
</CarouselCard> </CarouselItemCard>
))} ))}
</CarouselCardContainer> </CarouselCardContainer>
<IconContainer right onClick={() => toggleSlide(1)}> <CarouselItemIcon onClick={() => toggleSlide(1)}>
<ChevronLeftIcon width="16px" height="16px" /> <ChevronRightIcon width="16px" height="16px" />
</IconContainer> </CarouselItemIcon>
</CarouselContainer> </CarouselContainer>
) )
} }
export const LoadingCarousel = ({ children }: { children: ReactNode }) => (
<Carousel activeIndex={0} toggleNextSlide={() => undefined}>
{[children]}
</Carousel>
)

@ -22,6 +22,10 @@ const ExploreContainer = styled.div`
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
max-width: 1200px; max-width: 1200px;
padding-left: 16px;
padding-right: 16px;
padding-top: 36px;
` `
const StyledHeader = styled.div` const StyledHeader = styled.div`

@ -174,6 +174,8 @@ export const EllipsisIcon = (props: SVGProps) => (
</svg> </svg>
) )
export const ChevronRightIcon = (props: SVGProps) => <ChevronLeftIcon {...props} transform="rotate(180)" />
export const LightningBoltIcon = (props: SVGProps) => ( export const LightningBoltIcon = (props: SVGProps) => (
<svg {...props} width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg {...props} width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path

@ -9,7 +9,6 @@ const ExploreContainer = styled.div`
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 16px;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) { @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
gap: 16px; gap: 16px;