Merge pull request #2 from Permissionless-Software-Foundation/nft-card

feat(NFT): Lazy-load NFT token icons
This commit is contained in:
Chris Troutner
2025-03-03 18:57:05 -08:00
committed by GitHub
5 changed files with 267 additions and 157 deletions
+9 -9
View File
@@ -1,18 +1,18 @@
const webpack = require('webpack');
const webpack = require('webpack')
module.exports = function override(config) {
module.exports = function override (config) {
config.resolve.fallback = {
...config.resolve.fallback,
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
vm: require.resolve('vm-browserify'),
};
vm: require.resolve('vm-browserify')
}
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
}),
]);
process: 'process/browser'
})
])
return config;
};
return config
}
+215 -99
View File
@@ -1,16 +1,21 @@
/*
Shows NFTs for sale on the DEX.
workflow:
1. Load offers
2. display token cards with current offers data
3. Load tokens data
4. add data to offers and update card with new data
5. Load tokens icons
6. add icons to offers and update card with new icons
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Card, Col, Button, Spinner } from 'react-bootstrap'
import React, { useState, useEffect, useCallback } from 'react'
import { Container, Row, Col, Button, Spinner } from 'react-bootstrap'
import axios from 'axios'
import Jdenticon from '@chris.troutner/react-jdenticon'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
// import BchDexLib from 'bch-dex-lib'
// import RetryQueue from '@chris.troutner/retry-queue'
// Local libraries
import config from '../../../config'
@@ -21,111 +26,188 @@ const SERVER = config.dexServer
function NftsForSale (props) {
// Dependency injection through props
const appData = props.appData
console.log('NftsForSale() appData: ', appData)
// State
const [offers, setOffers] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [offersAreLoaded, setOffersAreLoaded] = useState(false)
const [iconsAreLoaded, setIconsAreLoaded] = useState(false)
const [dataAreLoaded, setDataAreLoaded] = useState(false)
async function getNftOffers(page = 0) {
// Handler for refresh button
const handleRefresh = () => {
console.log('handling refresh')
loadNftOffers()
}
// Function to process token data
const processTokenData = useCallback(async (offer) => {
try {
const options = {
method: 'GET',
url: `${SERVER}/offer/list/nft/${page}`,
data: {}
}
const result = await axios.request(options)
console.log('result.data: ', result.data)
const { wallet, bchWalletState } = appData
const { bchjs } = wallet
// Calculate USD price
const rateInSats = parseInt(offer.rateInBaseUnit)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
const usdPrice = bchCost * bchWalletState.bchUsdPrice * offer.numTokens
offer.usdPrice = `$${usdPrice.toFixed(3)}`
return offer
} catch (err) {
console.error('Error processing token:', err)
return offer
}
}, [appData])
// Fetch offers
const getNftOffers = useCallback(async (page = 0) => {
try {
setOffersAreLoaded(false)
const result = await axios.get(`${SERVER}/offer/list/nft/${page}`)
const rawOffers = result.data
console.log('rawOffers: ', rawOffers)
const bchjs = appData.wallet.bchjs
const wallet = appData.wallet
// Instantiate the BchDexLib object.
// const dexLib = new BchDexLib({bchWallet: wallet, p2wdbRead: {}, p2wdbWrite: {}})
const offerDataCallback = (offer) => {
console.log('offerDataCallback() offer: ', offer)
}
// Add a default icon.
// Process each offer
const processedOffers = []
for (let i = 0; i < rawOffers.length; i++) {
const thisOffer = rawOffers[i]
thisOffer.icon = (<Jdenticon size='100' value={thisOffer.tokenId} />)
thisOffer.iconDownloaded = false
// Convert sats to BCH, and then calculate cost in USD.
const rateInSats = parseInt(thisOffer.rateInBaseUnit)
// console.log('rateInSats: ', rateInSats)
const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats)
// console.log('bchCost: ', bchCost)
// console.log('bchUsdPrice: ', this.state.appData.bchWalletState.bchUsdPrice)
const usdPrice = bchCost * appData.bchWalletState.bchUsdPrice * thisOffer.numTokens
// usdPrice = bchjs.Util.floor2(usdPrice)
// console.log(`usdPrice: ${usdPrice}`)
const priceStr = `$${usdPrice.toFixed(3)}`
thisOffer.usdPrice = priceStr
// Download token data
// await dexLib.tokenData.getTokenData(thisOffer, offerDataCallback)
const tokenData = await wallet.getTokenData(thisOffer.tokenId)
console.log('complete tokenData: ', tokenData)
// const mutableCid = tokenData.mutableData.slice(7)
// const url1 = `https://free-bch.fullstack.cash/ipfs/file-info/${mutableCid}`
// console.log('url1: ', url1)
// const resp1 = await axios.get(url1)
// console.log('resp1.data: ', resp1.data)
try {
const mutableCid = tokenData.mutableData.slice(7)
console.log(`mutable CID for token ${thisOffer.tokenId}: ${mutableCid}`)
let mutableData = await wallet.cid2json({ cid: mutableCid })
console.log('mutableData: ', mutableData)
mutableData = mutableData.json
} catch(err) {
console.error(`Could not download mutable data for token ${thisOffer.tokenId}: ${err.message}`)
}
const offer = rawOffers[i]
const processedOffer = await processTokenData(offer)
processedOffers.push(processedOffer)
}
return rawOffers
} catch(err) {
console.error('NftsForSale() getNftOffers() error: ', err)
return []
setOffersAreLoaded(true)
return processedOffers
} catch (err) {
console.error('getNftOffers error:', err)
setOffersAreLoaded(true)
throw err
}
}
}, [processTokenData])
async function handleOffers() {
let offers = await getNftOffers()
console.log('offers: ', offers)
return offers
}
async function handleStartProcessingTokens() {
return await handleOffers()
}
// This is an async startup function that is called by the useEffect hook.
const asyncStartup = async () => {
// This function loads the token data .
const lazyLoadTokenData = useCallback(async (tokens) => {
try {
const offers = await handleStartProcessingTokens()
setOffers(offers)
setDataAreLoaded(false)
// map each token and fetch the token data
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// data does not need to be downloaded, so continue with the next one
if (thisToken.dataAlreadyDownloaded) continue
// Try to get token data.
const tokenData = await appData.wallet.getTokenData(thisToken.tokenId)
if (tokenData) {
// Set data to the token object , this can be used to display the token name in the token card component.
thisToken.tokenData = tokenData
}
// Mark token to prevent fetch token data again.
thisToken.dataAlreadyDownloaded = true
}
setDataAreLoaded(true)
} catch (error) {
console.error('NftsForSale() asyncStartup() error: ', error)
setDataAreLoaded(true)
}
}
}, [appData])
// Load NFT data when the page loads from the server or from the cache.
// Fetch mutable data if it exist and get the token icon url
const fetchTokenInfo = useCallback(async (token) => {
try {
// Get the token data
const tokenData = token.tokenData
if (!tokenData.mutableData) return false // Return false if no mutable data
// Get the token icon from the mutable data
const cid = parseCid(tokenData.mutableData)
console.log('mutable data cid', cid)
const { json } = await appData.wallet.cid2json({ cid })
console.log('json: ', json)
if (!json) return false
let iconUrl = json.tokenIcon
if (json.fullSizedUrl && json.fullSizedUrl.includes('http')) {
iconUrl = json.fullSizedUrl
}
// Return icon url
return iconUrl
} catch (error) {
return false
}
}, [appData])
// This function loads the token icons from the ipfs gateways.
const lazyLoadTokenIcons = useCallback(async (tokens) => {
try {
setIconsAreLoaded(false)
// map each token and fetch the icon url
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// Incon does not need to be downloaded, so continue with the next one
if (thisToken.iconAlreadyDownloaded) continue
// Try to get token icon url from mutable data.
const iconUrl = await fetchTokenInfo(thisToken)
console.log('iconUrl', iconUrl)
if (iconUrl) {
// Set the icon url to the token , this can be used to display the icon in the token card component.
thisToken.icon = iconUrl
}
// Mark token to prevent fetch token icon again.
thisToken.iconAlreadyDownloaded = true
}
setIconsAreLoaded(true)
} catch (error) {
setIconsAreLoaded(true)
}
}, [fetchTokenInfo])
// Main function to load NFT offers
const loadNftOffers = useCallback(async () => {
try {
setIsLoading(true)
// Get tokens in offers
const offers = await getNftOffers()
console.log('offers: ', offers)
setOffers(offers)
// Load tokens data
await lazyLoadTokenData(offers)
// Load tokens icons
await lazyLoadTokenIcons(offers)
setIsLoading(false)
} catch (err) {
setIsLoading(false)
console.error('Error loading NFT offers:', err)
}
}, [lazyLoadTokenData, lazyLoadTokenIcons, getNftOffers])
// Effect to load NFTs on component mount
useEffect(() => {
asyncStartup()
}, [])
console.log('loading nfts for sale')
loadNftOffers()
}, [loadNftOffers])
// Get Cid from url
const parseCid = (url) => {
// get the cid from the url format 'ipfs://bafybeicem27xbzs65uvbcgykcmscsgln3lmhbfrcoec3gdttkdgtxv5acq
if (url && url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
return cid
}
return url
}
// This function generates a Token Card for each token in the wallet.
function generateCards () {
// console.log('generateCards() offerData: ', offerData)
function generateCards (offers) {
console.log('generateCards() offerData: ', offers)
const tokens = offers
@@ -133,13 +215,12 @@ function NftsForSale (props) {
for (let i = 0; i < tokens.length; i++) {
const thisToken = tokens[i]
// console.log(`thisToken: ${JSON.stringify(thisToken, null, 2)}`)
const thisTokenCard = (
<TokenCard
appData={appData}
token={thisToken}
key={`${thisToken.tokenId}`}
key={`${thisToken.tokenId + i}`}
/>
)
tokenCards.push(thisTokenCard)
@@ -155,18 +236,53 @@ function NftsForSale (props) {
<h1>NFTs for Sale</h1>
</Col>
</Row>
<Row>
<Col xs={6}>
<Button variant='success' >
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
<Col xs={12} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{/** Show spinner info if tokens are loaded but data is not loaded */
offersAreLoaded && !dataAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Tokens Data </span>
<Spinner animation='border' />
</div>
)
}
{/** Show spinner info if tokens are loaded but icons are not loaded */
dataAreLoaded && !iconsAreLoaded && (
<div style={{ borderRadius: '10px', backgroundColor: '#f0f0f0', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: 'fit-content' }}>
<span style={{ marginRight: '10px' }}>Loading Tokens Icons </span>
<Spinner animation='border' />
</div>
)
}
</Col>
</Row>
{!isLoading && (
<Row>
<Col xs={6}>
<Button variant='success' onClick={handleRefresh}>
<FontAwesomeIcon icon={faRedo} size='lg' /> Refresh
</Button>
</Col>
</Row>
)}
{!offersAreLoaded && (
<Row className='d-block text-center'>
<Spinner animation='border' variant='primary' style={{ maegin: '0 auto' }} />
</Row>
)}
<Row>
{generateCards()}
{offersAreLoaded && generateCards(offers)}
</Row>
{/** Display a message if no tokens are found */}
{offersAreLoaded && offers.length === 0 && (
<Row className='text-center'>
<span> No Offers found. </span>
</Row>
)}
</Container>
)
}
@@ -23,8 +23,6 @@ function InfoButton (props) {
setShow(true)
}
console.log('props.token: ', props.token)
// Replace with dummy button until token data is loaded.
if (!props.token.tokenData) {
return (
@@ -34,15 +32,9 @@ function InfoButton (props) {
)
}
// Convert the url property of the token to a link, if it matches common patterns.
// let url = props.token.tokenurl
// url = linkIfUrl(props.token.url)
// console.log('props.token: ', props.token)
return (
<>
<Button variant='info' onClick={handleOpen}>Info</Button>
<Button variant='info' disabled={props.disabled} onClick={handleOpen}>Info</Button>
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Token Information</Modal.Title>
@@ -56,7 +48,7 @@ function InfoButton (props) {
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Name</b>:</Col>
<Col xs={8}>{props.token.tokenData.tokenStats.name}</Col>
<Col xs={8}>{props.token.tokenData.genesisData.name}</Col>
</Row>
<Row>
@@ -70,19 +62,14 @@ function InfoButton (props) {
<Row style={{ backgroundColor: '#eee' }}>
<Col xs={4}><b>Decimals</b>:</Col>
<Col xs={8}>{props.token.tokenData.tokenStats.decimals}</Col>
<Col xs={8}>{props.token.tokenData.genesisData.decimals}</Col>
</Row>
<Row>
<Col xs={4}><b>Token Type</b>:</Col>
<Col xs={8}>{props.token.tokenType}</Col>
</Row>
{/*
<Row style={{ backgroundColor: '#eee', wordBreak: 'break-all' }}>
<Col xs={4}><b>URL</b>:</Col>
<Col xs={8}>{url}</Col>
</Row>
*/}
</Container>
</Modal.Body>
<Modal.Footer />
@@ -91,4 +78,4 @@ function InfoButton (props) {
)
}
export default InfoButton
export default InfoButton
@@ -3,63 +3,70 @@
*/
// Global npm libraries
import React from 'react'
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Card } from 'react-bootstrap'
// import BuyNftButton from './buy-button'
import InfoButton from './info-button'
// import FlagButton from './flag-button'
// Local libraries
// import InfoButton from './info-button'
// import SendTokenButton from './send-token-button'
// import SellButton from './sell-button'
import Jdenticon from '@chris.troutner/react-jdenticon'
function TokenCard (props) {
let imageLink = ''
if (props.token.mutableData) {
imageLink = props.token.mutableData.tokenIcon
const { token } = props
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
if (props.token.mutableData.fullSizedUrl && props.token.mutableData.fullSizedUrl.includes('http')) {
imageLink = props.token.mutableData.fullSizedUrl
}
}
console.log('TokenCard props.token: ', props.token)
// Update icon state every token.icon and token.tokenData changes
useEffect(() => {
setIcon(token.icon)
setTokenData(token.tokenData)
}, [token.icon, token.tokenData])
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
<a href={imageLink} target='_blank' rel='noreferrer'>
{props.token.icon}
</a>
<Card.Title style={{ textAlign: 'center' }}>
<h4>{props.token.ticker}</h4>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
src={icon}
style={{ height: '100px', width: 'auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)
}
{/** If the icon is not loaded, display the Jdenticon */
!icon && (
<Jdenticon size='100' value={token.tokenId} />
)
}
<Card.Title style={{ textAlign: 'center', marginTop: '10px' }}>
<h4>{token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{props.token.tokenData ? props.token.tokenData.tokenStats.name : null}
{tokenData && tokenData.genesisData ? tokenData.genesisData.name : null}
</Col>
</Row>
<br />
<Row>
<Col>Price:</Col>
<Col>{props.token.usdPrice}</Col>
<Col>{token.usdPrice}</Col>
</Row>
<br />
<Row>
<Col>
<InfoButton token={props.token} />
<InfoButton token={token} disabled={!token.tokenData} />
</Col>
<Col></Col>
<Col />
<Col></Col>
<Col />
</Row>
</Container>
@@ -69,7 +76,7 @@ function TokenCard (props) {
</>
)
}
// eslint-disable-next-line
{/* <Col>
<InfoButton token={props.token} />
</Col>
@@ -80,6 +87,6 @@ function TokenCard (props) {
<Col>
<BuyNftButton appData={props.appData} offer={props.token} />
</Col> */}
</Col> */ }
export default TokenCard
+2 -2
View File
@@ -68,7 +68,7 @@ function NavMenu (props) {
>
Wallet
</NavLink>
<NavLink
className={currentPath === '/slp-tokens' ? 'nav-link-active' : 'nav-link-inactive'}
to='/slp-tokens'
@@ -83,7 +83,7 @@ function NavMenu (props) {
>
Placeholder2
</NavLink>
<NavLink
className={currentPath === '/placeholder3' ? 'nav-link-active' : 'nav-link-inactive'}
to='/placeholder3'