diff --git a/src/components/Card3D.tsx b/src/components/Card3D.tsx new file mode 100644 index 000000000..8db814453 --- /dev/null +++ b/src/components/Card3D.tsx @@ -0,0 +1,202 @@ +/* +Copyright 2018 - 2023 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { motion, Transition, useMotionValue, useSpring, useTransform } from 'framer-motion' +import { PointerEvent, ReactNode, useEffect, useState } from 'react' +import styled, { useTheme } from 'styled-components' + +import { getPointerRelativePositionInElement } from '@/utils/pointer' + +// TODO: Copied from explorer + +interface Card3DProps { + frontFace: ReactNode + backFace: ReactNode + onPointerMove?: (pointerX: number, pointerY: number) => void + onCardHover?: (isHovered: boolean) => void + onCardFlip?: (isFlipped: boolean) => void + className?: string +} + +export const card3DHoverTransition: Transition = { + type: 'spring', + stiffness: 1000, + damping: 100 +} + +const Card3D = ({ frontFace, backFace, onPointerMove, onCardFlip, onCardHover, className }: Card3DProps) => { + const theme = useTheme() + const [isHovered, setIsHovered] = useState(false) + const [isFlipped, setIsFlipped] = useState(false) + + const angle = 10 + + const y = useMotionValue(0.5) + const x = useMotionValue(0.5) + + const springConfig = { damping: 10, stiffness: 100 } + const xSpring = useSpring(x, springConfig) + const ySpring = useSpring(y, springConfig) + + const rotateY = useTransform(xSpring, [0, 1], [-angle, angle], { + clamp: true + }) + const rotateX = useTransform(ySpring, [0, 1], [angle, -angle], { + clamp: true + }) + + const reflectionTranslationX = useTransform(xSpring, [0, 1], [angle * 1.5, -angle * 1.5], { + clamp: true + }) + + const reflectionTranslationY = useTransform(ySpring, [0, 1], [angle * 3, -angle * 3], { + clamp: true + }) + + const handlePointerMove = (e: PointerEvent) => { + const { x: positionX, y: positionY } = getPointerRelativePositionInElement(e) + + x.set(positionX, true) + y.set(isFlipped ? 1 - positionY : positionY, true) + + onPointerMove && onPointerMove(positionX, positionY) + } + + useEffect(() => { + onCardFlip && onCardFlip(isFlipped) + }, [isFlipped, onCardFlip]) + + useEffect(() => { + onCardHover && onCardHover(isHovered) + }, [isHovered, onCardHover]) + + return ( + setIsHovered(true)} + onPointerLeave={() => { + setIsHovered(false) + setIsFlipped(false) + x.set(0.5, true) + y.set(0.5, true) + }} + onPointerMove={handlePointerMove} + onClick={() => setIsFlipped((p) => !p)} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + whileHover={{ zIndex: 3, cursor: 'pointer' }} + > + + + + {frontFace} + + + + + {backFace} + + + + ) +} + +const Card3DStyled = styled(motion.div)` + position: relative; + perspective: 1000px; +` + +const FlippingContainer = styled(motion.div)` + transform-style: preserve-3d; +` + +const CardContainer = styled(motion.div)` + position: relative; + height: 205px; + transform-style: preserve-3d; + flex: 1; + + border-radius: 9px; + border-style: solid; + border-width: 1px; + background-color: ${({ theme }) => theme.bg.primary}; + + border-color: ${({ theme }) => theme.border.secondary}; +` + +const CardFace = styled.div` + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; +` + +const FrontFaceContainer = styled(CardFace)`` + +const BackFaceContainer = styled(CardFace)` + transform: rotateY(180deg); +` +const ReflectionClipper = styled.div` + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + overflow: hidden; + border-radius: 9px; +` + +const MovingReflection = styled(motion.div)` + position: absolute; + background: linear-gradient( + 60deg, + transparent 40%, + rgba(255, 255, 255, 0.3) 70%, + rgba(255, 255, 255, 0.3) 80%, + transparent 90% + ); + pointer-events: none; + + inset: -50px; + z-index: 10; +` + +export default Card3D diff --git a/src/components/CursorHighlight.tsx b/src/components/CursorHighlight.tsx new file mode 100644 index 000000000..477dfdebd --- /dev/null +++ b/src/components/CursorHighlight.tsx @@ -0,0 +1,83 @@ +/* +Copyright 2018 - 2023 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { motion } from 'framer-motion' +import { PointerEvent, useState } from 'react' +import styled from 'styled-components' + +import { getPointerAbsolutePositionInElement } from '@/utils/pointer' + +// TODO: Copied from explorer + +interface CursorHighlightProps { + className?: string +} + +const CursorHighlight = ({ className }: CursorHighlightProps) => { + const [visible, setVisible] = useState(false) + const [position, setPosition] = useState({ x: 0, y: 0 }) + + const handlePointerEnter = () => (!visible ? setVisible(true) : null) + const handlePointerLeave = () => (visible ? setVisible(false) : null) + + const handlePointerMove = (e: PointerEvent) => { + const position = getPointerAbsolutePositionInElement(e) + + setPosition(position) + } + + return ( + + + + + + ) +} + +const Container = styled(motion.div)` + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: hidden; +` + +const HighlightContainer = styled(motion.div)` + height: 900px; + width: 900px; +` + +const Highlight = styled(motion.div)` + height: 100%; + width: 100%; + border-radius: 100%; + background: radial-gradient( + ${({ theme }) => (theme.name === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)')} 0%, + transparent 40% + ); + transform: translateX(-50%) translateY(-50%); +` + +export default CursorHighlight diff --git a/src/components/NFTCard.tsx b/src/components/NFTCard.tsx new file mode 100644 index 000000000..ebce06fe5 --- /dev/null +++ b/src/components/NFTCard.tsx @@ -0,0 +1,155 @@ +/* +Copyright 2018 - 2023 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion' +import { useState } from 'react' +import styled from 'styled-components' + +import Card3D, { card3DHoverTransition } from '@/components/Card3D' +import Truncate from '@/components/Truncate' +import { NFT } from '@/types/assets' + +interface NFTCardProps { + nft: NFT +} + +// TODO: Copied from explorer + +const NFTCard = ({ nft }: NFTCardProps) => { + const [isHovered, setIsHovered] = useState(false) + + const y = useMotionValue(0.5) + const x = useMotionValue(0.5) + + const springConfig = { damping: 10, stiffness: 100 } + const xSpring = useSpring(x, springConfig) + const ySpring = useSpring(y, springConfig) + + const imagePosX = useTransform(xSpring, [0, 1], ['5px', '-5px'], { + clamp: true + }) + const imagePosY = useTransform(ySpring, [0, 1], ['5px', '-5px'], { + clamp: true + }) + + const handlePointerMove = (pointerX: number, pointerY: number) => { + x.set(pointerX, true) + y.set(pointerY, true) + } + + return ( + + + + + + {nft?.name} + + } + backFace={ + + + {nft?.description} + + } + /> + ) +} + +export default NFTCard + +const NFTCardStyled = styled(Card3D)` + background-color: ${({ theme }) => theme.bg.primary}; +` + +const FrontFace = styled.div` + padding: 10px; +` + +const BackFace = styled.div` + padding: 20px; + height: 100%; + background-color: ${({ theme }) => theme.bg.background2}; + border-radius: 9px; + position: relative; +` + +const NFTPictureContainer = styled(motion.div)` + position: relative; + border-radius: 9px; + overflow: hidden; +` + +const PictureContainerShadow = styled(motion.div)` + position: absolute; + height: 100%; + width: 100%; + box-shadow: inset 0 0 30px black; + z-index: 2; +` + +const NFTPicture = styled(motion.div)` + max-width: 100%; + height: 150px; + background-repeat: no-repeat; + background-color: black; + background-size: contain; + background-position: center; +` + +const NFTName = styled(Truncate)` + margin-top: 15px; + font-weight: 600; + margin: 15px 0; + max-width: 100%; +` + +const NFTDescription = styled.div` + display: -webkit-box; + -webkit-line-clamp: 10; + -webkit-box-orient: vertical; + overflow: hidden; + position: relative; +` + +const BackFaceBackground = styled.div` + position: absolute; + background-size: cover; + background-repeat: no-repeat; + top: 0; + bottom: 0; + right: 0; + left: 0; + border-radius: 9px; + opacity: 0.1; +` diff --git a/src/components/Table.tsx b/src/components/Table.tsx index ad3226a34..16c18a649 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -228,7 +228,7 @@ export const ExpandableTable = styled(Table)<{ isExpanded: boolean; maxHeightInP &:hover { ${ExpandRowStyled} { opacity: 1; - z-index: 1; // Make sure it is displayed above copy btns + z-index: 3; // Make sure it is displayed above copy btns } ${ExpandButton} { diff --git a/src/modals/TransactionDetailsModal.tsx b/src/modals/TransactionDetailsModal.tsx index b56daa038..9d2d9f0b4 100644 --- a/src/modals/TransactionDetailsModal.tsx +++ b/src/modals/TransactionDetailsModal.tsx @@ -223,9 +223,11 @@ const TransactionDetailsModal = ({ transaction, onClose }: TransactionDetailsMod {nftsData.length > 0 && ( - {nftsData.map((nft) => ( - - ))} + + {nftsData.map((nft) => ( + + ))} + )} {unknownTokens.length > 0 && ( @@ -389,3 +391,10 @@ const AddressBadgeStyled = styled(AddressBadge)` const SwapPartnerAddress = styled.div` max-width: 80px; ` + +const NFTThumbnails = styled.div` + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +` diff --git a/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx b/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx index ed96fc32f..2560af6bb 100644 --- a/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx +++ b/src/pages/UnlockedWallet/OverviewPage/AssetsList.tsx @@ -28,7 +28,7 @@ import AssetLogo from '@/components/AssetLogo' import Badge from '@/components/Badge' import FocusableContent from '@/components/FocusableContent' import HashEllipsed from '@/components/HashEllipsed' -import NFTThumbnail from '@/components/NFTThumbnail' +import NFTCard from '@/components/NFTCard' import SkeletonLoader from '@/components/SkeletonLoader' import { TabItem } from '@/components/TabBar' import { ExpandableTable, ExpandRow, TableRow } from '@/components/Table' @@ -42,6 +42,7 @@ import { makeSelectAddressesNFTs, selectIsStateUninitialized } from '@/storage/addresses/addressesSelectors' +import { deviceBreakPoints } from '@/style/globalStyles' import { AddressHash } from '@/types/addresses' interface AssetsListProps { @@ -229,20 +230,23 @@ const NFTsList = ({ className, addressHashes, isExpanded, onExpand }: AssetsList <> {isLoadingTokensMetadata || stateUninitialized ? ( - - - + + + + + + ) : ( - + {nfts.map((nft) => ( - + ))} {nfts.length === 0 && {t('No NFTs found.')}} - + )} - {!isExpanded && nfts.length > 10 && onExpand && } + {!isExpanded && nfts.length > 4 && onExpand && } ) } @@ -299,8 +303,15 @@ const PlaceholderText = styled.div` justify-content: center; ` -const TableRowStyled = styled(TableRow)` - display: flex; - gap: 20px; - flex-wrap: wrap; +const NFTList = styled(TableRow)` + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-auto-flow: initial; + gap: 25px; + padding: 15px; + border-radius: 0 0 12px 12px; + + @media ${deviceBreakPoints.desktop} { + grid-template-columns: repeat(4, 1fr); + } ` diff --git a/src/utils/pointer.ts b/src/utils/pointer.ts new file mode 100644 index 000000000..dc46b2f16 --- /dev/null +++ b/src/utils/pointer.ts @@ -0,0 +1,39 @@ +/* +Copyright 2018 - 2023 The Alephium Authors +This file is part of the alephium project. + +The library is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +The library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with the library. If not, see . +*/ + +import { PointerEvent } from 'react' + +// TODO: Copied from explorer + +export const getPointerAbsolutePositionInElement = (e: PointerEvent) => { + const bounds = e.currentTarget.getBoundingClientRect() + + return { + x: e.clientX - bounds.x, + y: e.clientY - bounds.y + } +} + +export const getPointerRelativePositionInElement = (e: PointerEvent) => { + const { x: posX, y: posY } = getPointerAbsolutePositionInElement(e) + + return { + x: posX / e.currentTarget.clientWidth, + y: posY / e.currentTarget.clientHeight + } +}