10. Category, ErrorPage 완성하기
요약
Gemini CLI로 구현하기 — Category 페이지네이션 & ErrorPage
npx gemini-
1src/components/Category.jsx와 ErrorPage.jsx를 작성해줘. Category는 useParams로 type(now_playing, popular, top_rated)을 받아서 TMDB API movie/[type]?page=[page]를 호출하고, 이전/다음 버튼으로 페이지네이션을 구현해줘. 최대 페이지는 [최대 페이지 수]으로 제한해줘. ErrorPage는 404 메시지와 홈으로 돌아가기 Link를 포함해줘.
- 사용 가이드:
[최대 페이지 수]를 원하는 숫자(기본: 20)로 바꾼다. 카테고리 한글 이름이 필요하면 프롬프트에 “카테고리 키를 한글로 변환하는 매핑 객체도 추가해줘”를 덧붙인다.
1. 이번 편에서 만들 것
| 컴포넌트 | 기능 |
|---|---|
ErrorPage | 404 등 존재하지 않는 URL 처리 |
Category | 카테고리별 영화 목록 + 페이지네이션 |
2. ErrorPage — 에러 페이지
src/components/ErrorPage.jsx 파일을 열고 임시 코드를 모두 지운 뒤 아래로 교체한다. 존재하지 않는 URL로 접속했을 때 보여주는 페이지이다.
1import { Link } from "react-router";2
3export function ErrorPage() {4 return (5 <div className="bg-black min-h-screen flex flex-col items-center justify-center text-center px-6">6 <h1 className="text-8xl font-bold text-yellow-400">404</h1>7 <p className="text-white text-2xl mt-4">페이지를 찾을 수 없습니다</p>8 <p className="text-gray-400 mt-2">요청하신 페이지가 존재하지 않거나 이동되었습니다.</p>9 <Link10 to="/"11 className="mt-8 bg-yellow-400 text-black px-6 py-3 rounded-lg font-bold hover:bg-yellow-300 transition-colors"12 >13 홈으로 돌아가기14 </Link>15 </div>16 );17}| 줄 | 설명 |
|---|---|
| 1 | Link로 홈 페이지 이동 버튼을 만든다. |
| 6 | 상태 코드 404를 고정으로 표시한다. |
| 11-15 | to="/" — 버튼 클릭 시 홈으로 이동한다. |
3. Category — 카테고리 + 페이지네이션
src/components/Category.jsx 파일을 열고 임시 코드를 모두 지운 뒤 작성한다. 이 컴포넌트는 “인기 영화”, “현재 상영작” 등 카테고리별 영화 목록을 보여주는 페이지이다. 한 페이지에 20편씩 표시하고, “이전”/“다음” 버튼으로 책장을 넘기듯 다른 페이지를 볼 수 있다.
- import + 상태 + API 호출
- JSX + 페이지네이션 버튼
1import { useState, useEffect } from "react";2import { useParams } from "react-router";3import { Card } from "./Card.jsx";4import api from "../api/axios";5import { Spinner, Container, Button } from "./UI.jsx";6
7const TITLES = {8 now_playing: "현재 상영작",9 popular: "인기 영화",10 top_rated: "최고 평점",11};12
13export function Category() {14 const { type } = useParams();15 const [page, setPage] = useState(1);16 const [totalPages, setTotalPages] = useState(1);17 const [data, setData] = useState({ type: "", page: 0, movies: [] });18
19 useEffect(() => {20 api21 .get(`movie/${type}`, { params: { page: page } })22 .then((res) => {23 let pages = res.data.total_pages;24 if (pages > 20) {25 pages = 20;26 }27 setTotalPages(pages);28 setData({ type: type, page: page, movies: res.data.results.filter((m) => m.poster_path) });29 })30 .catch(() => {31 setData({ type: type, page: page, movies: [] });32 });33 }, [type, page]);34
35 const loading = data.type !== type || data.page !== page;| 줄 | 설명 |
|---|---|
| 7-11 | TITLES 객체로 영어 카테고리 키를 한글 제목으로 변환한다. |
| 14 | useParams()로 URL에서 :type 값(popular, now_playing, top_rated)을 꺼낸다. |
| 15 | page — 현재 페이지 번호이다. “이전”/“다음” 버튼으로 변경한다. |
| 21 | { params: { page: page } }로 TMDB에 페이지 번호를 전달한다. |
| 24-25 | TMDB가 반환하는 total_pages가 너무 크면 20페이지로 제한한다. |
| 33 | [type, page] — type이나 page가 바뀔 때마다 API를 다시 호출한다. |
| 35 | 현재 type+page와 data의 type+page가 다르면 로딩 중이다. |
1 function goPrev() {2 if (page > 1) {3 setPage(page - 1);4 }5 }6
7 function goNext() {8 if (page < totalPages) {9 setPage(page + 1);10 }11 }12
13 const title = TITLES[type] || type;14
15 return (16 <Container className="min-h-screen pt-28 pb-16">17 <h2 className="text-3xl font-bold text-white mb-8">{title}</h2>18
19 {loading && <Spinner />}20
21 {!loading && (22 <>23 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">24 {data.movies.map((el) => (25 <Card key={el.id} item={el} />26 ))}27 </div>28
29 <div className="flex justify-center items-center gap-4 mt-12">30 <Button variant="secondary" onClick={goPrev} disabled={page === 1} className="px-4 py-2 rounded">31 이전32 </Button>33 <span className="text-white">34 {page} / {totalPages}35 </span>36 <Button variant="secondary" onClick={goNext} disabled={page === totalPages} className="px-4 py-2 rounded">37 다음38 </Button>39 </div>40 </>41 )}42 </Container>43 );44}| 줄 | 설명 |
|---|---|
| 1-10 | ”이전”/“다음” 버튼의 핸들러이다. 페이지 범위를 벗어나지 않도록 검사한다. |
| 12 | TITLES[type]으로 한글 제목을 가져온다. 없으면 type 값 그대로 표시한다. |
| 30 | disabled={page === 1} — 1페이지면 “이전” 버튼이 비활성화된다. |
| 36 | disabled={page === totalPages} — 마지막 페이지면 “다음” 버튼이 비활성화된다. |
4. 전체 코드
- ErrorPage.jsx
- Category.jsx
1import { Link } from "react-router";2
3export function ErrorPage() {4 return (5 <div className="bg-black min-h-screen flex flex-col items-center justify-center text-center px-6">6 <h1 className="text-8xl font-bold text-yellow-400">404</h1>7 <p className="text-white text-2xl mt-4">페이지를 찾을 수 없습니다</p>8 <p className="text-gray-400 mt-2">요청하신 페이지가 존재하지 않거나 이동되었습니다.</p>9 <Link10 to="/"11 className="mt-8 bg-yellow-400 text-black px-6 py-3 rounded-lg font-bold hover:bg-yellow-300 transition-colors"12 >13 홈으로 돌아가기14 </Link>15 </div>16 );17}1import { useState, useEffect } from "react";2import { useParams } from "react-router";3import { Card } from "./Card.jsx";4import api from "../api/axios";5import { Spinner, Container, Button } from "./UI.jsx";6
7const TITLES = {8 now_playing: "현재 상영작",9 popular: "인기 영화",10 top_rated: "최고 평점",11};12
13export function Category() {14 const { type } = useParams();15 const [page, setPage] = useState(1);16 const [totalPages, setTotalPages] = useState(1);17 const [data, setData] = useState({ type: "", page: 0, movies: [] });18
19 useEffect(() => {20 api21 .get(`movie/${type}`, { params: { page: page } })22 .then((res) => {23 let pages = res.data.total_pages;24 if (pages > 20) {25 pages = 20;26 }27 setTotalPages(pages);28 setData({ type: type, page: page, movies: res.data.results.filter((m) => m.poster_path) });29 })30 .catch(() => {31 setData({ type: type, page: page, movies: [] });32 });33 }, [type, page]);34
35 const loading = data.type !== type || data.page !== page;36
37 function goPrev() {38 if (page > 1) {39 setPage(page - 1);40 }41 }42
43 function goNext() {44 if (page < totalPages) {45 setPage(page + 1);46 }47 }48
49 const title = TITLES[type] || type;50
51 return (52 <Container className="min-h-screen pt-28 pb-16">53 <h2 className="text-3xl font-bold text-white mb-8">{title}</h2>54
55 {loading && <Spinner />}56
57 {!loading && (58 <>59 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">60 {data.movies.map((el) => (61 <Card key={el.id} item={el} />62 ))}63 </div>64
65 <div className="flex justify-center items-center gap-4 mt-12">66 <Button variant="secondary" onClick={goPrev} disabled={page === 1} className="px-4 py-2 rounded">67 이전68 </Button>69 <span className="text-white">70 {page} / {totalPages}71 </span>72 <Button variant="secondary" onClick={goNext} disabled={page === totalPages} className="px-4 py-2 rounded">73 다음74 </Button>75 </div>76 </>77 )}78 </Container>79 );80}