NFTギャラリーを構築する
前章で作成したCulturalNFTコントラクトを、ブラウザから操作できるようにしましょう。本章では、Next.js + wagmiを使って、所有するNFTの一覧表示(ギャラリー)とミント機能を備えたフロントエンドを構築します。
第7章で構築したGreeter dAppのプロジェクトを拡張する形で進めます。wagmiやRainbowKitの基本設定はすでに完了している前提です。
コントラクト定義の追加
まず、CulturalNFTコントラクトのABIとアドレスを定義します。
// src/contracts/culturalNFT.ts
export const CULTURAL_NFT_ADDRESS = process.env
.NEXT_PUBLIC_NFT_ADDRESS as `0x${string}`;
export const CULTURAL_NFT_ABI = [
{
inputs: [{ name: "to", type: "address" }, { name: "uri", type: "string" }],
name: "mint",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "nonpayable",
type: "function",
},
{
inputs: [{ name: "tokenId", type: "uint256" }],
name: "ownerOf",
outputs: [{ name: "", type: "address" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ name: "owner", type: "address" }],
name: "balanceOf",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ name: "tokenId", type: "uint256" }],
name: "tokenURI",
outputs: [{ name: "", type: "string" }],
stateMutability: "view",
type: "function",
},
{
inputs: [
{ name: "owner", type: "address" },
{ name: "index", type: "uint256" },
],
name: "tokenOfOwnerByIndex",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "totalMinted",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
] as const;
ABIは artifacts/contracts/CulturalNFT.sol/CulturalNFT.json から必要な関数だけを抜き出して定義しています。as const を付けることで、wagmiが型推論を効かせてくれます。
所有NFTの一覧を取得するフック
ユーザーが所有するNFTのtokenIdリストを取得するカスタムフックを作成します。ERC721Enumerableの balanceOf と tokenOfOwnerByIndex を組み合わせて使います。
// src/hooks/useOwnedNFTs.ts
"use client";
import { useReadContract, useReadContracts } from "wagmi";
import {
CULTURAL_NFT_ADDRESS,
CULTURAL_NFT_ABI,
} from "@/contracts/culturalNFT";
export function useOwnedNFTs(ownerAddress: `0x${string}` | undefined) {
// 1. 所有トークン数を取得
const { data: balance } = useReadContract({
address: CULTURAL_NFT_ADDRESS,
abi: CULTURAL_NFT_ABI,
functionName: "balanceOf",
args: ownerAddress ? [ownerAddress] : undefined,
query: { enabled: !!ownerAddress },
});
const tokenCount = balance ? Number(balance) : 0;
// 2. 各インデックスのtokenIdを一括取得
const tokenIdQueries = Array.from({ length: tokenCount }, (_, i) => ({
address: CULTURAL_NFT_ADDRESS,
abi: CULTURAL_NFT_ABI,
functionName: "tokenOfOwnerByIndex" as const,
args: [ownerAddress!, BigInt(i)] as const,
}));
const { data: tokenIdResults } = useReadContracts({
contracts: tokenIdQueries,
query: { enabled: tokenCount > 0 },
});
const tokenIds =
tokenIdResults
?.filter((r) => r.status === "success")
.map((r) => Number(r.result)) ?? [];
return { tokenIds, balance: tokenCount };
}
IPFSからメタデータと画像を取得する
NFTの tokenURI はIPFS URI(ipfs://Qm...)です。フロントエンドで表示するには、IPFSゲートウェイ経由でHTTPアクセスに変換する必要があります。
// src/lib/ipfs.ts
// IPFSゲートウェイのURL
const IPFS_GATEWAY =
process.env.NEXT_PUBLIC_IPFS_GATEWAY ||
"https://gateway.pinata.cloud/ipfs/";
/**
* ipfs:// URIをHTTP URLに変換する
*/
export function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
return uri.replace("ipfs://", IPFS_GATEWAY);
}
return uri;
}
/**
* NFTメタデータの型定義
*/
export interface NFTMetadata {
name: string;
description: string;
image: string;
external_url?: string;
attributes?: {
trait_type: string;
value: string;
}[];
}
/**
* tokenURIからメタデータを取得する
*/
export async function fetchNFTMetadata(
tokenURI: string
): Promise<NFTMetadata> {
const url = ipfsToHttp(tokenURI);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.statusText}`);
}
return response.json();
}
NFTカードコンポーネント
個々のNFTを表示するカードコンポーネントを作成します。tokenURI からメタデータを取得し、画像と属性を表示します。
// src/components/NFTCard.tsx
"use client";
import { useEffect, useState } from "react";
import { useReadContract } from "wagmi";
import {
CULTURAL_NFT_ADDRESS,
CULTURAL_NFT_ABI,
} from "@/contracts/culturalNFT";
import { ipfsToHttp, fetchNFTMetadata, NFTMetadata } from "@/lib/ipfs";
interface NFTCardProps {
tokenId: number;
}
export function NFTCard({ tokenId }: NFTCardProps) {
const [metadata, setMetadata] = useState<NFTMetadata | null>(null);
const [loading, setLoading] = useState(true);
// tokenURIをコントラクトから取得
const { data: tokenURI } = useReadContract({
address: CULTURAL_NFT_ADDRESS,
abi: CULTURAL_NFT_ABI,
functionName: "tokenURI",
args: [BigInt(tokenId)],
});
// tokenURIからメタデータを取得
useEffect(() => {
if (!tokenURI) return;
setLoading(true);
fetchNFTMetadata(tokenURI)
.then(setMetadata)
.catch(console.error)
.finally(() => setLoading(false));
}, [tokenURI]);
if (loading) {
return (
<div className="bg-gray-800 rounded-lg p-4 animate-pulse h-64" />
);
}
if (!metadata) {
return (
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-red-400">メタデータの取得に失敗しました</p>
</div>
);
}
return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
{/* NFT画像 */}
<img
src={ipfsToHttp(metadata.image)}
alt={metadata.name}
className="w-full h-48 object-cover"
/>
{/* NFT情報 */}
<div className="p-4">
<h3 className="font-semibold text-lg">{metadata.name}</h3>
<p className="text-sm text-gray-400 mt-1">
Token ID: #{tokenId}
</p>
<p className="text-sm text-gray-300 mt-2 line-clamp-2">
{metadata.description}
</p>
{/* 属性 */}
{metadata.attributes && metadata.attributes.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{metadata.attributes.map((attr, i) => (
<span
key={i}
className="text-xs bg-gray-700 px-2 py-1 rounded"
>
{attr.trait_type}: {attr.value}
</span>
))}
</div>
)}
</div>
</div>
);
}
NFTギャラリーコンポーネント
所有する全NFTをグリッド表示するギャラリーコンポーネントです。
// src/components/NFTGallery.tsx
"use client";
import { useAccount } from "wagmi";
import { useOwnedNFTs } from "@/hooks/useOwnedNFTs";
import { NFTCard } from "./NFTCard";
export function NFTGallery() {
const { address } = useAccount();
const { tokenIds, balance } = useOwnedNFTs(address);
if (!address) {
return (
<p className="text-gray-400">
ウォレットを接続してNFTを表示してください。
</p>
);
}
return (
<div>
<h2 className="text-xl font-semibold mb-4">
所有NFT({balance}件)
</h2>
{balance === 0 ? (
<p className="text-gray-400">
NFTを所有していません。下のフォームからミントしてみましょう。
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{tokenIds.map((tokenId) => (
<NFTCard key={tokenId} tokenId={tokenId} />
))}
</div>
)}
</div>
);
}
ミントフォームコンポーネント
NFTをミントするフォームです。メタデータURI(IPFS URI)を入力してミントを実行します。
// src/components/MintForm.tsx
"use client";
import { useState } from "react";
import {
useAccount,
useWriteContract,
useWaitForTransactionReceipt,
} from "wagmi";
import {
CULTURAL_NFT_ADDRESS,
CULTURAL_NFT_ABI,
} from "@/contracts/culturalNFT";
export function MintForm() {
const { address } = useAccount();
const [metadataURI, setMetadataURI] = useState("");
const {
writeContract,
data: hash,
isPending,
error,
} = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
function handleMint(e: React.FormEvent) {
e.preventDefault();
if (!address || !metadataURI.trim()) return;
writeContract({
address: CULTURAL_NFT_ADDRESS,
abi: CULTURAL_NFT_ABI,
functionName: "mint",
args: [address, metadataURI],
});
}
return (
<form onSubmit={handleMint} className="p-6 bg-gray-800 rounded-lg">
<h2 className="text-lg font-semibold mb-4">NFTをミントする</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">
メタデータURI(IPFS)
</label>
<input
type="text"
value={metadataURI}
onChange={(e) => setMetadataURI(e.target.value)}
placeholder="ipfs://QmYourMetadataHash"
className="w-full px-4 py-2 bg-gray-700 rounded
text-white placeholder-gray-400"
disabled={isPending || isConfirming}
/>
</div>
<button
type="submit"
disabled={isPending || isConfirming || !metadataURI.trim()}
className="w-full px-6 py-2 bg-purple-600 rounded font-semibold
hover:bg-purple-500 disabled:opacity-50
disabled:cursor-not-allowed"
>
{isPending
? "署名待ち..."
: isConfirming
? "確認中..."
: "ミント"}
</button>
</div>
{hash && (
<p className="mt-3 text-sm text-gray-400">
TX:{" "}
<a
href={`https://sepolia.etherscan.io/tx/${hash}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{hash.slice(0, 10)}...{hash.slice(-8)}
</a>
</p>
)}
{isSuccess && (
<p className="mt-2 text-green-400">
NFTがミントされました!ページを再読み込みしてギャラリーを確認してください。
</p>
)}
{error && (
<p className="mt-2 text-red-400">
エラー: {error.message.slice(0, 100)}
</p>
)}
</form>
);
}
NFTページの組み立て
ギャラリーとミントフォームを組み合わせたNFTページを作成します。
// src/app/nft/page.tsx
"use client";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useAccount } from "wagmi";
import { NFTGallery } from "@/components/NFTGallery";
import { MintForm } from "@/components/MintForm";
export default function NFTPage() {
const { isConnected } = useAccount();
return (
<main className="min-h-screen bg-gray-900 text-white p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">
Cultural Heritage NFT Gallery
</h1>
<div className="mb-8">
<ConnectButton />
</div>
<div className="space-y-8">
<NFTGallery />
{isConnected && <MintForm />}
</div>
</div>
</main>
);
}
環境変数の追加
.env.local に新しい環境変数を追加します。
# .env.local に追加
NEXT_PUBLIC_NFT_ADDRESS=0xデプロイされたCulturalNFTのアドレス
NEXT_PUBLIC_IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs/
Pinataの専用ゲートウェイを使う場合は、https://your-gateway.mypinata.cloud/ipfs/ の形式で設定してください。公開ゲートウェイよりも高速で安定しています。
動作確認の手順
# 1. ターミナル1: Hardhatローカルノードを起動
npx hardhat node
# 2. ターミナル2: CulturalNFTをデプロイ
npx hardhat run scripts/deploy.ts --network localhost
# 3. .env.local にデプロイされたアドレスを設定
# 4. ターミナル3: フロントエンドを起動
cd frontend
npm run dev
ブラウザで http://localhost:3000/nft にアクセスし、MetaMaskでHardhatのローカルネットワークに接続します。ミントフォームからIPFS URIを指定してNFTをミントすると、ギャラリーにNFTカードが表示されます。
まとめ
本章では、wagmiのフックを活用してNFTギャラリーとミントUIを構築しました。useReadContract によるコントラクト状態の読み取り、useReadContracts による一括クエリ、IPFSゲートウェイ経由でのメタデータ・画像取得という、NFTフロントエンド開発の基本パターンを学びました。
次章では、ERC4907規格を使ったNFTレンタル機能を実装します。所有権とは別に「使用権」を設定できる仕組みを学びます。