feat: nft page

This commit is contained in:
DIYgod 2022-11-28 01:01:59 +00:00
parent ddaa1ff6bf
commit f139375ae7
No known key found for this signature in database
GPG Key ID: D159328F47A80DCA
17 changed files with 295 additions and 24 deletions

View File

@ -10,6 +10,11 @@ stringData:
NEXT_PUBLIC_CSB_SCAN: "https://scan.crossbell.io"
NEXT_PUBLIC_IPFS_GATEWAY: "https://ipfs.4everland.xyz/ipfs/"
REDIS_URL: ${REDIS_URL_DEV}
MORALIS_WEB3_API_KEY: ${MORALIS_WEB3_API_KEY}
ALCHEMY_ETHEREUM_API_KEY: ${ALCHEMY_ETHEREUM_API_KEY}
ALCHEMY_POLYGON_API_KEY: ${ALCHEMY_POLYGON_API_KEY}
NFTSCAN_API_KEY: ${NFTSCAN_API_KEY}
OPENSEA_API_KEY: ${OPENSEA_API_KEY}
kind: Secret
metadata:
name: xlog

View File

@ -9,3 +9,8 @@ NEXT_PUBLIC_CSB_IO=APP_NEXT_PUBLIC_CSB_IO
NEXT_PUBLIC_CSB_SCAN=APP_NEXT_PUBLIC_CSB_SCAN
NEXT_PUBLIC_IPFS_GATEWAY=APP_NEXT_PUBLIC_IPFS_GATEWAY
REDIS_URL=APP_NEXT_REDIS_URL
MORALIS_WEB3_API_KEY=APP_MORALIS_WEB3_API_KEY
ALCHEMY_ETHEREUM_API_KEY=APP_ALCHEMY_ETHEREUM_API_KEY
ALCHEMY_POLYGON_API_KEY=APP_ALCHEMY_POLYGON_API_KEY
NFTSCAN_API_KEY=APP_NFTSCAN_API_KEY
OPENSEA_API_KEY=APP_OPENSEA_API_KEY

View File

@ -10,6 +10,11 @@ stringData:
NEXT_PUBLIC_CSB_SCAN: "https://scan.crossbell.io"
NEXT_PUBLIC_IPFS_GATEWAY: "https://ipfs.4everland.xyz/ipfs/"
REDIS_URL: ${REDIS_URL}
MORALIS_WEB3_API_KEY: ${MORALIS_WEB3_API_KEY}
ALCHEMY_ETHEREUM_API_KEY: ${ALCHEMY_ETHEREUM_API_KEY}
ALCHEMY_POLYGON_API_KEY: ${ALCHEMY_POLYGON_API_KEY}
NFTSCAN_API_KEY: ${NFTSCAN_API_KEY}
OPENSEA_API_KEY: ${OPENSEA_API_KEY}
kind: Secret
metadata:
name: xlog

View File

@ -81,7 +81,7 @@
"remark-stringify": "10.0.2",
"sharp": "0.31.2",
"sortablejs": "1.15.0",
"unidata.js": "0.7.7",
"unidata.js": "0.7.9",
"unified": "10.1.2",
"unist-builder": "3.0.0",
"unist-util-visit": "4.1.1",

View File

@ -99,7 +99,7 @@ specifiers:
sortablejs: 1.15.0
tailwindcss: 3.2.4
typescript: 4.9.3
unidata.js: 0.7.7
unidata.js: 0.7.9
unified: 10.1.2
unist-builder: 3.0.0
unist-util-visit: 4.1.1
@ -175,7 +175,7 @@ dependencies:
remark-stringify: 10.0.2
sharp: 0.31.2
sortablejs: 1.15.0
unidata.js: 0.7.7
unidata.js: 0.7.9
unified: 10.1.2
unist-builder: 3.0.0
unist-util-visit: 4.1.1
@ -3361,8 +3361,8 @@ packages:
which: 2.0.2
dev: true
/crossbell.js/0.10.40:
resolution: {integrity: sha512-kGuAVKJA9PyYISGJRSmUmsInm2mN2y6p5pnO4cAAOPtiBI+uicXPAk+4FFS1sykYMyO0Iqc4gWycK1254apqOw==}
/crossbell.js/0.11.1:
resolution: {integrity: sha512-mAYlD05rqVKN12xYJW8MgCWrLiWMMgxyGXb6OOCvnpMtCKkRPKHjHAOPd/X07GwqVj2kZQ+0f5/xccsn1M3V5A==}
dependencies:
async-retry: 1.3.3
ethers: 5.7.2
@ -3378,8 +3378,8 @@ packages:
- utf-8-validate
dev: false
/crossbell.js/0.11.1:
resolution: {integrity: sha512-mAYlD05rqVKN12xYJW8MgCWrLiWMMgxyGXb6OOCvnpMtCKkRPKHjHAOPd/X07GwqVj2kZQ+0f5/xccsn1M3V5A==}
/crossbell.js/0.11.5:
resolution: {integrity: sha512-25lvV2YVNpgb7mZapJAO429ToYuFvx7f2I4/MM13YP91YtvABWoiBrne/gHIyXlEb6QqclQNVRY1H63Vhdabrg==}
dependencies:
async-retry: 1.3.3
ethers: 5.7.2
@ -8401,14 +8401,14 @@ packages:
dependencies:
busboy: 1.6.0
/unidata.js/0.7.7:
resolution: {integrity: sha512-RPUA4r6nf63TNeuFaY1CljfHOly1iIWFmjS+UC8h4ew/gWgTzBKxqOLRRk9+0tiorOzjyoysJsvQIoNn/BRc+A==}
/unidata.js/0.7.9:
resolution: {integrity: sha512-QSJGVGAgN+5KJA8wio0Yith/3ukSd8kVdSUDx9IYE0r7qMkNiasVtzVdx1appGADRR2XWnHeeLjZ/5n8cfemuQ==}
dependencies:
'@types/lodash': 4.14.190
'@urql/core': 3.0.5_graphql@16.6.0
axios: 1.1.3
axios-retry: 3.3.1
crossbell.js: 0.10.40
crossbell.js: 0.11.5
ethers: 5.7.2
graphql: 16.6.0
lodash: 4.17.21

View File

@ -16,6 +16,7 @@ export const getServerSideProps = async (
options?: {
take?: number
useStat?: boolean
skipPages?: boolean
},
) => {
const domainOrSubdomain = ctx.params!.site as string
@ -61,19 +62,21 @@ export const getServerSideProps = async (
reject(error)
}
} else {
await prefetchGetPagesBySite(
{
site: domainOrSubdomain,
...(options?.take && { take: options.take }),
type: "post",
visibility: PageVisibilityEnum.Published,
...(tag && { tags: [tag] }),
...(options?.useStat && {
useStat: true,
}),
},
queryClient,
)
if (!options?.skipPages) {
await prefetchGetPagesBySite(
{
site: domainOrSubdomain,
...(options?.take && { take: options.take }),
type: "post",
visibility: PageVisibilityEnum.Published,
...(tag && { tags: [tag] }),
...(options?.useStat && {
useStat: true,
}),
},
queryClient,
)
}
}
resolve(null)
}),

View File

@ -9,6 +9,7 @@ export const Image: React.FC<
src?: string
width?: number | string
height?: number | string
"original-src"?: string
} & React.HTMLAttributes<HTMLImageElement> &
ImageProps
> = ({ fill, className, alt, src, width, height, ...props }) => {

View File

@ -0,0 +1,75 @@
import React, { useState } from "react"
import { Image } from "~/components/ui/Image"
import axios from "axios"
import { IPFS_GATEWAY } from "~/lib/env"
export const UniMedia: React.FC<{
src: string
mime_type?: string
}> = ({ src, mime_type }) => {
const [errorHandled, setErrorHandled] = useState(false)
const [type, setType] = useState(mime_type)
const handleError = async (e: any) => {
const src = e.target.getAttribute("original-src")
if (src && !errorHandled) {
try {
const result = await axios({
url: src.replace(IPFS_GATEWAY, "https://gateway.ipfs.io/ipfs/"),
method: "HEAD",
})
setType(result.headers["content-type"])
setErrorHandled(true)
} catch (error) {}
}
}
return (
<div className="w-full h-0 pt-[100%] relative rounded overflow-hidden">
<div className="absolute top-0 bottom-0 left-0 right-0">
{(() => {
switch (type?.split("/")[0]) {
case "video":
return (
<video
className="w-full h-full"
src={src}
autoPlay
loop
muted
/>
)
case "model":
return (
// @ts-ignore
<model-viewer
class="w-full h-full"
src={src}
ar
ar-modes="webxr scene-viewer quick-look"
seamless-poster
shadow-intensity="1"
camera-controls
enable-pan
/>
)
case "text":
return <iframe className="w-full h-full" src={src}></iframe>
default:
return (
<Image
className="object-cover"
alt={"nft"}
src={src}
original-src={src}
fill={true}
onError={handleError}
/>
)
}
})()}
</div>
</div>
)
}

View File

@ -19,3 +19,9 @@ export const REDIS_URL = process.env.REDIS_URL
export const REDIS_EXPIRE =
parseInt(process.env.REDIS_URL || "0") || 60 * 60 * 24 * 7 // 1 week
export const REDIS_REFRESH = parseInt(process.env.REDIS_URL || "0") || 5 * 1000 // 5 seconds
export const MORALIS_WEB3_API_KEY = process.env.MORALIS_WEB3_API_KEY
export const ALCHEMY_ETHEREUM_API_KEY = process.env.ALCHEMY_ETHEREUM_API_KEY
export const ALCHEMY_POLYGON_API_KEY = process.env.ALCHEMY_POLYGON_API_KEY
export const NFTSCAN_API_KEY = process.env.NFTSCAN_API_KEY
export const OPENSEA_API_KEY = process.env.OPENSEA_API_KEY

View File

@ -16,6 +16,8 @@ if (REDIS_URL) {
})
}
export const getRedis = () => redis
export async function cacheGet(
key: string | (Record<string, any> | string | undefined)[],
getValueFun: () => Promise<any>,

View File

@ -484,3 +484,11 @@ export async function removeOperator(
return contract?.removeOperator(input.characterId, input.operator)
}
}
export async function getNFTs(address: string, customUnidata?: Unidata) {
const assets = await (customUnidata || unidata).assets.get({
source: "Ethereum NFT",
identity: address,
})
return assets
}

View File

@ -0,0 +1,77 @@
import { GetServerSideProps } from "next"
import { SiteLayout } from "~/components/site/SiteLayout"
import { getServerSideProps as getLayoutServerSideProps } from "~/components/site/SiteLayout.server"
import { dehydrate, QueryClient } from "@tanstack/react-query"
import { useGetSite, useGetNFTs } from "~/queries/site"
import { ReactElement, useEffect, useState } from "react"
import type { Asset } from "unidata.js"
import Script from "next/script"
import { UniMedia } from "~/components/ui/UniMedia"
import { UniLink } from "~/components/ui/UniLink"
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const queryClient = new QueryClient()
const domainOrSubdomain = ctx.params!.site as string
await getLayoutServerSideProps(ctx, queryClient, {
skipPages: true,
})
return {
props: {
dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
domainOrSubdomain,
},
}
}
function SiteNFTPage({ domainOrSubdomain }: { domainOrSubdomain: string }) {
const site = useGetSite(domainOrSubdomain)
const nftsOrigin = useGetNFTs(site.data?.metadata?.owner)
const [nfts, setNfts] = useState<Asset[]>([])
useEffect(() => {
if (nftsOrigin?.data?.list && !nfts.length) {
setNfts(nftsOrigin.data.list)
}
}, [nftsOrigin.data, nfts])
return (
<>
<Script
type="module"
src="https://cdn.jsdelivr.net/npm/@google/model-viewer/dist/model-viewer.min.js"
></Script>
<h2 className="text-xl font-bold page-title">NFT Showcase</h2>
<div className="grid grid-cols-3 md:grid-cols-4 gap-10 mt-8">
{nfts
.filter((nft) => nft.items?.[0]?.address)
.map((nft: Asset) => (
<UniLink
key={nft.metadata?.proof}
className="xlog-nft flex items-center flex-col"
href={nft.related_urls?.[nft.related_urls.length - 1]}
data-collection={nft.metadata?.collection_address}
data-network={nft.metadata?.network}
data-token-id={nft.metadata?.token_id}
data-name={nft.name}
>
<UniMedia
src={nft.items?.[0]?.address || ""}
mime_type={nft.items?.[0].mime_type}
/>
<div className="text-xs mt-2 text-center font-medium">
{nft.name}
</div>
</UniLink>
))}
</div>
</>
)
}
SiteNFTPage.getLayout = (page: ReactElement) => {
return <SiteLayout>{page}</SiteLayout>
}
export default SiteNFTPage

View File

@ -23,7 +23,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
},
queryClient,
)
console.log(pages)
const link = getSiteLink({
subdomain: site.username || "",

56
src/pages/api/nfts.ts Normal file
View File

@ -0,0 +1,56 @@
import { NextApiRequest, NextApiResponse } from "next"
import { getNFTs } from "~/models/site.model"
import { getRedis, cacheGet } from "~/lib/redis.server"
import { Asset } from "unidata.js"
import { IPFS_GATEWAY } from "~/lib/env"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const query = req.query
if (!query.address) {
res.status(400).end()
return
}
const redis = getRedis()
const redisKey = `nfts/${query.address}`
const cache = await redis.get(redisKey)
if (cache) {
res.status(200).json(JSON.parse(cache))
} else {
const result = await getNFTs(query.address as string)
await Promise.all(
result.list.map(async (nft: Asset) => {
if (!nft.items?.[0].mime_type && nft.items?.[0]?.address) {
try {
const mime_type = await cacheGet(
`nft-mimetype/${nft.items[0].address}`,
async () => {
const head = await fetch(
`${nft.items![0].address!.replace(
IPFS_GATEWAY,
"https://gateway.ipfs.io/ipfs/",
)}`,
{
method: "HEAD",
},
)
return head.headers.get("content-type")
},
)
nft.items[0].mime_type = mime_type
} catch (error) {
console.warn(error)
}
}
return nft
}),
)
redis.set(redisKey, JSON.stringify(result), "EX", 60 * 60 * 24)
res.status(200).json(result)
}
}

View File

@ -264,3 +264,19 @@ export function useRemoveOperator() {
},
)
}
export const useGetNFTs = (address: string) => {
return useQuery(["getNFTs", address], async () => {
if (!address) {
return null
}
return await (
await fetch(
"/api/nfts?" +
new URLSearchParams({
address,
} as any),
)
).json()
})
}

View File

@ -1,6 +1,18 @@
import Unidata from "unidata.js"
import { IPFS_GATEWAY } from "../lib/env"
import {
MORALIS_WEB3_API_KEY,
ALCHEMY_ETHEREUM_API_KEY,
ALCHEMY_POLYGON_API_KEY,
NFTSCAN_API_KEY,
OPENSEA_API_KEY,
} from "../lib/env.server"
export default new Unidata({
ipfsGateway: IPFS_GATEWAY,
moralisWeb3APIKey: MORALIS_WEB3_API_KEY,
alchemyEthereumAPIKey: ALCHEMY_ETHEREUM_API_KEY,
alchemyPolygonAPIKey: ALCHEMY_POLYGON_API_KEY,
nftscanAPIKey: NFTSCAN_API_KEY,
openseaAPIKey: OPENSEA_API_KEY,
})

View File

@ -10,6 +10,7 @@ export const useUnidata = () => {
connector?.getProvider().then((provider) => {
unidata = new Unidata({
ethereumProvider: provider,
ipfsGateway: IPFS_GATEWAY,
})
})
} else {