개발/React&Redux

[React] 영화 추천 사이트 제작하기 (1)

xuwon 2024. 11. 7. 03:17

현재 부트캠프에서 영화 추천 사이트를 만드는 프로젝트를 진행 중입니다.
총 5개의 미션으로 진행되어, 미션 1개씩 완료할 때마다 블로그를 작성하여 정리하려구 합니다!
(제발 당일에 쓰자...!!)

우선 첫번째 미션 결과물입니다.

1.5배를 돌렸더니 좀 정신없네요...

우선 1단계 미션은

메인 페이지 및 영화 상세 페이지 레이아웃 구성

입니다!!

 

따라서 구현한 기능은

1. 더미데이터를 사용한 메인 페이지 (App.jsx) 레이아웃 구성
2. 더미데이터를 사용한 상세 페이지 (MovieDetail.jsx) 레이아웃 구성
3. React-Router-Dom을 사용하여 라우팅 구성
4. Swiper를 사용하여 슬라이드 구현
5. (+) Header 만들기
6. (+) 반응형 웹으로 만들기

입니다!!

 


우선 메인 페이지인 App.jsx부터 보겠습니다!!

1. 더미데이터를 사용한 메인 페이지 (App.jsx) 레이아웃 구성

// App.jsx

import { useEffect, useState } from 'react'
import './App.scss'
import MovieCard from './components/MovieCard';
import './index.css';
import MovieSlider from './components/MovieSlider';

function App() {
  const [movieListDatas, setMovieListDatas] = useState([]);

  useEffect(() => {
    fetch('http://localhost:3000/results')
      .then(res => res.json())
      .then(res => setMovieListDatas(res))
  }, [])


  return (
    <div>
        <div className='main'>
          <MovieSlider movieListDatas={movieListDatas} />
          <div className='cards' style={{
            display: 'flex',
            flexWrap: 'wrap',
            gap: '20px',
            justifyContent: 'space-around',
            padding: '40px'
          }}>
            {movieListDatas.map((movieListData) => (
              <MovieCard key={movieListData.id} movieListData={movieListData} />
            ))}
          </div>
        </div>
    </div>
  );
}

export default App

우선 더미데이터는 data 폴더 안에 movieListData.json에 있습니다.
그래서 json-server를 사용해서 열어주었고, fetch로 데이터를 받아왔어요.
그리고 이 데이터는 상태로 관리합니다!

 

const [movieListDatas, setMovieListDatas] = useState([]);

useEffect(() => {
  fetch('http://localhost:3000/results')
    .then(res => res.json())
    .then(res => setMovieListDatas(res))
}, [])

1. useEffect 사용

- fetch로 서버에서 데이터를 받아와서 res에 저장 후 movieListDatas를 업데이트 한다.
- 빈 배열을 의존성 배열로 주어 처음 렌더링 시에만 데이터를 받아오도록 한다.

return (
    <div>
        <div className='main'>
          <MovieSlider movieListDatas={movieListDatas} />
          <div className='cards' style={{
            display: 'flex',
            flexWrap: 'wrap',
            gap: '20px',
            justifyContent: 'space-around',
            padding: '40px'
          }}>
            {movieListDatas.map((movieListData) => (
              <MovieCard key={movieListData.id} movieListData={movieListData} />
            ))}
          </div>
        </div>
    </div>
);

MovieSlider 컴포넌트는 추후에!
swiper로 슬라이드를 구현한 컴포넌트입니다.

2. MovieCard 컴포넌트 호출

- 받아온 데이터가 담겨있는 movieListDatasmap으로 순회하여 각각의 항목에 접근한다.
- movieListDataprops로 내려주어 MovieCard 컴포넌트에서 이 데이터를 활용해 각 영화의 UI를 개별적으로 구성한다.

 


이제 MovieCard 컴포넌트를 봐야겠죠!

import { useNavigate } from "react-router-dom";

export default function MovieCard ({ movieListData }) {
    const posterUrl = `https://image.tmdb.org/t/p/w500${movieListData.poster_path}`;
    const navigate = useNavigate();

    const handleClick = () => {
        // /details 페이지로 이동
        navigate('/details');
    };

    return (
        <div className="movieCard hover:scale-105 ease-in duration-100 rounded-2xl" onClick={handleClick}>
            <img src={posterUrl} className="w-full h-[270px] rounded-t-2xl"/>
            <p className="pl-[7px] pr-[7px]">{movieListData.title}</p>
            <p className="pl-[7px]">평점: {movieListData.vote_average}</p>
        </div>
    )
}

1. 사진 url 상수로 할당하기

- 데이터에 포함된 poster_path의 경우, 상대경로라서 접근이 안되었다.
-  const posterUrl = `https://image.tmdb.org/t/p/w500${movieListData.poster_path}`;  
- 따라서 이렇게 절대 경로로 바꿔서 posterUrl에 할당해 주었다.

 

const navigate = useNavigate();

const handleClick = () => {
    // /details 페이지로 이동
    navigate('/details');
};

2. useNavigate 훅을 활용하여 페이지 이동 기능 구현하기

- handleClick 함수는 /details 페이지로 이동하는 이벤트 핸들러로 설정한다.
- navigate('/details')를 호출하여 /details 경로로 이동.
- 디테일 페이지에는 /detail 경로가 설정되어 있다. (라우팅)

 

return (
    <div className="movieCard hover:scale-105 ease-in duration-100 rounded-2xl" 
    	onClick={handleClick}
    >
        <img src={posterUrl} className="w-full h-[270px] rounded-t-2xl"/>
        <p className="pl-[7px] pr-[7px]">{movieListData.title}</p>
        <p className="pl-[7px]">평점: {movieListData.vote_average}</p>
    </div>
)

3. 각 영화 Card UI 만들기

- props로 받아온 movieListData에서 titlevote_average 값을 받아서 렌더링.
- 아까 할당한 posterUrl로 이미지 렌더링.
- click 이벤트에 아까 만들어 놓은 이벤트 핸들러를 등록한다.

 


이렇게 메인 페이지는 구현이 완료되었고, 다음은 상세 페이지입니다.

 

2. 더미데이터를 사용한 상세 페이지 (MovieDetail.jsx) 레이아웃 구성

 

import { useEffect, useState } from 'react';
import movieDetailData from '../../data/movieDetailData.json';
import '../index.css';

export default function MovieDetail() {
    const [movieDetailDatas] = useState(movieDetailData);
    const genres = movieDetailData.genres.map((genre) => {
        return genre.name
    })
    const posterUrl = `https://image.tmdb.org/t/p/w500${movieDetailData.poster_path}`;
    const backdropUrl = `https://image.tmdb.org/t/p/w500${movieDetailData.backdrop_path}`;

    return (
        <div className="w-[100vw] bg-[#e1e1e1] flex justify-center pb-[50px]">
            <div className="w-[85%] flex flex-col lg:flex-row items-center gap-[50px] mt-[50px]">
                <img src={posterUrl} className='w-[400px]' />
                <div className="flex flex-col gap-[50px]">
                    <div className="flex gap-[30px] items-center">
                        <p className="text-[30px] font-black">{movieDetailData.title}</p>
                        <p>평점: {movieDetailData.vote_average}</p>
                    </div>
                    <div>
                        장르:
                        {genres.map((genre, index) => {
                            return (
                                <span key={index}>
                                    {index == 0 && " "}
                                    {genre}
                                    {index < genres.length - 1 && ", "}
                                </span>
                            )
                        })}
                    </div>
                    <p className="leading-[33px]">줄거리: {movieDetailData.overview}</p>
                </div>
            </div>
        </div>
    )
}

이번에는 서버가 아니라 data 폴더에 있는 movieDetailDataimport하여 사용합니다.


const [movieDetailDatas] = useState(movieDetailData);
const genres = movieDetailData.genres.map((genre) => {
    return genre.name
})
const posterUrl = `https://image.tmdb.org/t/p/w500${movieDetailData.poster_path}`;
const backdropUrl = `https://image.tmdb.org/t/p/w500${movieDetailData.backdrop_path}`;

1. 데이터 받아오기

- movieDetailData.json 파일에서 데이터를 받아와 movieDetailDatas에 저장한다.
- 이 데이터의 genres는 여러 객체를 담은 배열이므로 map으로 순회하여 각 요소의 name 값만 꺼내 genres에 할당한다.
- poster_pathbackdrop_path를 절대 경로로 바꾸어 상수에 할당한다.

 

return (
        <div className="w-[100vw] bg-[#e1e1e1] flex justify-center pb-[50px]">
            <div className="w-[85%] flex flex-col lg:flex-row items-center gap-[50px] mt-[50px]">
                <img src={posterUrl} className='w-[400px]' />
                <div className="flex flex-col gap-[50px]">
                    <div className="flex gap-[30px] items-center">
                        <p className="text-[30px] font-black">{movieDetailData.title}</p>
                        <p>평점: {movieDetailData.vote_average}</p>
                    </div>
                    <div>
                        장르:
                        {genres.map((genre, index) => {
                            return (
                                <span key={index}>
                                    {index == 0 && " "}
                                    {genre}
                                    {index < genres.length - 1 && ", "}
                                </span>
                            )
                        })}
                    </div>
                    <p className="leading-[33px]">줄거리: {movieDetailData.overview}</p>
                </div>
            </div>
        </div>
)

2. UI 구성하기

- 포스터 이미지와 제목, 평점, 장르, 줄거리 데이터를 렌더링 한다.
- genres의 경우 배열이므로, map으로 순회하여 각 데이터에 접근한다.
- 장르에는 쉼표를 추가하기 위해 index를 활용해서 마지막 요소를 뺀 나머지 요소 뒤에 쉼표를 붙여준다.

 


이렇게 디테일 페이지도 완성이 되었습니다.

다음은 라우팅 구성을 해보겠습니다!

 

3. React-Router-Dom을 사용하여 라우팅 구성

 

// MainRouter.jsx

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import App from './App';               // App은 홈 페이지로 사용
import MovieDetail from './components/MovieDetail';

function MainRouter() {
    return (
        <Routes>
            <Route path="/" element={<App />} />               {/* 홈 페이지 */}
            <Route path="/details" element={<MovieDetail />} /> {/* 상세 페이지 */}
        </Routes>
    );
}

export default MainRouter;

 

App.jsx에서 라우팅을 설정할 경우, App 컴포넌트를 element로 넣어줬을 때 에러가 발생하여
따로 MainRouter.jsx 파일을 만들어서 설정해 주었습니다.


Why Error?

- App 컴포넌트가 이미 Router로 감싸져 있다면, 다시 App.jsx에서 Router를 설정할 경우 중첩 문제가 발생할 수 있다.

 

1. Routes와 Route를 사용하여 라우팅 설정

- "/" 경로에는 App 컴포넌트를 연결하고, "/details" 경로에는 MovieDetail 컴포넌트를 연결한다.

 

// main.jsx

import { createRoot } from 'react-dom/client'
import './index.css'
import { BrowserRouter } from 'react-router-dom'
import MainRouter from './MainRouter.jsx'
import Header from './components/Header.jsx'

createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <Header />
    <MainRouter />
  </BrowserRouter>,
)

2. main.jsx에서 BrowserRouter 사용하기

- BrowserRouter로 감싸서 MainRouter 컴포넌트를 호출하여 라우팅 설정을 마무리한다.

 

Header 컴포넌트의 경우 추후 설명하겠습니다!

 


라우팅 설정도 마무리 되었으니, 이제 슬라이드를 구현해봅시다.

 

4. Swiper를 사용하여 슬라이드 구현

import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/swiper-bundle.min.css'; // Swiper 6.x에서 필요한 CSS 파일
import SwiperCore, { Navigation } from 'swiper';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import '../App.scss'
import SliderCard from './SliderCard';

// Swiper에 Navigation 모듈을 추가합니다
SwiperCore.use([Navigation]);

export default function MovieSlider({ movieListDatas }) {
  const navigate = useNavigate();

  const handleClick = () => {
    // /details 페이지로 이동
    navigate('/details');
  };

  return (
    <div className="
      w-[300px] sm:w-[600px] md:w-[700px] lg:w-[900px] xl:w-[1100px] 2xl:w-[1500px] h-[450px] 
      mx-auto mt-[30px] pt-[10px]
      border-b border-[rgb(224, 224, 224)]"
    >
      <div>
        <Swiper
          spaceBetween={10}
          breakpoints={{
            0: {
              slidesPerView: 1
            },
            // 크기가 sm 이상일 때
            640: {
              slidesPerView: 2,
            },
            // 크기가 md 이상일 때
            768: {
              slidesPerView: 3,
            },
            // 크기가 lg 이상일 때
            1024: {
              slidesPerView: 4,
            },
            // 크기가 xl 이상일 때
            1280: {
              slidesPerView: 5,
            },
            // 크기가 2xl 이상일 때
            1536: {
              slidesPerView: 6,
            }
          }}
          navigation // 네비게이션 버튼 활성화
          className='w-full h-full overflow-visible'
        >
          {movieListDatas.map((movieListData) => {
            return (
              <SwiperSlide key={movieListData.id} className='hover:drop-shadow-xl xs:p-3'>
                <SliderCard movieListData={movieListData} />
              </SwiperSlide>
            )
          })}
        </Swiper>
      </div>
    </div>
  );
}

아무리 Swiper 라이브러리의 최신 버전을 다운로드 해도 모듈을 불러오지 못한다기에, 6 버전으로 다운그레이드 했습니다.

 

1. useNavigete 훅 사용하기

- useNavigate 훅을 사용하여 디테일 페이지로의 이동 기능을 구현한다.

2. Swiper 구성하기

- spaceBetween10을 주어, 슬라이드 간 간격을 10px로 설정한다.
- breakpoints 옵션을 통해 화면 크기에 따라 슬라이드 수를 조정한다. (슬라이드 수 = slidesPerView)
- navigation 버튼을 활성화하여 사용자가 슬라이드를 이전/다음으로 이동할 수 있도록 한다.

 

{movieListDatas.map((movieListData) => {
   return (
      <SwiperSlide key={movieListData.id} className='hover:drop-shadow-xl xs:p-3'>
          <MovieCard movieListData={movieListData}/>
      </SwiperSlide>
   )
})}

 

3. 슬라이드 항목 생성하기

- movieListDatas 배열을 App.jsx에서 props로 받아와 map으로 순회하며, 각 영화 데이터를 SwiperSlide 컴포넌트 내에 렌더링한다.
- 각 SwiperSlideSliderCard 컴포넌트를 포함하여, 영화 제목과 평점 등을 SliderCard에서 렌더링 하도록 한다.
(SliderCardMovieCard와 똑같이 생겼는데 style만 다릅니다.)



모바일 화면을 위해 width500px 이상일 경우에만 p-3을 적용하도록 만들어줘야 했는데요.
tailwindCSS640pxsm이 마지막이어서, 직접 xs를 만들었습니다.

// tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}", 
    // src 폴더에 있는 모든 JS, JSX, TS, TSX 파일에 Tailwind CSS가 적용되도록 설정
  ],
  theme: {
    extend: {
      screens: {
        xs: '500px', // 500px 이상일 때 xs 클래스를 사용 가능
      },
    },
  },
  plugins: [],
}

 


슬라이드 구현 완료!
이제 Header 컴포넌트를 보겠습니다.

 

 

5. (+) Header 만들기

import { useNavigate } from "react-router-dom";
import '../App.scss'

export default function Header () {
    const navigate = useNavigate();

    const handleClick = () => {
        // /details 페이지로 이동
        navigate('/');
    };

    return (
        <div className="w-full h-[100px] border bg-black flex items-center text-white justify-between px-9">
            <p className="text-[35px] ozMovieText" onClick={handleClick}>OZ Movie</p>
            <div className="flex flex-row gap-6 headerBtns">
                <button className="bg-[purple] w-[80px] h-[35px] rounded-md">로그인</button>
                <button className="bg-[purple] w-[80px] h-[35px] rounded-md">회원가입</button>
            </div>
        </div>
    )
}

헤더는 별 거 없습니다.

- OZ Movienavigate를 연결하여 메인 페이지로 이동할 수 있도록 한다.
- 로그인 버튼과 회원가입 버튼을 만든다.
- 기타 스타일을 적용한다.

 


마지막으로 반응형 웹!
정말... 여기서 머리가 제일 아팠습니다. ㅠㅠ

 

6. (+) 반응형 웹으로 만들기
.movieCard {
  width: 200px;
  height: 380px;
  border: 1px solid rgb(214, 214, 214);
  display: flex;
  flex-direction: column;
  justify-content: start;
  gap: 10px;

  @media (max-width: 500px) {
    width: 130px;
    height: 250px;
    gap: 5px;
    padding-bottom: 2px;

    img {
      height: 180px !important;
    }
  
    p {
      font-size: 11px; /* 모바일에서는 글씨 크기를 줄임 */
      padding-left: 8px !important;
    }
  }
}

.slideCard {
  img {
    width: 200px;
    height: 270px;
  }

  @media (max-width: 500px) {
    width: 300px;
    height: 380px;

    img {
      width: 300px;
      height: 330px;
    }

    p {
      font-size: 12px; /* 모바일에서는 글씨 크기를 줄임 */
      padding-left: 10px;
      padding-top: 4px;
    }
  }
}

.ozMovieText {
  @media (max-width: 500px) {
    font-size: 20px !important;
  }

  font-family: 'CWDangamAsac-Bold', sans-serif;
  font-weight: 500;
}

.headerBtns {
  @media (max-width: 500px) {
    gap: 7px !important;
    flex-direction: column;

    button {
      width: 60px;
      height: 25px;
      font-size: 12px;
    }
  }
}

SCSS를 사용하여 좀 더 편리하게 작성하였습니다.

모바일 모드에서는 슬라이드와 디테일 페이지, 그리고 메인 페이지도 구성이 바뀌는 게 좋을 것 같아서
width500px보다 작을 때를 기준으로 미디어 쿼리를 작성했습니다.

 

1. movieCard 미디어 쿼리

/* movieCard */ 

@media (max-width: 500px) {
    width: 130px;
    height: 250px;
    gap: 5px;
    padding-bottom: 2px;

    img {
      height: 180px !important;
    }
  
    p {
      font-size: 11px; /* 모바일에서는 글씨 크기를 줄임 */
      padding-left: 8px !important;
    }
}

- width200px에서 130px로 축소
- height 역시 380px에서 250px로 축소
- gap10px에서 5px로 축소
- padding-bottom 추가 (미관상)

- 이미지 높이 180px로 축소 
- p 태그의 폰트 사이즈, padding-left 수정
(!important를 적용하여 tailwindCSS보다 우선 적용되도록)

 

2. slideCard 미디어 쿼리

/* slideCard */

@media (max-width: 500px) {
    width: 300px !important;
    height: 380px !important;

    img {
      width: 300px !important;
      height: 330px !important;
    }

    p {
      font-size: 12px; /* 모바일에서는 글씨 크기를 줄임 */
      padding-left: 10px;
      padding-top: 4px;
    }
}

- widthheight를 설정하여 한 화면에 하나의 슬라이드만 뜨도록 설정
- 이미지, 폰트 사이즈 및 padding 조정

 

3. 헤더의 텍스트, 버튼 미디어쿼리

.ozMovieText {
  @media (max-width: 500px) {
    font-size: 20px !important;
  }

  font-family: 'CWDangamAsac-Bold', sans-serif;
  font-weight: 500;
}

.headerBtns {
  @media (max-width: 500px) {
    gap: 7px !important;
    flex-direction: column;

    button {
      width: 60px;
      height: 25px;
      font-size: 12px;
    }
  }
}

- ozMovieText 폰트 사이즈 조정
- 버튼의 경우 flex-directioncolumn으로 변경 후 사이즈 변경

 


이렇게 하면 끝~~!!!
내일은 2번째 미션으로 돌아올게용

https://github.com/xuuwon/React-moviepage

 

GitHub - xuuwon/React-moviepage

Contribute to xuuwon/React-moviepage development by creating an account on GitHub.

github.com