View on GitHub

映画詳細ページの作成

Home

映画詳細ページの作成

定数の定義

src/frontend/src/constants/styles.ts

/**
 * スタイル関連の定数
 */
export const STYLES = {
  ......
  // ↓追加
  // 映画(詳細)カード関連
  MOVIE_DETAIL_PAGE: `flex min-h-screen items-center justify-center bg-gray-100`,
  MOVIE_DETAIL_CARD: `w-full max-w-3xl rounded-lg border border-gray-300 bg-white p-8 shadow-lg`,
  MOVIE_DETAIL_TITLE: `mb-8 text-4xl font-bold`,
  MOVIE_DETAIL_BODY: `flex flex-col gap-8 md:flex-row`,
  MOVIE_DETAIL_IMAGE: `relative flex shrink-0 cursor-pointer overflow-hidden rounded-lg`,
  MOVIE_DETAIL_POSTER_WIDTH: 150,
  MOVIE_DETAIL_POSTER_HEIGHT: 224,
  MOVIE_DETAIL_INFO: `flex flex-col gap-4`,
  MOVIE_DETAIL_LABEL_YEAR: `text-lg text-gray-500`,
  MOVIE_DETAIL_TAG_GENRES: `flex flex-wrap gap-2`,
  MOVIE_DETAIL_TAG_GENRE: `rounded bg-gray-100 px-3 py-1 text-sm`,
  // ↑追加
} as const;

映画取得 API

src/frontend/src/api/movies/getMovie.ts

import { ApiContext, Movie } from "@/types/data";
import { fetcher } from "@/utils";

const context: ApiContext = {
  apiRootUrl: process.env.NEXT_PUBLIC_API_BASE_URL,
};

/**
 * 映画取得API
 * @param movieId - 映画ID
 * @returns movie - 映画オブジェクト
 */
const getMovie = async (movieId: number): Promise<{ movie: Movie }> => {
  const url = `${context.apiRootUrl?.replace(/\/$/g, "")}/online/movies/${movieId}`;
  const headers: Record<string, string> = {
    Accept: "application/json",
    "Content-Type": "application/json",
  };

  return await fetcher(url, {
    method: "GET",
    headers: headers,
    cache: "no-store",
  });
};

export default getMovie;

映画詳細カードコンポーネント

src/frontend/src/app/components/card/CardMovieDetail.tsx

"use client";

import React, { useCallback, useEffect, useMemo, useState } from "react";
import Image from "next/image";

import { Movie } from "@/types/data";
import { MESSAGES, STYLES, ERROR_MESSAGES } from "@/constants";
import getMovie from "@/api/movies/getMovie";

type Props = {
  movieId: number;
};

/**
 * 映画(詳細)カードコンポーネント
 */
const CardMovieDetail = (props: Props) => {
  const [loading, setLoading] = useState(true);
  const [movie, setMovie] = useState<Movie | null>(null);

  const posterPath = useMemo(() => {
    const path = STYLES.MOVIE_POSTER_PATH_DUMMY;
    return path;
  }, [movie]);

  useEffect(() => {
    const load = async () => {
      try {
        const { movie: movie_ } = await getMovie(props.movieId);
        setMovie(movie_);
      } catch (e) {
        console.error(ERROR_MESSAGES.MOVIE_GET_FAILED, e);
        setMovie(null);
      } finally {
        setLoading(false);
      }
    };
    load();
  }, []);

  /**
   * 画像エラーハンドラ
   * @param e - 画像エラーイベント
   */
  const handleImageError = useCallback(
    (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
      const target = e.currentTarget;
      target.onerror = null;
      target.src = STYLES.MOVIE_POSTER_PATH_DUMMY;
      console.log(MESSAGES.DUMMY_IMAGE_LOADED, target.src);
    },
    [],
  );

  if (!movie) {
    return;
  }

  return (
    <div className={`${STYLES.MOVIE_DETAIL_PAGE}`}>
      <article className={`${STYLES.MOVIE_DETAIL_CARD}`} key={movie.movie_id}>
        <h1 className={`${STYLES.MOVIE_DETAIL_TITLE}`}>{movie.title}</h1>
        <div className={`${STYLES.MOVIE_DETAIL_BODY}`}>
          <div className={`${STYLES.MOVIE_DETAIL_IMAGE}`}>
            <Image
              src={posterPath}
              alt={movie.title}
              width={STYLES.MOVIE_DETAIL_POSTER_WIDTH}
              height={STYLES.MOVIE_DETAIL_POSTER_HEIGHT}
              unoptimized
              onError={handleImageError}
              priority={false}
              loading="lazy"
            />
          </div>
          <div className={`${STYLES.MOVIE_DETAIL_INFO}`}>
            <div className={`${STYLES.MOVIE_DETAIL_LABEL_YEAR}`}>
              {movie.year}
            </div>
            {movie.genres.length > 0 && (
              <div className={`${STYLES.MOVIE_DETAIL_TAG_GENRES}`}>
                {movie.genres.map((genre) => (
                  <span
                    key={genre.genre_id}
                    className={`${STYLES.MOVIE_DETAIL_TAG_GENRE}`}
                  >
                    {genre.genre_name}
                  </span>
                ))}
              </div>
            )}
          </div>
        </div>
      </article>
    </div>
  );
};

export default CardMovieDetail;

映画詳細ページ

src/frontend/src/app/movies/[movie_id]/page.tsx

import CardMovieDetail from "@/app/components/ui/card/CardMovieDetail";

type Props = {
  params: Promise<{ movie_id: string }>;
};

const Movie = async ({ params }: Props) => {
  const { movie_id } = await params;
  const movieId = Number(movie_id);

  return (
    <>
      <CardMovieDetail movieId={movieId} />
    </>
  );
};

export default Movie;

tsconfig.json の設定

src/frontend/tsconfig.json

{
...(略)...
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/types/app/movies/[movie_id]/page.tsx", // <- 追加
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

ブラウザで下記 URL にアクセスすると、映画 ID 1 の詳細ページが表示されます。

映画リスト内の映画をクリックすることでも詳細ページが表示されます。

参考

  1. 【Next.js 15】動的ルーティング設定時に発生したエラーについて #AppRouter - Qiita
  2. 手島拓也,吉田健人,高林佳稀,『TypeScript と React/Next.js でつくる 実践 Web アプリケーション開発』,技術評論社,2022.
    • 6.9.5 商品詳細ページ