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の balanceOftokenOfOwnerByIndex を組み合わせて使います。

// 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">
            メタデータURIIPFS
          </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レンタル機能を実装します。所有権とは別に「使用権」を設定できる仕組みを学びます。

関連記事