From f139375ae75ebe239d9e4cde79f6efa861f09d0b Mon Sep 17 00:00:00 2001 From: DIYgod Date: Mon, 28 Nov 2022 01:01:59 +0000 Subject: [PATCH] feat: nft page --- deploy/dev/secrets.yaml | 5 ++ deploy/env.production | 5 ++ deploy/prod/secrets.yaml | 5 ++ package.json | 2 +- pnpm-lock.yaml | 18 +++--- src/components/site/SiteLayout.server.tsx | 29 +++++---- src/components/ui/Image.tsx | 1 + src/components/ui/UniMedia.tsx | 75 ++++++++++++++++++++++ src/lib/env.server.ts | 6 ++ src/lib/redis.server.ts | 2 + src/models/site.model.ts | 8 +++ src/pages/_site/[site]/nft.tsx | 77 +++++++++++++++++++++++ src/pages/_site/[site]/sitemap.xml.tsx | 1 - src/pages/api/nfts.ts | 56 +++++++++++++++++ src/queries/site.ts | 16 +++++ src/queries/unidata.server.ts | 12 ++++ src/queries/unidata.ts | 1 + 17 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 src/components/ui/UniMedia.tsx create mode 100644 src/pages/_site/[site]/nft.tsx create mode 100644 src/pages/api/nfts.ts diff --git a/deploy/dev/secrets.yaml b/deploy/dev/secrets.yaml index 64a20504..731c6e5a 100644 --- a/deploy/dev/secrets.yaml +++ b/deploy/dev/secrets.yaml @@ -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 diff --git a/deploy/env.production b/deploy/env.production index fcd1ffbb..44b9e484 100644 --- a/deploy/env.production +++ b/deploy/env.production @@ -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 diff --git a/deploy/prod/secrets.yaml b/deploy/prod/secrets.yaml index 3c4fde75..cae186fc 100644 --- a/deploy/prod/secrets.yaml +++ b/deploy/prod/secrets.yaml @@ -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 diff --git a/package.json b/package.json index fb42d0d8..1df01536 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fc81a6e..7a7f7c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/site/SiteLayout.server.tsx b/src/components/site/SiteLayout.server.tsx index 9e94f164..a38d1d81 100644 --- a/src/components/site/SiteLayout.server.tsx +++ b/src/components/site/SiteLayout.server.tsx @@ -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) }), diff --git a/src/components/ui/Image.tsx b/src/components/ui/Image.tsx index 18e04638..d41d2c6e 100644 --- a/src/components/ui/Image.tsx +++ b/src/components/ui/Image.tsx @@ -9,6 +9,7 @@ export const Image: React.FC< src?: string width?: number | string height?: number | string + "original-src"?: string } & React.HTMLAttributes & ImageProps > = ({ fill, className, alt, src, width, height, ...props }) => { diff --git a/src/components/ui/UniMedia.tsx b/src/components/ui/UniMedia.tsx new file mode 100644 index 00000000..b634b073 --- /dev/null +++ b/src/components/ui/UniMedia.tsx @@ -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 ( +
+
+ {(() => { + switch (type?.split("/")[0]) { + case "video": + return ( +
+
+ ) +} diff --git a/src/lib/env.server.ts b/src/lib/env.server.ts index 0e5f1fca..f7ac9bd0 100644 --- a/src/lib/env.server.ts +++ b/src/lib/env.server.ts @@ -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 diff --git a/src/lib/redis.server.ts b/src/lib/redis.server.ts index 4fb3baa0..90bca251 100644 --- a/src/lib/redis.server.ts +++ b/src/lib/redis.server.ts @@ -16,6 +16,8 @@ if (REDIS_URL) { }) } +export const getRedis = () => redis + export async function cacheGet( key: string | (Record | string | undefined)[], getValueFun: () => Promise, diff --git a/src/models/site.model.ts b/src/models/site.model.ts index 14a6efb5..876ac398 100644 --- a/src/models/site.model.ts +++ b/src/models/site.model.ts @@ -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 +} diff --git a/src/pages/_site/[site]/nft.tsx b/src/pages/_site/[site]/nft.tsx new file mode 100644 index 00000000..74c2c743 --- /dev/null +++ b/src/pages/_site/[site]/nft.tsx @@ -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([]) + useEffect(() => { + if (nftsOrigin?.data?.list && !nfts.length) { + setNfts(nftsOrigin.data.list) + } + }, [nftsOrigin.data, nfts]) + + return ( + <> + +

NFT Showcase

+
+ {nfts + .filter((nft) => nft.items?.[0]?.address) + .map((nft: Asset) => ( + + +
+ {nft.name} +
+
+ ))} +
+ + ) +} + +SiteNFTPage.getLayout = (page: ReactElement) => { + return {page} +} + +export default SiteNFTPage diff --git a/src/pages/_site/[site]/sitemap.xml.tsx b/src/pages/_site/[site]/sitemap.xml.tsx index 61f6177f..a353e814 100644 --- a/src/pages/_site/[site]/sitemap.xml.tsx +++ b/src/pages/_site/[site]/sitemap.xml.tsx @@ -23,7 +23,6 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { }, queryClient, ) - console.log(pages) const link = getSiteLink({ subdomain: site.username || "", diff --git a/src/pages/api/nfts.ts b/src/pages/api/nfts.ts new file mode 100644 index 00000000..46f059d8 --- /dev/null +++ b/src/pages/api/nfts.ts @@ -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) + } +} diff --git a/src/queries/site.ts b/src/queries/site.ts index efb39162..bef2f5cf 100644 --- a/src/queries/site.ts +++ b/src/queries/site.ts @@ -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() + }) +} diff --git a/src/queries/unidata.server.ts b/src/queries/unidata.server.ts index 60d4fa02..b6e60a03 100644 --- a/src/queries/unidata.server.ts +++ b/src/queries/unidata.server.ts @@ -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, }) diff --git a/src/queries/unidata.ts b/src/queries/unidata.ts index df83d3d6..75c17d1b 100644 --- a/src/queries/unidata.ts +++ b/src/queries/unidata.ts @@ -10,6 +10,7 @@ export const useUnidata = () => { connector?.getProvider().then((provider) => { unidata = new Unidata({ ethereumProvider: provider, + ipfsGateway: IPFS_GATEWAY, }) }) } else {