Next.jsでポケモン図鑑を作ってみた
このプロジェクトを始めるきっかけにしたのは、ユーザ検索に対してdebounce
を実装したかったことだ。debounce
といえば、ユーザ入力を基に、APIにデータを要求する際に使うことが一般的なようだ。具体的に、ユーザが検索バーに入力することを止めるまで、debounce
はサーバにデータ要求を送信しないという例が挙げられる。
そこで、千匹以上のポケモン情報を提供するPokéAPIを使えば、debounce
を使うかいがあるだろうと思った。
Next 14でユーザ入力のdebounceを実装しようと思えば、PokéAPIとpokedex-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を検知するように.env
のDATABASE_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-folder
にpage.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:ポート/api
でroutes.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,
});
fetcher
はaxiosにした:
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' });
}
}
ポイントは名前のGET
とNextResponse.json()
を返していること。
リクエストパラメータ
この例では、_req
を使わず、試しに任意引数のparams
を利用することにしたが、NextRequestを使いたい場合はreq.nextUrl.searchParams
にgetURLメソッドの使用も可能である。
const id = req.nextUrl.searchParams.get('id');
// もしくは
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
ただ、こうするとid
にヌルチェックを適用しないといけない。
リクエストbody
これは簡単:const body = request.json()
。
参考
-
Netflix Clone React
12月31日2023年 - タイプ絵:pokemon-type-svg-icons
- CSSで中央よせ
Discussion