映画リストコンポーネントの作成
画像ファイルの準備
- 下記ファイルをダウンロードする。
- recsyslab / recsys-full / src / frontend / public / img /
dummy_poster.png
- recsyslab / recsys-full / src / frontend / public / img /
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の内容が表示されています。
参考
- 手島拓也,吉田健人,高林佳稀,『TypeScript と React/Next.js でつくる 実践 Web アプリケーション開発』,技術評論社,2022.
- 6.2.3 アプリケーションで使用されるデータの型