diff --git a/app/.env b/app/.env index 260fbc4..d22e40d 100644 --- a/app/.env +++ b/app/.env @@ -1,4 +1,4 @@ NEXT_PUBLIC_NETWORK_URL="https://raw.githubusercontent.com/overlock-network/net/main" NEXT_PUBLIC_CONFIGURATION_COLLECTION_UPDATE_AUTHORITY="7yJ14gQeQn3nNGv8sG1Up1MHLZdk8WD61g1s4A5Kn81h,7yJ14gQeQn3nNGv8sG1Up1MHLZdk8WD61g1s4A5Kn81h" NEXT_PUBLIC_ANCHOR_PROVIDER_URL="http://localhost:8899" -NEXT_PUBLIC_GAS_CONTRACT_ID="1" \ No newline at end of file +NEXT_PUBLIC_GAS_CONTRACT_ID="1" diff --git a/app/src/app/marketplace/gas/collection/content.tsx b/app/src/app/marketplace/gas/collection/content.tsx new file mode 100644 index 0000000..7881269 --- /dev/null +++ b/app/src/app/marketplace/gas/collection/content.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { GasContractClient } from "@/chain/client"; + +import { useSearchParams } from "next/navigation"; +import { NftProvider } from "@/chain/client"; +import { NftCards } from "@/components/NftCards"; + +export function Content() { + const searchParams = useSearchParams(); + const contractId = searchParams.get("id"); + if (!contractId) return; + + return ( + + queryClientClass={GasContractClient} + fetchInfo={async (client, tokenId) => { + return await client.nftInfo({ tokenId }); + }} + contractId={contractId} + fetchNft={async (client, limit, startAfter) => { + return await client.allTokens({ limit, startAfter }); + }} + > + + + ); +} diff --git a/app/src/app/marketplace/gas/collection/page.tsx b/app/src/app/marketplace/gas/collection/page.tsx new file mode 100644 index 0000000..9c93887 --- /dev/null +++ b/app/src/app/marketplace/gas/collection/page.tsx @@ -0,0 +1,18 @@ +import { Inset } from "@/components/AppSidebar"; +import { Content } from "./content"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Gas Collection NFT", + description: "List of Collection NFT.", +}; + +export default async function GasCollectionPage() { + return ( + +
+ +
+
+ ); +} diff --git a/app/src/app/marketplace/gas/content.tsx b/app/src/app/marketplace/gas/content.tsx index e42af21..46ceb7a 100644 --- a/app/src/app/marketplace/gas/content.tsx +++ b/app/src/app/marketplace/gas/content.tsx @@ -1,7 +1,6 @@ "use client"; -import { ContractsProvider } from "@/chain/client"; -import { Cw721baseQueryClient } from "@/../../generated/Cw721base.client"; +import { ContractsProvider, GasContractClient } from "@/chain/client"; import { ContractsTable } from "@/components/ContractsTable"; export function Content() { @@ -9,7 +8,7 @@ export function Content() { if (!contractCodeId) return; return ( { const info = await client.contractInfo(); return { diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 0565866..620e9ec 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -38,7 +38,7 @@ export default function Home() { -
+
diff --git a/app/src/app/providers/page.tsx b/app/src/app/providers/page.tsx index 02996d3..88f0eae 100644 --- a/app/src/app/providers/page.tsx +++ b/app/src/app/providers/page.tsx @@ -10,7 +10,7 @@ export const metadata: Metadata = { export default function ProvidersListPage() { return ( -
+
diff --git a/app/src/chain/client/cosmos/components/ContractsProvider/ContractsProvider.tsx b/app/src/chain/client/cosmos/components/ContractsProvider/ContractsProvider.tsx index 2a9a2ac..17061ca 100644 --- a/app/src/chain/client/cosmos/components/ContractsProvider/ContractsProvider.tsx +++ b/app/src/chain/client/cosmos/components/ContractsProvider/ContractsProvider.tsx @@ -28,14 +28,7 @@ export function ContractsProvider({ useEffect(() => { const rpcEndpoint = networkMeta.apis.rpc[0].address; - if (!rpcEndpoint) { - toast({ - title: "Error", - description: "Missing RPC endpoint in environment", - variant: "destructive", - }); - return; - } + CosmWasmClient.connect(rpcEndpoint) .then(setClient) .catch(() => { @@ -56,8 +49,8 @@ export function ContractsProvider({ const addresses = await client.getContracts(parseInt(contractCodeId)); const results = await Promise.all( addresses.map(async (address) => { - const instance = new queryClientClass(client, address); - return await fetchInfo(instance); + const queryClient = new queryClientClass(client, address); + return await fetchInfo(queryClient); }), ); setContracts(results); diff --git a/app/src/chain/client/cosmos/components/NftProvider/NftProvider.tsx b/app/src/chain/client/cosmos/components/NftProvider/NftProvider.tsx new file mode 100644 index 0000000..8cff728 --- /dev/null +++ b/app/src/chain/client/cosmos/components/NftProvider/NftProvider.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useState, + useCallback, +} from "react"; +import { CosmWasmClient, JsonObject } from "@cosmjs/cosmwasm-stargate"; +import { useToast } from "@/hooks/use-toast"; +import { Nft } from "@/lib/types"; +import { + NftContextType, + NftProviderProps, + useNetwork, +} from "@/chain/client/cosmos"; + +const NftContext = createContext(undefined); + +const normalizeUri = (uri: string): string => { + if (!uri) return ""; + return uri.startsWith("ipfs://") + ? uri.replace("ipfs://", "https://ipfs.io/ipfs/") + : uri; +}; + +export function NftProvider({ + queryClientClass, + fetchNft, + fetchInfo, + children, +}: NftProviderProps) { + const [client, setClient] = useState(null); + const { toast } = useToast(); + const { networkMeta } = useNetwork(); + + useEffect(() => { + const rpcEndpoint = networkMeta.apis.rpc[0].address; + + CosmWasmClient.connect(rpcEndpoint) + .then(setClient) + .catch(() => { + toast({ + title: "Error", + description: "Failed to connect to RPC", + variant: "destructive", + }); + }); + }, [toast, networkMeta]); + + const getCollectionNft = useCallback( + async ( + address: string, + startAfter?: string, + limit: number = 20, + ): Promise<{ nft: Nft[]; nextStartAfter?: string }> => { + if (!client) return { nft: [] }; + + try { + const queryClient = new queryClientClass(client, address); + const { tokens = [] } = await fetchNft(queryClient, limit, startAfter); + + const nextStartAfter = + tokens.length === limit ? tokens[tokens.length - 1] : undefined; + + const nft = await Promise.all( + tokens.map(async (tokenId) => { + const nftInfo = await fetchInfo(queryClient, tokenId); + const tokenUri = nftInfo.token_uri || ""; + + let metadata: JsonObject = {}; + if (tokenUri) { + const res = await fetch(normalizeUri(tokenUri)); + if (res.ok) metadata = await res.json(); + } + + return { + tokenId, + tokenUri, + name: metadata.name, + image: normalizeUri(metadata.image), + description: metadata.description, + }; + }), + ); + + return { nft, nextStartAfter }; + } catch { + toast({ + title: "Error", + description: "Failed to load NFT from collection", + variant: "destructive", + }); + return { nft: [] }; + } + }, + [client, toast], + ); + + return ( + + {children} + + ); +} + +export const useNft = () => { + const context = useContext(NftContext); + if (!context) throw new Error("useNft must be used within a NftProvider"); + return context; +}; diff --git a/app/src/chain/client/cosmos/components/NftProvider/index.ts b/app/src/chain/client/cosmos/components/NftProvider/index.ts new file mode 100644 index 0000000..15e70c5 --- /dev/null +++ b/app/src/chain/client/cosmos/components/NftProvider/index.ts @@ -0,0 +1 @@ +export { NftProvider, useNft } from "./NftProvider"; diff --git a/app/src/chain/client/cosmos/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx b/app/src/chain/client/cosmos/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx index 4f7c5b5..be08fd5 100644 --- a/app/src/chain/client/cosmos/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx +++ b/app/src/chain/client/cosmos/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { useChain } from "@cosmos-kit/react"; import { useNetwork } from "../NetworkProvider"; diff --git a/app/src/chain/client/cosmos/components/index.ts b/app/src/chain/client/cosmos/components/index.ts index 2005315..e85c616 100644 --- a/app/src/chain/client/cosmos/components/index.ts +++ b/app/src/chain/client/cosmos/components/index.ts @@ -6,3 +6,4 @@ export { export { WalletProviderWrapper } from "./WalletProviderWrapper"; export { useWallet } from "./WalletProvider"; export { ContractsProvider, useContracts } from "./ContractsProvider"; +export { NftProvider, useNft } from "./NftProvider"; diff --git a/app/src/chain/client/cosmos/index.ts b/app/src/chain/client/cosmos/index.ts index bb03f47..4fe0294 100644 --- a/app/src/chain/client/cosmos/index.ts +++ b/app/src/chain/client/cosmos/index.ts @@ -1,3 +1,4 @@ export * from "./components"; export * from "./lib/types"; export * from "./hooks"; +export { Cw721baseQueryClient as GasContractClient } from "@/../../generated/Cw721base.client"; diff --git a/app/src/chain/client/cosmos/lib/types.ts b/app/src/chain/client/cosmos/lib/types.ts index c98d28c..3847a6b 100644 --- a/app/src/chain/client/cosmos/lib/types.ts +++ b/app/src/chain/client/cosmos/lib/types.ts @@ -1,6 +1,7 @@ import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import { Contract } from "@/lib/types"; +import { Contract, Nft } from "@/lib/types"; import { overlock } from "@overlocknetwork/api"; +import { TokensResponse } from "@/../../generated/Cw721base.types"; export type Network = { name: string; icon: React.ElementType }; @@ -96,6 +97,17 @@ export interface ContractsContextType { loading: boolean; } +export interface NftContextType { + getCollectionNft: ( + address: string, + startAfter?: string, + limit?: number, + ) => Promise<{ + nft: Nft[]; + nextStartAfter?: string; + }>; +} + export interface ContractsProviderProps { queryClientClass: QueryClientConstructor; fetchInfo: (instance: T) => Promise; @@ -107,3 +119,18 @@ export type QueryClientConstructor = new ( client: CosmWasmClient, address: string, ) => T; + +export interface NftProviderProps { + queryClientClass: QueryClientConstructor; + fetchNft: ( + instance: T, + limit: number, + startAfter?: string, + ) => Promise; + fetchInfo: ( + instance: T, + tokenId: string, + ) => Promise<{ token_uri?: string | null }>; + children: React.ReactNode; + contractId: string; +} diff --git a/app/src/chain/client/solana/components/NftsProvider/NftProvider.tsx b/app/src/chain/client/solana/components/NftsProvider/NftProvider.tsx new file mode 100644 index 0000000..eec6aa1 --- /dev/null +++ b/app/src/chain/client/solana/components/NftsProvider/NftProvider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, useContext, useCallback } from "react"; +import { Nft } from "@/lib/types"; +import { NftContextType, NftProviderProps } from "@/chain/client/cosmos"; + +const NftContext = createContext(undefined); + +export function NftProvider({ children }: NftProviderProps) { + const getCollectionNft = useCallback(async (): Promise<{ + nft: Nft[]; + nextStartAfter?: string; + }> => { + return { nft: [] }; + }, []); + + return ( + + {children} + + ); +} + +export const useNft = () => { + const context = useContext(NftContext); + if (!context) throw new Error("useNft must be used within a NftProvider"); + return context; +}; diff --git a/app/src/chain/client/solana/components/NftsProvider/index.ts b/app/src/chain/client/solana/components/NftsProvider/index.ts new file mode 100644 index 0000000..15e70c5 --- /dev/null +++ b/app/src/chain/client/solana/components/NftsProvider/index.ts @@ -0,0 +1 @@ +export { NftProvider, useNft } from "./NftProvider"; diff --git a/app/src/chain/client/solana/components/WalletModalProvider/WalletModalProvider.tsx b/app/src/chain/client/solana/components/WalletModalProvider/WalletModalProvider.tsx index 64721aa..b6d2583 100644 --- a/app/src/chain/client/solana/components/WalletModalProvider/WalletModalProvider.tsx +++ b/app/src/chain/client/solana/components/WalletModalProvider/WalletModalProvider.tsx @@ -1,4 +1,5 @@ -import React, { createContext, useContext } from "react"; +"use client"; +import { createContext, useContext } from "react"; import { useWalletModal as useSolanaWalletModal } from "@solana/wallet-adapter-react-ui"; interface WalletModalContextType { diff --git a/app/src/chain/client/solana/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx b/app/src/chain/client/solana/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx index 3e3ec6f..62faeb0 100644 --- a/app/src/chain/client/solana/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx +++ b/app/src/chain/client/solana/components/WalletModalProviderWrapper/WalletModalProviderWrapper.tsx @@ -1,3 +1,4 @@ +"use client"; import { WalletModalProvider as SolanaWalletModalProvider } from "@solana/wallet-adapter-react-ui"; import { WalletModalProvider } from "../WalletModalProvider"; diff --git a/app/src/chain/client/solana/components/WalletProviderWrapper/WalletProviderWrapper.tsx b/app/src/chain/client/solana/components/WalletProviderWrapper/WalletProviderWrapper.tsx index 392b6a1..bd62eb4 100644 --- a/app/src/chain/client/solana/components/WalletProviderWrapper/WalletProviderWrapper.tsx +++ b/app/src/chain/client/solana/components/WalletProviderWrapper/WalletProviderWrapper.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState } from "react"; +"use client"; +import { useEffect, useState } from "react"; import { WalletProvider as SolanaWalletProvider } from "@solana/wallet-adapter-react"; import { WalletProvider } from "../WalletProvider"; diff --git a/app/src/components/AppSidebar/Inset.tsx b/app/src/components/AppSidebar/Inset.tsx index 63a641c..7c8158d 100644 --- a/app/src/components/AppSidebar/Inset.tsx +++ b/app/src/components/AppSidebar/Inset.tsx @@ -30,8 +30,8 @@ export function Inset({ return ( -
-
+
+
{pageTitle == "Editor" && configurationId ? ( diff --git a/app/src/components/ContractsTable/ContractsTable.tsx b/app/src/components/ContractsTable/ContractsTable.tsx index a7afe92..a74b627 100644 --- a/app/src/components/ContractsTable/ContractsTable.tsx +++ b/app/src/components/ContractsTable/ContractsTable.tsx @@ -2,9 +2,11 @@ import { useContracts } from "@/chain/client"; import { DataTable } from "../ListTable/DataTable"; import { Contract } from "@/lib/types"; import { ContractInfoColumns } from "../ListTable/ContractInfoColumns"; +import { useRouter } from "next/navigation"; export function ContractsTable() { const { contracts, loading } = useContracts(); + const router = useRouter(); return (
@@ -12,6 +14,9 @@ export function ContractsTable() { columns={ContractInfoColumns()} data={contracts} isLoading={loading} + onRowClick={(row) => + router.push(`/marketplace/gas/collection?id=${row.address}`) + } />
); diff --git a/app/src/components/ListTable/CollectionsColumns.tsx b/app/src/components/ListTable/CollectionsColumns.tsx new file mode 100644 index 0000000..393e249 --- /dev/null +++ b/app/src/components/ListTable/CollectionsColumns.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTableColumnHeader } from "./ColumnHeader"; +import { Contract } from "@/lib/types"; +import { formatAddress } from "@/lib/utils"; + +export const CollectionsColumns = (): ColumnDef[] => [ + { + accessorKey: "address", + header: ({ column }) => ( + + ), + cell: ({ row }) => formatAddress(row.original.address), + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return row.original.name; + }, + }, + { + accessorKey: "symbol", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return row.original.symbol; + }, + }, +]; diff --git a/app/src/components/NftCards/NftCards.tsx b/app/src/components/NftCards/NftCards.tsx new file mode 100644 index 0000000..89c2751 --- /dev/null +++ b/app/src/components/NftCards/NftCards.tsx @@ -0,0 +1,109 @@ +import { Nft } from "@/lib/types"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Pagination } from "@/components/Pagination"; +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { useNft } from "@/chain/client"; + +const NFT_CARDS_QUANTITY = 20; + +export function NftCards({ id }: { id: string }) { + const { getCollectionNft } = useNft(); + const [nft, setNft] = useState([]); + const [pageLoading, setPageLoading] = useState(false); + const [page, setPage] = useState(0); + const [startAfterMap, setStartAfterMap] = useState<{ + [key: number]: string | undefined; + }>({ 0: undefined }); + const [hasNextPage, setHasNextPage] = useState(false); + + useEffect(() => { + const fetch = async () => { + setPageLoading(true); + const startAfter = startAfterMap[page]; + + try { + const { nft: fetchedNft, nextStartAfter } = await getCollectionNft( + id, + startAfter, + NFT_CARDS_QUANTITY, + ); + setNft(fetchedNft); + setHasNextPage(!!nextStartAfter); + + if (nextStartAfter) { + setStartAfterMap((prev) => ({ + ...prev, + [page + 1]: nextStartAfter, + })); + } + } finally { + setPageLoading(false); + } + }; + + fetch(); + }, [id, page, getCollectionNft]); + + const handlePageChange = (newPage: number) => { + if (newPage < 0) return; + if (newPage > page && !hasNextPage) return; + setPage(newPage); + }; + + return ( +
+
+ {nft.map((n) => ( + + {n.image ? ( +
+ {n.name +
+ ) : ( +
+ No Image +
+ )} + + + {n.name || `Token #${n.tokenId}`} + + {n.description && ( + + {n.description} + + )} + + + + +
+ ))} +
+ + {nft.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/app/src/components/NftCards/index.ts b/app/src/components/NftCards/index.ts new file mode 100644 index 0000000..ceb5ea9 --- /dev/null +++ b/app/src/components/NftCards/index.ts @@ -0,0 +1 @@ +export { NftCards } from "./NftCards"; diff --git a/app/src/components/Pagination/Pagination.tsx b/app/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..299a8a2 --- /dev/null +++ b/app/src/components/Pagination/Pagination.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { + Pagination as ShadcnPagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { Spinner } from "@/components/Spinner"; + +interface PaginationProps { + page: number; + hasNextPage: boolean; + onPageChange: (newPage: number) => void; + loading?: boolean; +} + +export function Pagination({ + page, + hasNextPage, + onPageChange, + loading = false, +}: PaginationProps) { + return ( + + + + { + e.preventDefault(); + onPageChange(page - 1); + }} + isActive={page === 0} + /> + + + + { + e.preventDefault(); + onPageChange(0); + }} + > + {loading && page === 0 ? : "1"} + + + + {page > 2 && ( + + + + )} + + {[page - 1, page, page + 1] + .filter((p) => p > 0) + .map((p) => ( + + { + e.preventDefault(); + onPageChange(p); + }} + > + {loading && p === page ? : p + 1} + + + ))} + + {hasNextPage && ( + + + + )} + + + { + e.preventDefault(); + onPageChange(page + 1); + }} + isActive={!hasNextPage} + /> + + + + ); +} diff --git a/app/src/components/Pagination/index.ts b/app/src/components/Pagination/index.ts new file mode 100644 index 0000000..dc87b27 --- /dev/null +++ b/app/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export { Pagination } from "./Pagination"; diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 30e0bef..83aa8da 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -74,3 +74,11 @@ export interface Contract { symbol: string; address: string; } + +export interface Nft { + tokenId: string; + tokenUri: string; + name: string; + image: string; + description: string; +}