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
+ }
+}