View on GitHub

映画リストコンポーネントの作成

Home

映画リストコンポーネントの作成

画像ファイルの準備

  1. 下記ファイルをダウンロードする。
frontend$ mkdir -p public/img/
frontend$ mv ~/Downloads/dummy_poster.png public/img/
frontend$ ls public/img/
dummy_poster.png

定数の定義

src/frontend/src/constants/messages.ts

/**
 * メッセージの定数
 */
export const MESSAGES = {
  SIGN_UP_SUCCESS: "ユーザ登録が完了しました。サインインしてください。",
  DUMMY_IMAGE_LOADED: 'ダミー画像が読み込まれました', // <- 追加
} as const;
......

src/frontend/src/constants/styles.ts

/**
 * スタイル関連の定数
 */
export const STYLES = {
  ......
  // ↓追加
  // 映画リスト関連
  LIST_MOVIE: "mx-4 my-4 flex justify-between",
  LIST_MOVIE_LABEL_PHRASE: "text-lg font-bold",
  LIST_MOVIE_INSIDE: "flex flex-1 flex-wrap justify-center gap-2",
  LIST_MOVIE_PER_PAGE_DEFAULT: 5,

  // 本日のおすすめ映画リスト関連
  LIST_MOVIE_DAILY_PHRASE: '本日のおすすめ',

  // 映画カード関連
  MOVIE_CARD: `flex h-56 w-32 flex-col rounded-lg border border-gray-300 bg-white shadow-lg`,
  MOVIE_IMAGE: `relative flex cursor-pointer justify-center overflow-hidden`,
  MOVIE_POSTER_PATH_DUMMY: "/img/dummy_poster.png",
  MOVIE_POSTER_WIDTH: 120,
  MOVIE_POSTER_HEIGHT: 180,
  MOVIE_INFO: "flex items-start",
  MOVIE_LABEL_TITLE: `line-clamp-2 text-sm font-bold break-words`,
  // ↑追加
} as const;

データ型の定義

src/frontend/src/types/data.d.ts

......
// ↓追加
/**
 * ジャンルモデル
 */
export type Genre = {
  genre_id: number;
  genre_name: string;
};

/**
 * 映画モデル
 */
export type Movie = {
  movie_id: number;
  title: string;
  year: number;
  genres: Genre[];
  imdb_id: number;
  tmdb_id: number;
};
// ↑追加

コンポーネントの作成

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

src/frontend/src/app/components/ui/card/CardMovie.tsx

"use client";

import React, { useCallback, useMemo } from "react";
import Image from "next/image";
import Link from "next/link";

import { Movie } from "@/types/data";
import { STYLES, MESSAGES } from "@/constants";

interface Props {
  movie: Movie;
}

/**
 * 映画カードコンポーネント
 */
const CardMovie = (props: Props) => {
  const posterPath = useMemo(() => {
    const path = STYLES.MOVIE_POSTER_PATH_DUMMY;
    return path;
  }, [props.movie]);

  /**
   * 画像エラーハンドラ
   * @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);
    },
    [],
  );

  return (
    <article className={`${STYLES.MOVIE_CARD}`} key={props.movie.movie_id}>
      <div className={`${STYLES.MOVIE_IMAGE}`}>
        <Link
          href={`/movies/${props.movie.movie_id}/`}
          className="hover:opacity-75"
        >
          <Image
            src={posterPath}
            alt={props.movie.title}
            width={STYLES.MOVIE_POSTER_WIDTH}
            height={STYLES.MOVIE_POSTER_HEIGHT}
            unoptimized
            onError={handleImageError}
            priority={false}
            loading="lazy"
          />
        </Link>
      </div>
      <div className={`${STYLES.MOVIE_INFO}`}>
        <div className={`${STYLES.MOVIE_LABEL_TITLE}`}>{props.movie.title}</div>
      </div>
    </article>
  );
};

export default CardMovie;

映画リストコンポーネント

src/frontend/src/app/components/list/ListMovie.tsx

"use client";

import { useEffect, useState } from "react";

import { Movie } from "@/types/data";
import { STYLES } from "@/constants";

import CardMovie from "../card/CardMovie";

interface Props {
  phrase: string;
  movies: Movie[];
}

/**
 * 映画リストコンポーネント
 */
const ListMovie = (props: Props) => {
  const [movies, setMovies] = useState<Movie[]>(props.movies);
  const [perPage, setPerPage] = useState<number>(
    STYLES.LIST_MOVIE_PER_PAGE_DEFAULT,
  );
  const [currentMovies, setCurrentMovies] = useState<Movie[]>([]);

  useEffect(() => {
    setMovies(props.movies);
  }, [props.movies]);

  useEffect(() => {
    if (movies.length === 0 || perPage <= 0) {
      setCurrentMovies([]);
      return;
    }

    const start = 0;
    const end = start + perPage;
    const currentMovies_ = movies.slice(start, end);
    setCurrentMovies(currentMovies_);
  }, [movies, perPage]);

  // 空の状態の表示
  if (movies.length === 0) {
    return <></>;
  }

  return (
    <>
      <div className={`${STYLES.LIST_MOVIE_LABEL_PHRASE}`}>{props.phrase}</div>
      <div className={`${STYLES.LIST_MOVIE}`}>
        <div className={`${STYLES.LIST_MOVIE_INSIDE}`}>
          {currentMovies.map((movie, index) => (
            <CardMovie movie={movie} key={movie.movie_id} />
          ))}
        </div>
      </div>
    </>
  );
};

export default ListMovie;

本日のおすすめ映画リストコンポーネント

src/frontend/src/app/components/list/ListMovieDaily.tsx

"use client";

import { Movie } from "@/types/data";
import { STYLES } from "@/constants";

import ListMovie from "./ListMovie";

/**
 * 本日のおすすめ映画リストコンポーネント
 */
const ListMovieDaily = () => {
  const phrase = STYLES.LIST_MOVIE_DAILY_PHRASE;

  const { movies }: { movies: Movie[] } = {
    movies: [
      {
        movie_id: 1,
        title: "Toy Story",
        year: 1995,
        genres: [
          { genre_id: 2, genre_name: "Adventure" },
          { genre_id: 3, genre_name: "Animation" },
          { genre_id: 4, genre_name: "Children" },
          { genre_id: 5, genre_name: "Comedy" },
          { genre_id: 9, genre_name: "Fantasy" },
        ],
        imdb_id: 114709,
        tmdb_id: 862,
      },
      {
        movie_id: 2,
        title: "Jumanji",
        year: 1995,
        genres: [
          { genre_id: 2, genre_name: "Adventure" },
          { genre_id: 4, genre_name: "Children" },
          { genre_id: 9, genre_name: "Fantasy" },
        ],
        imdb_id: 113497,
        tmdb_id: 8844,
      },
      {
        movie_id: 3,
        title: "Grumpier Old Men",
        year: 1995,
        genres: [
          { genre_id: 5, genre_name: "Comedy" },
          { genre_id: 14, genre_name: "Romance" },
        ],
        imdb_id: 113228,
        tmdb_id: 15602,
      },
      {
        movie_id: 4,
        title: "Waiting to Exhale",
        year: 1995,
        genres: [
          { genre_id: 5, genre_name: "Comedy" },
          { genre_id: 8, genre_name: "Drama" },
          { genre_id: 14, genre_name: "Romance" },
        ],
        imdb_id: 114885,
        tmdb_id: 31357,
      },
      {
        movie_id: 5,
        title: "Father of the Bride Part II",
        year: 1995,
        genres: [{ genre_id: 5, genre_name: "Comedy" }],
        imdb_id: 113041,
        tmdb_id: 11862,
      },
      {
        movie_id: 6,
        title: "Heat",
        year: 1995,
        genres: [
          { genre_id: 1, genre_name: "Action" },
          { genre_id: 6, genre_name: "Crime" },
          { genre_id: 16, genre_name: "Thriller" },
        ],
        imdb_id: 113277,
        tmdb_id: 949,
      },
      {
        movie_id: 7,
        title: "Sabrina",
        year: 1995,
        genres: [
          { genre_id: 5, genre_name: "Comedy" },
          { genre_id: 14, genre_name: "Romance" },
        ],
        imdb_id: 114319,
        tmdb_id: 11860,
      },
      {
        movie_id: 8,
        title: "Tom and Huck",
        year: 1995,
        genres: [
          { genre_id: 2, genre_name: "Adventure" },
          { genre_id: 4, genre_name: "Children" },
        ],
        imdb_id: 112302,
        tmdb_id: 45325,
      },
    ],
  };
  console.log(movies);

  return (
    <>
      <ListMovie phrase={phrase} movies={movies} />
    </>
  );
};

export default ListMovieDaily;

インデックスページ

src/frontend/src/app/components/Index.tsx

'use client';

import { useEffect, useState } from 'react';

import { User } from '@/types/data';
import SignInPage from '@/app/signin/page';
import getMyAccount from '@/api/auth/getMyAccount';
import useAuth from '@/hooks/useAuth';

import ListMovieDaily from './ui/list/ListMovieDaily'; // <- 追加

/**
 * インデックスコンポーネント
 *
 * JWT の有効期限を監視し、タイムアウト時にメイン画面をログアウト状態へ切り替える。
 */
const Index = () => {
  ......
  return (
    <>
      {isLoading ? (
        <div>Loading...</div>
      ) : !isAuthenticated ? (
        <SignInPage />
      ) : (
        <>
          <div>ようこそ {user?.user_email} さん!</div>
          <ListMovieDaily /> {/* <- 追加 */}
        </>
      )}
    </>
  );
};

export default Index;

ブラウザで下記 URL にアクセスしてください。

サインインすると、映画リストが表示されます。また、ブラウザのデベロッパーツールのコンソールにmoviesの内容が表示されています。

参考

  1. 手島拓也,吉田健人,高林佳稀,『TypeScript と React/Next.js でつくる 実践 Web アプリケーション開発』,技術評論社,2022.
    • 6.2.3 アプリケーションで使用されるデータの型