映画詳細ページの作成
定数の定義
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 の詳細ページが表示されます。
映画リスト内の映画をクリックすることでも詳細ページが表示されます。
参考
- 【Next.js 15】動的ルーティング設定時に発生したエラーについて #AppRouter - Qiita
- 手島拓也,吉田健人,高林佳稀,『TypeScript と React/Next.js でつくる 実践 Web アプリケーション開発』,技術評論社,2022.
- 6.9.5 商品詳細ページ