기록

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}
      </>
    )
  );
}

movie
마우스를 잠시 올려두고 있을 때

 

이미지 클릭 시

 카테고리에 따른 영화들을 보여주는 컴포넌트이다. 위의 코드에서는 카테고리를 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 } = ~ : 가장 윗 부분에 현재 상영 중인 영화를 하나 표시해야 하는데, 이를 위한 부분이다.