기록
React Movies 기록 - 4. movies, movie
als982001
2023. 2. 5. 23:21
1. movie.tsx
import { AnimatePresence, motion, useViewportScroll } from "framer-motion";
import { useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import {
getTopRatedMovies,
getUpcomingMovies,
getNowPlayingMovies,
IGetMovieResult,
IMovie,
API_KEY,
getPopularMovies,
} from "../../api";
import { getMovieGenre } from "../../Components/genres";
import { makeImagePath } from "../../utils";
const boxVariants = {
normal: {
scale: 1,
},
hover: {
scale: 1.3,
y: -80,
transition: {
delay: 0.5,
duration: 0.1,
type: "tween",
},
},
};
const infoVariants = {
hover: {
opacity: 1,
transition: {
delay: 0.5,
duration: 0.1,
type: "tween",
},
},
};
const Loader = styled.div`
height: 20vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 30px;
font-weight: bold;
margin-top: 50px;
`;
const Wrapper = styled.div`
position: relative;
top: -100px;
margin-bottom: 100px;
`;
const Display = styled.section`
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 15px;
`;
const StandTitles = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 10px;
position: relative;
`;
const StandTitle = styled.h2`
font-size: 39px;
font-weight: bold;
margin-left: 50px;
margin-bottom: 20px;
margin-right: 80px;
`;
const Overlay = styled(motion.div)`
position: fixed;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
z-index: 5;
`;
const BigMovie = styled(motion.div)`
position: absolute;
width: 50vw;
height: 150vh;
left: 0;
right: 0;
margin: 0 auto;
border-radius: 15px;
border: 1px solid #f8ede3;
overflow: hidden;
background-color: ${(props) => props.theme.black.lighter};
display: flex;
flex-direction: column;
box-shadow: 5px 5px 20px 10px black;
z-index: 10;
`;
const BigCover = styled.div`
width: 100%;
flex: 4 0 0;
background-size: cover;
background-position: center center;
`;
const BigAllInfos = styled.section`
width: 100%;
flex: 6 0 0;
display: flex;
flex-direction: column;
`;
const BigSentenceInfos = styled.section`
width: 100%;
height: 40%;
display: flex;
`;
const BigMainInfos = styled.section`
width: 60%;
height: 100%;
display: flex;
flex-direction: column;
padding-left: 10px;
padding-top: 0px;
justify-content: center;
padding: 0 10px;
`;
const BigOtherInfos = styled.section`
width: 40%;
height: 100%;
display: flex;
flex-direction: column;
align-items: start;
justify-content: center;
padding-left: 20px;
`;
const BigOtherInfo = styled.span`
font-size: 15px;
margin: 1px 0;
`;
const SimilarMovies = styled.section`
width: 100%;
height: 60%;
display: grid;
grid-template-columns: repeat(6, 1fr);
position: relative;
`;
const SimilarTitle = styled.h2`
font-size: 30px;
font-weight: bold;
margin: 0 auto;
`;
const SimilarMovie = styled.section<{ bgphoto: string }>`
border: 1px black solid;
border-radius: 10px;
background-image: url(${(props) => props.bgphoto});
background-size: cover;
background-position: center;
`;
const BigMediaTitle = styled.h1<{ titleLength: number }>`
fontweight: "bold";
font-size: ${(props) => (props.titleLength > 20 ? "25px" : "35px")};
margin-bottom: 5px;
`;
const Box = styled(motion.div)<{ bgphoto: string }>`
background-color: white;
background-image: url(${(props) => props.bgphoto});
background-size: cover;
background-position: center;
height: 200px;
border-radius: 20px;
box-shadow: 3px 3px 2px 1px #d8d8d8, -3px 3px 2px 1px #d8d8d8;
&:first-child,
&:nth-child(7),
&:nth-child(13) {
transform-origin: center left;
}
&:last-child,
&:nth-child(6),
&:nth-child(12) {
transform-origin: center right;
}
cursor: pointer;
`;
const Infos = styled(motion.section)`
background-color: ${(props) => props.theme.black.lighter};
opacity: 0;
position: absolute;
width: 100%;
height: 35%;
bottom: 0;
border-radius: 0 0 20px 20px;
display: flex;
align-items: center;
justify-content: space-around;
`;
const Info = styled.div`
height: 80%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
border-radius: 20%;
font-weight: bold;
span {
font-size: 10px;
}
`;
interface IMovieType {
movieType: string;
page: string;
}
const emptyMovie: IMovie = {
poster_path: "",
adult: false,
overview: "",
release_date: "",
genre_ids: [0],
id: 0,
original_title: "",
original_language: "",
title: "",
backdrop_path: "",
popularity: 0,
vote_average: 0,
video: false,
vote_count: 0,
};
const typeTitle = (type: string): string => {
if (type === "nowPlaying") return "Now Playing";
if (type === "topRated") return "Top Rated";
if (type === "popular") return "Popular Movies";
if (type === "upcoming") return "Upcoming";
return null;
};
const getMovies = (type: string) => {
if (type === "nowPlaying") return getNowPlayingMovies;
if (type === "topRated") return getTopRatedMovies;
if (type === "upcoming") return getUpcomingMovies;
if (type === "popular") return getPopularMovies;
};
export default function Movie({ movieType, page }: IMovieType) {
const history = useHistory();
const { scrollY } = useViewportScroll();
const bigMovieMatch = useRouteMatch<{ movieId: string }>(`/${page}/:movieId`);
const [clickedMovie, setClickedMovie] = useState<IMovie>(emptyMovie);
const [clicked, setClicked] = useState(false);
const [similar, setSimilar] = useState<IGetMovieResult>();
const [similarLoading, setSimilarLoading] = useState(true);
const { data, isLoading } = useQuery<IGetMovieResult>(
movieType,
getMovies(movieType)
);
const onBoxClick = (movie: IMovie) => {
setClickedMovie(movie);
setClicked((prev) => true);
history.push(`/${page}/${movie.id}`);
};
const onOverlayClick = () => {
setSimilarLoading((prev) => true);
setClicked((prev) => false);
history.push(`/${page}`);
};
useEffect(() => {
(async () => {
const response = await fetch(
`https://api.themoviedb.org/3/movie/${clickedMovie.id}/similar?api_key=${API_KEY}`
);
const json = await response.json();
setSimilar(json);
setSimilarLoading((prev) => false);
})();
}, [clickedMovie]);
return (
typeTitle(movieType) && (
<>
<Wrapper>
<StandTitles>
<StandTitle>{typeTitle(movieType)}</StandTitle>
</StandTitles>
{isLoading ? (
<Loader>Loading,,,</Loader>
) : (
<div>
<AnimatePresence>
<Display>
{data.results
.slice(1)
.slice(0, 18)
.map((movie) => (
<Box
layoutId={movie.id + movieType}
key={movie.id}
variants={boxVariants}
initial="normal"
whileHover="hover"
transition={{ type: "tween" }}
bgphoto={makeImagePath(movie.backdrop_path)}
onClick={() => onBoxClick(movie)}
>
<Infos variants={infoVariants}>
<Info
style={{
width: "50%",
fontSize: "15px",
fontWeight: "bold",
}}
>
{movie.title}
</Info>
<Info style={{ width: "30%" }}>
<span>{`🏳️: ${movie.original_language}`}</span>
<span>{`Score: ${movie.popularity}`}</span>
</Info>
</Infos>
</Box>
))}
</Display>
</AnimatePresence>
</div>
)}
</Wrapper>
{bigMovieMatch && clicked ? (
<>
<Overlay onClick={onOverlayClick} />
<BigMovie
style={{ top: scrollY.get() + 20 }}
layoutId={bigMovieMatch.params.movieId + movieType}
>
{clickedMovie ? (
<>
<BigCover
style={{
backgroundImage: `linear-gradient(to top, black, transparent), url(${makeImagePath(
clickedMovie.backdrop_path,
"w500"
)})`,
}}
/>
<BigAllInfos>
<BigSentenceInfos>
<BigMainInfos>
<BigMediaTitle
titleLength={clickedMovie.title.length}
style={{ fontWeight: "bold" }}
>
{clickedMovie.title}
</BigMediaTitle>
<p
style={{
fontSize: "15px",
fontWeight: "300",
}}
>
{clickedMovie.overview.length > 300
? clickedMovie.overview.slice(0, 300) + "..."
: clickedMovie.overview}
</p>
</BigMainInfos>
<BigOtherInfos>
{clickedMovie.genre_ids.map((genreId) => (
<BigOtherInfo key={genreId}>
{`● ${getMovieGenre(genreId)}`}
</BigOtherInfo>
))}
<BigOtherInfo>
{`Release Date: ${clickedMovie.release_date}`}
</BigOtherInfo>
<BigOtherInfo>
{`Popularity: ${clickedMovie.popularity}`}
</BigOtherInfo>
<BigOtherInfo>
{`Vote Average: ${clickedMovie.vote_average}`}
</BigOtherInfo>
</BigOtherInfos>
</BigSentenceInfos>
<SimilarTitle>Similar Movies</SimilarTitle>
<SimilarMovies>
{similarLoading ? (
<p>아직은 보여줄 수 없다.</p>
) : (
<>
{similar.results
? similar.results
.slice(0, 6)
.map((s) => (
<SimilarMovie
key={s.id}
bgphoto={makeImagePath(s.backdrop_path)}
/>
))
: null}
</>
)}
</SimilarMovies>
</BigAllInfos>
</>
) : null}
</BigMovie>
</>
) : null}
</>
)
);
}
카테고리에 따른 영화들을 보여주는 컴포넌트이다. 위의 코드에서는 카테고리를 type이라 표현하였다. 유효한 카테고리는 4개로 now playing, top rated, popular, upcoming이다. 총 18개의 영화를 3x6 grid 형식으로 보여주며 각 이미지에 마우스를 올려두고 있으면 이미지가 조금 커지면서 간략한 정보를 보여준다. 그리고 이미지를 클릭할 경우는 큰 창으로 해당 영화에 대한 정보를 보여준다. 이 때. 큰 창의 경우는 이미지를 클릭했는지 안했는지에 따라 null과 <BigMovie>를 보여준다. 그리고 layoutId를 이용하여 클릭한 이미지가 커지는 애니메이션을 적용하였다.
코드를 작성하면서 당황했던 순간이 있었다. 예를 들어, now playing 카테고리에도 장화신은 고양이 2가 있고 popular에도 이 영화가 있을 때, now playing의 장화신은 고양이를 클릭하고 다시 큰 정보를 없앤 경우, now playing의 장화신은 고양이가 사라지는 현상이 생겼다. 그래서 이를 방지하기 위해 layoutId는 영화의 아이디 + 카테고리로 설정하였다.
- typeTitle: type에 따른 영화 카테고리(타입) 문자열을 반환한다. 예를 들어, top rated movie는 movieType이라는 이름의 변수의 값이 "topRated"일 때 보여주려고 한다. 그런데 영화들의 타이틀로 "topRated"라고 그대로 쓸 수 없기 때문에 이 함수를 이용해 바꿔준다.
- getMovies: type에 따른 영화를 fetch하는 함수를 반환한다. 처음에는 삼항 연산 연산자를 줄줄이 엮었는데, 이것이 보기 좋지 않아 새로 함수를 만들어 사용하였다.
- bigMovieMatch: 만약 영화 이미지를 클릭한다면 그 영화의 정보를 더 큰 화면으로 보여주려 한다. 이 때, 어떤 영화의 이미지가 클릭되었는지 확인하기 위해 bigMovieMatch를 이용하였다. 만약 영화 이미지가 클릭되면 그 영화의 아이디가 uri에 적힐 것이고 이를 이용해 클릭되었는지 아닌지를 판단한다.
- useEffect(() => ... ), [clickedMovie]); : 영화가 클릭되었을 경우, 그 클릭된 영화와 비슷한 영화들의 데이터를 불러오기 위해 useEffect를 이용하였다.
2. Movies.tsx
import { useQuery } from "react-query";
import styled from "styled-components";
import { getNowPlayingMovies, IGetMovieResult } from "../../api";
import { makeImagePath } from "../../utils";
import Movie from "./movie";
const Wrapper = styled.div`
background: black;
padding-bottom: 200px;
`;
const Overview = styled.p`
font-size: 30px;
width: 50%;
`;
const Title = styled.h2`
font-size: 68px;
font-weight: bold;
margin-bottom: 20px;
`;
const Loader = styled.div`
height: 20vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 30px;
font-weight: bold;
margin-top: 50px;
`;
const Banner = styled.div<{ bgphoto: string }>`
height: 80vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 1)),
url(${(props) => props.bgphoto});
background-size: cover;
`;
export default function Movies() {
const { data: nowPlaying, isLoading: nowPlayingLoading } =
useQuery<IGetMovieResult>(["movies", "nowPlaying"], getNowPlayingMovies);
return (
<Wrapper>
{nowPlayingLoading ? (
<Loader>Loading...</Loader>
) : (
<>
<Banner
bgphoto={makeImagePath(nowPlaying.results[0].backdrop_path || "")}
>
<Title>{nowPlaying.results[0].title}</Title>
<Overview>{nowPlaying.results[0].overview}</Overview>
</Banner>
<>
<Movie page={"movies"} movieType={"nowPlaying"} />
<Movie page={"movies"} movieType={"upcoming"} />
<Movie page={"movies"} movieType={"popular"} />
<Movie page={"movies"} movieType={"topRated"} />
</>
</>
)}
</Wrapper>
);
}
우선, 가장 윗 부분은 현재 상영 중인 영화 중 하나의 포스터 이미지, 제목, 줄거리를 크게 보여주었다. 그리고 그 밑에는 각 카테고리에 따른 영화 정보를 보여준다.
- <Movie page={"movies"} movieType={"nowPlaying"} />: 카테고리에 따른 영화들을 보여준다.
- const { data: nowPlaying, isLoading: nowPlayingLoading } = ~ : 가장 윗 부분에 현재 상영 중인 영화를 하나 표시해야 하는데, 이를 위한 부분이다.