🦎

Next.jsでポケモン図鑑を作ってみた

2024/01/12に公開

このプロジェクトを始めるきっかけにしたのは、ユーザ検索に対してdebounceを実装したかったことだ。debounceといえば、ユーザ入力を基に、APIにデータを要求する際に使うことが一般的なようだ。具体的に、ユーザが検索バーに入力することを止めるまで、debounceはサーバにデータ要求を送信しないという例が挙げられる。

そこで、千匹以上のポケモン情報を提供するPokéAPIを使えば、debounceを使うかいがあるだろうと思った。

Next 14でユーザ入力のdebounceを実装しようと思えば、PokéAPIpokedex-angular-appライブバージョン)を参考にポケモン図鑑まで実装してしまった。フォルダアーキテクチャはsaas-starter-kitを模倣することにした。

TODO:

  • ユーザ検索をdebounce
  • サーバ・クライアントコンポーネントにルーティングする
  • ポケモンデータをDBに保存
  • ホームに全ポケモンの表示
  • ユーザ検索により表示されるポケモンを変更
  • 詳細ページを完成する
  • NextAuthを導入
  • 表示言語を変更可能にする(例えば日本語に)
  • 編集可能にする(UIからSupabaseを更新)+リセット釦
  • 検索内容をURLに保存し、ページを切り替えても再入力しなくて良いようにする
  • Pokemon Assetsの高質スプライトを使う
  • PWAに変換

ホーム表示

注意点:検索バーの位置に関し、stickyを指定すれば必ずtopかleftを指定しないといけない。さもなくば、スクロールすると検索バーはrelativeであるかのようにのようにページの上に消える。

ポケモンのカード

以下はホームにおけるポケモンの表示例。

ご覧の通り、要素は複数ある:

  • ポケモン番号と名前(「mega」などの拡張も含めて)
  • ポケモンのスプライト(Pokémon Assetsから取得)
  • ポケモンとタイプ・アイコン

タイプ・アイコン

アイコンはアイコン自体とその背景に分けられる。

アイコン自体はpokemon-type-svg-iconsからSVGを取得した。
背景色はタイプ別に指定し、ドロップシャドウを加えた。次に、ホバー効果を以下のように指定した:

  • hover:saturate-200で彩度をあげる
  • hover:scale-110でアイコンの背景を多少大きくする

アイコンを背景の中央に寄せるべくflexを使った。また、アイコンがテキストから十分距離を保つようにテキストのbasis-20でテキストの枠を広くした(親はflex)。

詳細ページ

ポケモンのカードをクリックすると詳細ページが開く:

かっこいい詳細メニュー

両側のテキストに遠近感を与えるには、まずはブロックをy軸方向に回転させ、次に親HTML要素にperspective: 400pxというルールを適用する。さらに、hoverの際に回転を戻すと静的要素にダイナミック感を持たせることもできる。

種族値ゲージ

次に、右側の種族値をそのまま表示できたが、見本はあえてかっこいいデザインを用いたので、そのデザインを再現することにした。

種族値ゲージ

縞模様アニメーション

静止画では見えないが、二つの緑色を交える縞模様は左から右へ流れる。その効果を作るには、まずrepeating-linear-gradientで背景を準備する:

background: repeating-linear-gradient(45deg, #A1CD9B, #A1CD9B 5px, #91C58A 5px, #91C58A 10px);

翻訳

  • 45deg: 左上に向かい
  • #A1CD9B, #A1CD9B 5px: 原点から5pxまで'#A1CD9B'色になる
  • #91C58A 5px, #91C58A 10px: 5px後10pxは'#91C58A'色になる

次に、CSSによるアニメーション実装:

animation: 1s linear infinite stat-gauge-strips;
@keyframes stat-gauge-stripes {
  from {
    background-position: 1rem 0;
  }
  to {
    background-position: 0 0;
  }
}

背景は1remほど移動するため、縞模様の大きさも1remにしないとアニメーションが途中でリセットしているかのように見えてしまう:

background-size: 1rem 1rem;

データ

ポケモンデータは全てPokéAPIのもので、Supabaseデータベースにコピーした。Supabaseにおけるデータベースの構築は容易い、詳細メニューからDBのURLを取得すれば、PrismaがそのURLを検知するように.envDATABASE_URLに設定するだけで完成:

スキーマ

最初のスキーマは名前だけSupabaseに移動させる練習を行いたかったため、PokéAPIの一般ルート、https://pokeapi.co/api/v2/pokemon説明)で以下のデータを受け取りそのままSupabaseに保存した。

model Pokemon {
  id Int      @id @default(autoincrement())
  name String @unique
  url String  @unique
}

モデル変化とSupabase更新

現在(1月1日)のモデルは次の通り:

model Pokemon {
  id              Int     @id
  name            String  @unique
  base_experience Int     @default(0)
  height          Int     @default(0)
  is_default      Boolean @default(true)
  order           Int     @default(0)
  weight          Int     @default(0)
  sprite          String  @default("")
  type1           String  @default("")
  type2           String?
}

モデルを変更すれば、npx prisma migrate devコマンドを実行するだけで、リモートのデータベース(今回はSupabase)のテーブルが自動的に更新されるから非常に便利だ。また、npx prisma generateでprismaクライアントとともにTypeScriptの型が生成、もしくは更新される。

PokéAPIから届くデータと上のモデルの間に相違点はいくつかある:

  • spriteは表裏別や色別に届くため、prismaモデルで表画像を選択した
  • タイプは配列と定義されるため、第一要素をtype1に変換し、あれば第二要素をtype2にした

こういった変更を加えながら、npx prisma studioでデータの変更を観察していた。

新しいモデルを用意し、リレーションを定義する

表示したい情報の全てを含む最終モデルはこちら:

model Pokemon {
  id              Int      @id
  abilites        String[]
  baseExperience Int      @default(0)
  forms           Form[]
  genera          Genus[]
  height          Int      @default(0)
  isDefault       Boolean  @default(true)
  name            String   @unique
  names           Name[]
  order           Int      @default(0)
  stats           Stat[]
  sprite          String   @default("")
  type1           String   @default("")
  type2           String?
  weight          Int      @default(0)
}

abilities(特性)、forms(形態)、genus(分類)、names(複数言語におけるポケモンの名前)、stat(種族値)フィールドを追加した。
複数の形態を持つポケモンもいるため、Pokemonモデルと新しいFormモデルの間に1対多リレーションを定義する。同じく、Genus、NameとStatモデル。

リレーションを定義するには、対象モデルに接続する@relationフィールドを定義する必要がある。例えば、Pokemon Pokemon @relation(fields: [pokemonId], references: [id])fieldsとは現在のモデル(Genus、Nameなど)において、リンクしたいフィールド名の配列であり、referencesで対象モデル(Pokemon)のどのフィールドに接続すべきか指定できる。

model Form {
  id        Int     @id @default(autoincrement())
  pokemonId Int
  name      String
  url       String
  Pokemon   Pokemon @relation(fields: [pokemonId], references: [id])
}
model Genus {
  id        Int     @id @default(autoincrement())
  pokemonId Int
  genus     String  @unique
  language  String
  Pokemon   Pokemon @relation(fields: [pokemonId], references: [id])
}
model Name {
  id        Int     @id @default(autoincrement())
  pokemonId Int
  name      String
  language  String
  Pokemon   Pokemon @relation(fields: [pokemonId], references: [id])
}
model Stat {
  baseStat  Int
  effort    Int
  name      String  @unique
  Pokemon   Pokemon @relation(fields: [pokemonId], references: [id])
  pokemonId Int
}

Routing

Defining Routesで説明されているように、Next.jsはフォルダーアーキテクチャを元にパスを決める。

このシステムを使えば、ページの切り替えを簡単位実装できる。app/my-folderpage.tsファイルを定義し、<Link>コンポーネントを使おう。

例えば、app/user/[name]/page.tsを作成したとする。なら<Link href={'/user/' + name}>を設定できる。ただし、この場合app/user/[name].tsのような一見便利そうな書き方は通用しない。

目的ページでURLパラメータを取得するにはparamsプロップが便利だ:

function Hoge({ params }: { params: { name: string } }) {
  // `name`が使える
  const { name } = params;
  ...
}

APIリクエスト

要は、appフォルダーにapi/routes.tsをおけば、GET localhost:ポート/apiroutes.tsに要求を送ることができる。

また、/api/:idのようなルートを使うには、api/[id]/route.tsファイルを作成するといいが、api/[id].tsは機能しない。

ただ、直接クライアントからAPIを呼び出さないように、カスタムフックを使っている。

カスタムフック

フック内にVercel(Next.jsを開発している会社)のuseSWRフックを利用する:

const { data, error, isLoading } = useSwr(`/api/pokemon/${id}`, fetcher, {
  revalidateIfStale: false,
  revalidateOnFocus: false,
  revalidateOnReconnect: false,
});

fetcheraxiosにした:

const fetcher = (url: string) => axios.get(url).then(res => res.data);

最後にクライアントコンポーネントからフックを呼び出す:

function Hoge({ id }: { id: string }) {
  const { data, error, isLoading } = usePokemon(params.id);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (error) {
    // エラー処理
  }

  return (
    // dataに基づくJSX出力
  );
}

リクエストの受取

受ける側ではHTTPメソッドの名前を持つ関数を定義するというやり方が使える:

export async function GET(_req: NextRequest, { params }: {params: {id: string}}) {
  const { id } = params;

  try {
    const pokemon = await prismadb.pokemon.findUnique({ where: { id: Number(id) } });

    return NextResponse.json(pokemon);
  } catch (error) {
    console.error(error);
    return NextResponse.json({ message: 'Internal server error' });
  }
}

ポイントは名前のGETNextResponse.json()を返していること。

リクエストパラメータ

この例では、_reqを使わず、試しに任意引数のparamsを利用することにしたが、NextRequestを使いたい場合はreq.nextUrl.searchParamsgetURLメソッドの使用も可能である。

const id = req.nextUrl.searchParams.get('id');
// もしくは
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

ただ、こうするとidにヌルチェックを適用しないといけない。

リクエストbody

これは簡単:const body = request.json()

参考

Discussion