Forked from nft-browser-v2

This commit is contained in:
Chris Troutner
2022-06-19 07:00:41 -07:00
commit 448b61de3a
22 changed files with 32633 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
build/
+8
View File
@@ -0,0 +1,8 @@
[
{
"srcDir": "",
"destDir": "",
"files": "**/*.js",
"command": "npm run lint"
}
]
+7
View File
@@ -0,0 +1,7 @@
Copyright 2022 Chris Troutner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
# nft-browser-v2
This is a single page app (SPA) based on [react-bootstrap](https://www.npmjs.com/package/react-bootstrap). This app leverages the [Cash Stack](https://cashstack.info) web3 architecture to retrieve NFT information from the Bitcoin Cash (BCH) blockchain and display them.
## License
[MIT](./LICENSE.md)
+11
View File
@@ -0,0 +1,11 @@
# Developer Docs
This file contains notes taken during software development. These notes may eventually be edited into informaiton that goes into the top-level README, or other documentation.
## Main Features of this App
- [react-bootstrap](https://react-bootstrap.github.io/) is used for general style and layout control.
- An easily customizable waiting modal component can be invoked while waiting for network calls to complete.
- [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) is used to access tokens and BCH on the Bitcoin Cash blockchain.
- A 'server selection' dropdown allows the user to select from an array of redundent back end servers.
- This site is statically compiled, uploaded to Filecoin, and served over IPFS for censorship resistance and version control.
+31749
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "nft-browser-v2",
"version": "1.0.0",
"dependencies": {
"axios": "0.27.2",
"bootstrap": "5.1.3",
"query-string": "7.1.1",
"react": "17.0.2",
"react-bootstrap": "2.0.0",
"react-dom": "17.0.2",
"react-scripts": "5.0.1",
"use-query-params": "1.2.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "standard --env mocha --fix"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"standard": "17.0.0",
"web3.storage": "4.2.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>React-Bootstrap CodeSandbox Starter</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
/*
Run this script with `npm run pub` after running `npm run build`
This script will upload the compiled site to Filecoin, then publish the new
hash to the BCH blockchain. The page at troutsblog.com will automatically
redirect users to the new site.
*/
const FILE_PATH = './build'
const { Web3Storage, getFilesFromPath } = require('web3.storage')
// const BCHJS = require('@psf/bch-js')
// const BchWallet = require('minimal-slp-wallet/index')
// const BchMessageLib = require('bch-message-lib/index')
const fs = require('fs')
async function publish () {
try {
// Get the Filecoin token from the environment variable.
const filecoinToken = process.env.FILECOIN_TOKEN
if (!filecoinToken) {
throw new Error(
'Filecoin token not detected. Get a token from https://web3.storage and save it to the FILECOIN_TOKEN environment variable.'
)
}
// Get the WIF for updating Trout's blog from the environment variable.
// const troutWif = process.env.TROUT_BLOG_WIF
// if (!troutWif) {
// throw new Error(
// 'WIF for troutsblog.com not detect. Add it to the TROUT_BLOG_WIF environment variable.'
// )
// }
// Get a list of all the files to be uploaded.
const fileAry = await getFileList()
// console.log(`fileAry: ${JSON.stringify(fileAry, null, 2)}`)
// Upload the files to Filecoin.
const cid = await uploadToFilecoin(fileAry, filecoinToken)
// const cid = 'bafybeibgekzxiqr7irgs26g5pw5sv6xtvfiihccyagpmn7anzl6ffb2xc4'
console.log('Content added with CID:', cid)
console.log(`https://${cid}.ipfs.dweb.link/`)
// Initialize libraries for working with BCH blockchain.
// const bchjs = new BCHJS()
// const wallet = new BchWallet(troutWif, {
// interface: 'consumer-api',
// })
// await wallet.walletInfoPromise
// const bchMsg = new BchMessageLib({ wallet })
//
// // Publish the CID to the BCH blockchain.
// const hex = await bchMsg.memo.memoPush(cid, 'IPFS UPDATE')
//
// // const txid = await bchjs.RawTransactions.sendRawTransaction(hex)
// // Broadcast the transaction to the network.
// const txid = await wallet.ar.sendTx(hex)
// console.log(`BCH blockchain updated with new CID. TXID: ${txid}`)
// console.log(`https://blockchair.com/bitcoin-cash/transaction/${txid}`)
} catch (err) {
console.error(err)
}
}
publish()
function getFileList () {
const fileAry = []
return new Promise((resolve, reject) => {
fs.readdir(FILE_PATH, (err, files) => {
if (err) return reject(err)
files.forEach(file => {
// console.log(file)
fileAry.push(`${FILE_PATH}/${file}`)
})
return resolve(fileAry)
})
})
}
async function uploadToFilecoin (fileAry, token) {
const storage = new Web3Storage({ token })
const files = []
for (let i = 0; i < fileAry.length; i++) {
const thisPath = fileAry[i]
// console.log('thisPath: ', thisPath)
const pathFiles = await getFilesFromPath(thisPath)
// console.log('pathFiles: ', pathFiles)
files.push(...pathFiles)
}
console.log(`Uploading ${files.length} files. Please wait...`)
const cid = await storage.put(files)
return cid
}
+3
View File
@@ -0,0 +1,3 @@
.header {
text-align: center;
}
+166
View File
@@ -0,0 +1,166 @@
/*
This is an SPA that displays information about NFTs on the BCH blockchain.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col } from 'react-bootstrap'
import { useQueryParam, StringParam } from 'use-query-params'
// Local libraries
import './App.css'
import LoadScripts from './components/load-scripts'
import NFTs from './components/nfts'
import WaitingModal from './components/waiting-modal'
import AsyncLoad from './services/async-load'
import ServerSelect from './components/servers'
import Footer from './components/footer'
// Token ID for Trout's NFTs
const groupTokenId = '030563ddd65772d8e9b79b825529ed53c7d27037507b57c528788612b4911107'
// Default restURL for a back-end server.
let serverURL = 'https://free-bch.fullstack.cash'
class App extends React.Component {
constructor (props) {
super(props)
// Encasulate dependencies
this.asyncLoad = new AsyncLoad()
// Working array for storing modal output.
this.modalBody = []
this.tokenData = {}
this.state = {
walletInitialized: false,
wallet: false,
modalBody: this.modalBody,
hideSpinner: false
}
this.cnt = 0
}
async componentDidMount () {
try {
this.addToModal('Loading minimal-slp-wallet')
await this.asyncLoad.loadWalletLib()
this.addToModal('Initializing wallet')
const wallet = await this.asyncLoad.initWallet(serverURL)
this.addToModal('Getting Group Token Information')
// Get Group Token info
const groupData = await this.asyncLoad.getGroupData(groupTokenId)
// console.log(`groupData: ${JSON.stringify(groupData, null, 2)}`)
this.addToModal('Getting NFT Information')
/// Get NFT child info
const nftData = []
for (let i = 0; i < groupData.nfts.length; i++) {
const tokenData = await this.asyncLoad.getTokenData(groupData.nfts[i])
nftData.push(tokenData)
}
// console.log(`nft data: ${JSON.stringify(nftData, null, 2)}`)
this.tokenData = {
groupData,
nftData
}
this.setState({
wallet,
walletInitialized: true
})
} catch (err) {
this.modalBody = [
`Error: ${err.message}`,
`Try selecting a different back end server using the drop-down menu at the bottom of the app.`
]
this.setState({
modalBody: this.modalBody,
hideSpinner: true
})
}
}
render () {
// console.log('App component rendered. this.state.wallet: ', this.state.wallet)
return (
<>
<GetRestUrl />
<LoadScripts />
{this.state.walletInitialized ? <InitializedView wallet={this.state.wallet} tokens={this.tokenData} /> : <UninitializedView modalBody={this.state.modalBody} hideSpinner={this.state.hideSpinner}/>}
<ServerSelect />
<Footer />
</>
)
}
// Add a new line to the waiting modal.
addToModal (inStr) {
this.modalBody.push(inStr)
this.setState({
modalBody: this.modalBody
})
}
}
// This is rendered *before* the BCH wallet is initialized.
function UninitializedView (props) {
// console.log('UninitializedView props: ', props)
const heading = 'Loading Blockchain Data...'
return (
<Container style={{ backgroundColor: '#ddd' }}>
<Row style={{ padding: '25px' }}>
<Col>
<h1 className='header'>NFT Explorer</h1>
<WaitingModal heading={heading} body={props.modalBody} hideSpinner={props.hideSpinner} />
</Col>
</Row>
</Container>
)
}
// This is rendered *after* the BCH wallet is initialized.
function InitializedView (props) {
return (
<>
<Container style={{ backgroundColor: '#ddd' }}>
<Row style={{ padding: '25px' }}>
<Col>
<h1 className='header'>NFT Explorer</h1>
</Col>
</Row>
</Container>
<NFTs wallet={props.wallet} tokens={props.tokens} />
</>
)
}
function GetRestUrl (props) {
const [restURL] = useQueryParam('restURL', StringParam)
// console.log('restURL: ', restURL)
serverURL = restURL
return (<></>)
}
// function sleep (ms) {
// return new Promise(resolve => setTimeout(resolve, ms))
// }
export default App
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
it('renders without crashing', () => {
const div = document.createElement('div')
ReactDOM.render(<App />, div)
ReactDOM.unmountComponentAtNode(div)
})
+38
View File
@@ -0,0 +1,38 @@
/*
A footer section for the SPA
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col } from 'react-bootstrap'
const IPFS_CID = 'bafybeiff7sjpdizarbtjgc3ftefyzndggsx3uxqs7l42ruzcwdkbuld6eq'
class Footer extends React.Component {
render () {
return (
<Container style={{ backgroundColor: '#ddd' }}>
<Row style={{ padding: '25px' }}>
<Col>
<h6>Site Mirrors</h6>
<ul>
<li><a href='https://troutnfts.com' target='_blank' rel='noreferrer'>troutnfts.com</a></li>
<li><a href={`https://${IPFS_CID}.ipfs.dweb.link/`} target='_blank' rel='noreferrer'>Filecoin</a></li>
</ul>
</Col>
<Col />
<Col>
<h6>Source Code</h6>
<ul>
<li><a href='https://github.com/christroutner/nft-browser-v2' target='_blank' rel='noreferrer'>GitHub</a></li>
</ul>
</Col>
</Row>
</Container>
)
}
}
export default Footer
+13
View File
@@ -0,0 +1,13 @@
/*
Load <script> libraries
*/
import useScript from '../hooks/use-script'
function LoadScripts () {
useScript('https://unpkg.com/minimal-slp-wallet')
return true
}
export default LoadScripts
+65
View File
@@ -0,0 +1,65 @@
/*
This is a top level component for displaying the NFTs.
*/
// Global npm libraries
import React from 'react'
import { Container, Row, Col } from 'react-bootstrap'
// Local libraries
import NFTCard from './nft-card'
class NFTs extends React.Component {
constructor (props) {
super(props)
console.log('props passed to NFT component: ', props)
this.groupData = this.props.tokens.groupData
this.nftData = this.props.tokens.nftData
this.state = {
nfts: []
}
}
async componentDidMount () {
// console.log('NFT component didMount(). nftData: ', this.nftData)
const nfts = []
for (let i = 0; i < this.nftData.length; i++) {
nfts.push(<NFTCard key={`nft${i}`} nftData={this.nftData[i]} />)
}
this.setState({
nfts
})
}
render () {
// Load spinner at startup while the wallet is being initialized.
return (
<>
<Container>
<Row>
<Col className="text-break" style={{ textAlign: 'center' }}>
<p>Loading NFTs associated with Group token{' '}
<a
href={`https://token.fullstack.cash/?tokenid=${this.groupData.tokenId}`}
target='_blank'
rel='noopener noreferrer'
>
{this.groupData.tokenId}
</a>
</p>
</Col>
</Row>
<Row><hr /></Row>
{this.state.nfts}
</Container>
</>
)
}
}
export default NFTs
+63
View File
@@ -0,0 +1,63 @@
/*
This component controls the display of each NFT.
*/
/* eslint-disable */
// Global npm libraries
import React from 'react'
import axios from 'axios'
import { Container, Row, Col, Image } from 'react-bootstrap'
class NFTCard extends React.Component {
constructor (props) {
super(props)
// console.log('NFTCard props: ', props)
this.tokenData = props.nftData
this.state = {
mutableData: {},
immutableData: {}
}
}
render () {
// console.log('Rendering NFT card with this token data: ', this.tokenData)
return (
<Row>
<Col style={{ textAlign: 'center' }}>
<Image className="d-md-none" src={this.tokenData.mutableData.tokenIcon} style={{border: 'black solid 5px', maxWidth: '300px'}} />
<Image className="d-none d-md-block" src={this.tokenData.mutableData.tokenIcon} style={{border: 'black solid 5px'}} />
</Col>
<Col>
<Container>
<Row>
<Col>
<br />
<h3>{this.tokenData.genesisData.name} ({this.tokenData.genesisData.ticker})</h3>
</Col>
</Row>
<Row style={{ textAlign: 'left' }}>
<Col className="text-break">
<b>Token ID:</b> <a href={`https://token.fullstack.cash/?tokenid=${this.tokenData.genesisData.tokenId}`} target="_blank">{this.tokenData.genesisData.tokenId}</a><br />
<b>Description: </b> {this.tokenData.mutableData.description}<br />
<b>Content: </b>
<ul>
<li><a href={this.tokenData.mutableData.content.youtube} target="_blank">YouTube</a></li>
<li><a href={this.tokenData.mutableData.content.rumble} target="_blank">Rumble</a></li>
<li><a href={this.tokenData.mutableData.content.odysee} target="_blank">Odysee</a></li>
<li><a href={this.tokenData.mutableData.content.filecoin} target="_blank">Filecoin</a> (download)</li>
</ul>
</Col>
</Row>
</Container>
</Col>
</Row>
)
}
}
export default NFTCard
+113
View File
@@ -0,0 +1,113 @@
/*
This component contains a drop-down form that lets the user select from
a range of Global Back End servers.
*/
// Global npm libraries
import React from 'react'
// import Select from 'react-dropdown-select'
import { Container, Row, Col, Form } from 'react-bootstrap'
// Local libraries
import GistServers from '../../services/gist-servers'
const defaultOptions = [
{ value: 'https://free-bch.fullstack.cash', label: 'https://free-bch.fullstack.cash' },
{ value: 'https://bc01-ca-bch-consumer.fullstackcash.nl', label: 'https://bc01-ca-bch-consumer.fullstackcash.nl' },
{ value: 'https://pdx01-usa-bch-consumer.fullstackcash.nl', label: 'https://pdx01-usa-bch-consumer.fullstackcash.nl' },
{ value: 'https://wa-usa-bch-consumer.fullstackcash.nl', label: 'https://wa-usa-bch-consumer.fullstackcash.nl' }
]
// const defaultOptions = [
// 'https://free-bch.fullstack.cash',
// 'https://bc01-ca-bch-consumer.fullstackcash.nl',
// 'https://pdx01-usa-bch-consumer.fullstackcash.nl',
// 'https://wa-usa-bch-consumer.fullstackcash.nl'
// ]
class ServerSelect extends React.Component {
constructor (props) {
super(props)
// this.wallet = props.wallet
this.state = {
options: defaultOptions
}
}
// async componentDidMount () {
// const servers = await this.getServers()
// // console.log('Server list retrieved from GitHub Gist: ', servers)
//
// this.setState({
// options: servers
// })
// }
// This is called when the a new drop-down item is selected.
selectServer (event) {
console.log('event.target.value: ', event.target.value)
const value = event.target.value
if (!value) return
window.location.href = `/?restURL=${value}`
}
// render() {
// return (
// <div>
// <Select options={this.state.options} onChange={(values) => this.selectServer(values)} />
// <p style={{textAlign: 'center'}}>Having trouble loading the NFTs? If NFTs don't load after a minute, then
// try selecting a different back-end server.</p>
// </div>
// )
// }
render () {
const items = []
for (let i = 0; i < this.state.options.length; i++) {
const thisServer = this.state.options[i]
items.push(<option key={`server-${i}`} value={thisServer.value}>{thisServer.label}</option>)
}
return (
<Container>
<hr />
<Row>
<Col>
<br />
<h5 style={{ textAlign: 'center' }}>
Having trouble loading the NFTs? If NFTs don't load after a minute,
then try selecting a different back-end server.
</h5>
<Form.Select onChange={(values) => this.selectServer(values)}>
<option>Choose a back-end server</option>
{items}
</Form.Select>
<br />
</Col>
</Row>
</Container>
)
}
// Retrieve an array of server URLs from the GitHub Gist.
async getServers () {
const gistLib = new GistServers()
const gistServers = await gistLib.getServerList()
const serversAry = []
for (let i = 0; i < gistServers.length; i++) {
serversAry.push({ value: gistServers[i].url, label: gistServers[i].url })
}
return serversAry
}
}
export default ServerSelect
+63
View File
@@ -0,0 +1,63 @@
/*
This 'Waiting Modal' component displays a spinner animation and a status log.
It's used to inform the user that the app is waiting for something, and to
display progress.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Modal, Spinner } from 'react-bootstrap'
function ModalTemplate (props) {
const [show, setShow] = useState(true)
const handleClose = () => setShow(false)
// const handleShow = () => setShow(true)
return (
<Modal show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>{props.heading}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container>
<Row>
<Col style={{ textAlign: 'center' }}>
<BodyList body={props.body} />
{props.hideSpinner ? null : <Spinner animation='border' />}
</Col>
</Row>
</Container>
</Modal.Body>
<Modal.Footer />
</Modal>
)
}
function BodyList (props) {
const items = props.body
const listItems = []
// List items
// for(let i=0; i < items.length; i++) {
// listItems.push(<li key={items[i]}>{items[i]}</li>)
// }
// return (
// <ul>
// {listItems}
// </ul>
// )
// Paragraphs
for (let i = 0; i < items.length; i++) {
listItems.push(<p key={items[i]}><code>{items[i]}</code></p>)
}
return (
listItems
)
}
// export default WaitingModal
export default ModalTemplate
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react'
const useScript = url => {
useEffect(() => {
const script = document.createElement('script')
script.src = url
script.async = true
document.body.appendChild(script)
return () => {
document.body.removeChild(script)
}
}, [url])
}
export default useScript
+15
View File
@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import { QueryParamProvider } from 'use-query-params'
// Importing the Bootstrap CSS
import 'bootstrap/dist/css/bootstrap.min.css'
ReactDOM.render(
<>
<QueryParamProvider>
<App />
</QueryParamProvider>
</>
, document.getElementById('root'))
+89
View File
@@ -0,0 +1,89 @@
/*
This library gets data that requires an async wait.
*/
// Global npm libraries
import axios from 'axios'
class AsyncLoad {
constructor () {
this.BchWallet = false
}
// Load the minimal-slp-wallet which comes in as a <script> file and is
// attached to the global 'window' object.
async loadWalletLib () {
do {
if (typeof window !== 'undefined' && window.SlpWallet) {
this.BchWallet = window.SlpWallet
return this.BchWallet
} else {
console.log('Waiting for wallet library to load...')
}
await sleep(1000)
} while (!this.BchWallet)
}
// Initialize the BCH wallet
async initWallet (restURL) {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true
}
const wallet = new this.BchWallet(null, options)
await wallet.walletInfoPromise
console.log(`mnemonic: ${wallet.walletInfo.mnemonic}`)
this.wallet = wallet
return wallet
}
// Get token data for a given Token ID
async getTokenData (tokenId) {
const tokenData = await this.wallet.getTokenData(tokenId)
// Convert the IPFS CIDs into actual data.
tokenData.immutableData = await this.getIpfsData(tokenData.immutableData)
tokenData.mutableData = await this.getIpfsData(tokenData.mutableData)
return tokenData
}
// Get data about a Group token
async getGroupData (tokenId) {
const tokenData = await this.getTokenData(tokenId)
const groupData = {
immutableData: tokenData.immutableData,
mutableData: tokenData.mutableData,
nfts: tokenData.genesisData.nfts,
tokenId: tokenData.genesisData.tokenId
}
return groupData
}
// Given an IPFS URI, this will download and parse the JSON data.
async getIpfsData (ipfsUri) {
const cid = ipfsUri.slice(7)
const downloadUrl = `https://${cid}.ipfs.dweb.link/data.json`
const response = await axios.get(downloadUrl)
const data = response.data
return data
}
}
function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export default AsyncLoad
+39
View File
@@ -0,0 +1,39 @@
/*
This library downloads a dynamic list of back-end servers from a GitHub Gist.
*/
const axios = require('axios')
class GistServers {
constructor () {
this.axios = axios
}
// Retrieve a JSON file from a GitHub Gist
async getServerList () {
try {
// https://gist.github.com/christroutner/e818ecdaed6c35075bfc0751bf222258
const gistUrl =
'https://api.github.com/gists/63c5513782181f8b8ea3eb89f7cadeb6'
// Retrieve the gist from github.com.
const result = await this.axios.get(gistUrl)
// console.log('result.data: ', result.data)
// Get the current content of the gist.
const content = result.data.files['psf-consumer-apis.json'].content
// console.log('content: ', content)
// Parse the JSON string into an Object.
const object = JSON.parse(content)
// console.log('object: ', object)
return object.consumerApis
} catch (err) {
console.error('Error in getCRList()')
throw err
}
}
}
export default GistServers