현재 부트캠프에서 영화 추천 사이트를 만드는 프로젝트를 진행 중입니다.
총 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 컴포넌트 호출
- 받아온 데이터가 담겨있는 movieListDatas를 map으로 순회하여 각각의 항목에 접근한다.
- movieListData를 props로 내려주어 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에서 title과 vote_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 폴더에 있는 movieDetailData를 import하여 사용합니다.
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_path와 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>
)
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 구성하기
- spaceBetween에 10을 주어, 슬라이드 간 간격을 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 컴포넌트 내에 렌더링한다.
- 각 SwiperSlide는 SliderCard 컴포넌트를 포함하여, 영화 제목과 평점 등을 SliderCard에서 렌더링 하도록 한다.
(SliderCard는 MovieCard와 똑같이 생겼는데 style만 다릅니다.)
모바일 화면을 위해 width가 500px 이상일 경우에만 p-3을 적용하도록 만들어줘야 했는데요.
tailwindCSS는 640px인 sm이 마지막이어서, 직접 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 Movie에 navigate를 연결하여 메인 페이지로 이동할 수 있도록 한다.
- 로그인 버튼과 회원가입 버튼을 만든다.
- 기타 스타일을 적용한다.
마지막으로 반응형 웹!
정말... 여기서 머리가 제일 아팠습니다. ㅠㅠ
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를 사용하여 좀 더 편리하게 작성하였습니다.
모바일 모드에서는 슬라이드와 디테일 페이지, 그리고 메인 페이지도 구성이 바뀌는 게 좋을 것 같아서
width가 500px보다 작을 때를 기준으로 미디어 쿼리를 작성했습니다.
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;
}
}
- width를 200px에서 130px로 축소
- height 역시 380px에서 250px로 축소
- gap도 10px에서 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;
}
}
- width와 height를 설정하여 한 화면에 하나의 슬라이드만 뜨도록 설정
- 이미지, 폰트 사이즈 및 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-direction을 column으로 변경 후 사이즈 변경
이렇게 하면 끝~~!!!
내일은 2번째 미션으로 돌아올게용
https://github.com/xuuwon/React-moviepage
'개발 > React&Redux' 카테고리의 다른 글
[React] 영화 추천 사이트 제작하기 (마무리) (2) | 2024.11.13 |
---|---|
[React] 영화 추천 사이트 제작하기 (3) (8) | 2024.11.10 |
[React] 영화 추천 사이트 제작하기 (2) (2) | 2024.11.08 |
[React] React로 Todo List 만들기 (23) | 2024.11.06 |
[React] React Router를 활용한 동물 소개 페이지 만들기 (0) | 2024.10.16 |