Netplix Clone

제동현·2023년 2월 23일
0
  1. Header
  2. Home Screen
  3. Slider
  4. Box Animation
  5. Mordal
  6. Search

순으로 만들어 보았다.

import styled from "styled-components";
import {
  motion,
  useAnimation,
  useMotionValueEvent,
  useScroll,
} from "framer-motion";
import { Link, useRouteMatch } from "react-router-dom";
import { useEffect, useState } from "react";

const Nav = styled(motion.nav)`
  display: flex;
  justify-content: space-between;
  align-items: center;
  position: fixed;
  width: 100%;
  top: 0;
  font-size: 14px;
  padding: 20px 60px;
  color: white;
`;

const Col = styled.div`
  display: flex;
  align-items: center;
`;

const Logo = styled(motion.svg)`
  margin-right: 50px;
  width: 95px;
  height: 25px;
  fill: ${(props) => props.theme.red};
  path {
    stroke-width: 6px;
    stroke: white;
  }
`;

const Items = styled.ul`
  display: flex;
  align-items: center;
`;

const Item = styled.li`
  margin-right: 20px;
  color: ${(props) => props.theme.white.darker};
  transition: color 0.3s ease-in-out;
  position: relative;
  display: flex;
  justify-content: center;
  flex-direction: column;
  &:hover {
    color: ${(props) => props.theme.white.lighter};
  }
`;

const Search = styled.span`
  display: flex;
  position: relative;
  align-items: center;
  color: white;
  svg {
    height: 25px;
  }
`;

const Circle = styled(motion.span)`
  position: absolute;
  width: 5px;
  height: 5px;
  border-radius: 5px;
  bottom: -5px;
  left: 0;
  right: 0;
  margin: 0 auto;
  background-color: ${(props) => props.theme.red};
`;

const Input = styled(motion.input)`
  transform-origin: right center;
  position: absolute;
  left: -180px;
  padding: 5px 10px;
  padding-left: 10px;
  z-index: -1;
  color: white;
  font-size: 16px;
  background-color: transparent;
  border: 1px solid ${(props) => props.theme.white.lighter};
`;

const logoVariants = {
  normal: {
    fillOpacity: 1,
  },
  active: {
    fillOpacity: [0, 1, 0],
    transition: {
      repeat: Infinity,
    },
  },
};

const navVariants = {
  top: {
    backgroundColor: "rgba(0,0,0,0)",
  },
  scroll: {
    backgroundColor: "rgba(0,0,0,1)",
  },
};

function Header() {
  const [searchOpen, setSearchOpen] = useState(false);
  const homeMatch = useRouteMatch("/");
  const tvMatch = useRouteMatch("/tv");
  const inputAnimation = useAnimation();
  const navAnimation = useAnimation();
  const { scrollY } = useScroll();
  const openSearch = () => {
    if (searchOpen) {
      inputAnimation.start({ scaleX: 0 });
      //trigger the close animaton
    } else {
      inputAnimation.start({ scaleX: 1 });
      //trigger the open animaton
    }
    setSearchOpen((prev) => !prev);
  };
  useMotionValueEvent(scrollY, "change", (latest) => {
    if (scrollY.get() > 80) {
      navAnimation.start("scroll");
    } else {
      navAnimation.start("top");
    }
  });
  return (
    <Nav variants={navVariants} animate={navAnimation} initial={"top"}>
      <Col>
        <Logo
          variants={logoVariants}
          whileHover="active"
          initial="normal"
          xmlns="http://www.w3.org/2000/svg"
          width="1024"
          height="276.742"
          viewBox="0 0 1024 276.742"
        >
          <motion.path d="M140.803 258.904c-15.404 2.705-31.079 3.516-47.294 5.676l-49.458-144.856v151.073c-15.404 1.621-29.457 3.783-44.051 5.945v-276.742h41.08l56.212 157.021v-157.021h43.511v258.904zm85.131-157.558c16.757 0 42.431-.811 57.835-.811v43.24c-19.189 0-41.619 0-57.835.811v64.322c25.405-1.621 50.809-3.785 76.482-4.596v41.617l-119.724 9.461v-255.39h119.724v43.241h-76.482v58.105zm237.284-58.104h-44.862v198.908c-14.594 0-29.188 0-43.239.539v-199.447h-44.862v-43.242h132.965l-.002 43.242zm70.266 55.132h59.187v43.24h-59.187v98.104h-42.433v-239.718h120.808v43.241h-78.375v55.133zm148.641 103.507c24.594.539 49.456 2.434 73.51 3.783v42.701c-38.646-2.434-77.293-4.863-116.75-5.676v-242.689h43.24v201.881zm109.994 49.457c13.783.812 28.377 1.623 42.43 3.242v-254.58h-42.43v251.338zm231.881-251.338l-54.863 131.615 54.863 145.127c-16.217-2.162-32.432-5.135-48.648-7.838l-31.078-79.994-31.617 73.51c-15.678-2.705-30.812-3.516-46.484-5.678l55.672-126.75-50.269-129.992h46.482l28.377 72.699 30.27-72.699h47.295z" />
        </Logo>
        <Items>
          <Item>
            <Link to="/">
              Home {homeMatch?.isExact && <Circle layoutId="circle" />}
            </Link>
          </Item>
          <Item>
            <Link to="/tv">
              Tv Shows{tvMatch && <Circle layoutId="circle" />}
            </Link>
          </Item>
        </Items>
      </Col>
      <Col>
        <Search>
          <motion.svg
            onClick={openSearch}
            animate={{ x: searchOpen ? -210 : 0 }}
            transition={{ type: "linear" }}
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              fillRule="evenodd"
              d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
              clipRule="evenodd"
            ></path>
          </motion.svg>
          <Input
            animate={inputAnimation}
            initial={{ scaleX: 0 }}
            transition={{ type: "linear" }}
            placeholder="Search for movie or tv show..."
          />
        </Search>
      </Col>
    </Nav>
  );
}

export default Header;

useRouteMatch를 이용하여 movie와 tv탭을 라우팅했으며 각탭을 눌렀을때 선택한 탭아래쪽에 동그라미가 자연스럽게 좌우로 이동되게 애니메이션 효과를 넣었다.

useAnimation을 사용하여 검색아이콘 클릭시 검색바가 생기며 아이콘이 왼쪽으로 밀리는것 처럼 표현했다.

useScroll은 일정 픽셀이상 y축에서 이동하면 상단 네비게이션바가 투명/검은색으로 색이 바뀌는 효과를 주었다.

Logo는 마우스를 호버했을때 반짝이게 표시처리했고.

여기까지가 넷플릭스 상단의 헤더 부분이다.

HomeScreen

import { useQuery } from "react-query";
import styled from "styled-components";
import {
  getMovies,
  getTopMovies,
  getUpcomingMovies,
  IGetMovieResult,
} from "../api";
import { makeImagePath } from "./utils";
import Slider from "../Components/Slider";

export const Wrapper = styled.div`
  background: black;
  padding-bottom: 200px;
`;

export const Loader = styled.div`
  height: 20vh;
  display: flex;
  justify-content: center;
  align-items: center;
  color: black;
`;

export const Banner = styled.div<{ bgPhoto: string }>`
  height: 100vh;
  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 const Title = styled.h2`
  font-size: 68px;
  margin-bottom: 20px; ;
`;

export const Overview = styled.p`
  font-size: 30px;
  width: 50%;
`;

export const SlideContainer = styled.div`
  height: 270px;
`;

export const SliderTitle = styled.h2`
  font-size: 2rem;
  font-weight: 600;
  padding-left: 1rem;
  margin-bottom: 1rem;
`;

function Home() {
  const { data: nowPlaying, isLoading: nowPlayingLoading } =
    useQuery<IGetMovieResult>(["movies", "nowPlaying"], getMovies);
  const { data: topMovie, isLoading: topMovieLoading } =
    useQuery<IGetMovieResult>(["movies", "topMovie"], getTopMovies);
  const { data: upcomingMovie, isLoading: upcomingMovieLoading } =
    useQuery<IGetMovieResult>(["movies", "upcomingMovie"], getUpcomingMovies);
  return (
    <Wrapper>
      {nowPlayingLoading && topMovieLoading && upcomingMovieLoading ? (
        <Loader>Loading...</Loader>
      ) : (
        <>
          <Banner
            bgPhoto={makeImagePath(nowPlaying?.results[0].backdrop_path || "")}
          >
            <Title>{nowPlaying?.results[0].title}</Title>
            <Overview>{nowPlaying?.results[0].overview}</Overview>
          </Banner>
          <SlideContainer>
            <SliderTitle>Latest Movies</SliderTitle>
            <Slider
              data={nowPlaying}
              type="nowPlaying"
              category="movie"
              url="movie"
            />
          </SlideContainer>
          <SlideContainer>
            <SliderTitle>Upcoming Movies</SliderTitle>
            <Slider
              data={upcomingMovie}
              type="upcomingMovie"
              category="movie"
              url="movie"
            />
          </SlideContainer>
          <SlideContainer>
            <SliderTitle>Top Rated Movies</SliderTitle>
            <Slider
              data={topMovie}
              type="topMovie"
              category="movie"
              url="movie"
            />
          </SlideContainer>
        </>
      )}
    </Wrapper>
  );
}
export default Home;

다음은 홈스크린 부분으로 넷플릭스에 처음들어가면 가장 크게 뜨는 화면을 구현했다.

Slider

슬라이더 부분은 api에서 fetch를 받고 useQuery를 이용하여 구현했다.

슬라이더는 총 6개만 뜨게 구현했으며 맨 왼쪽과 오른쪽 영화 섬네일을 누를경우 사진 좌우가 짤리지 않게 화면중앙쪽으로 팝업되게 했다.

슬라이드 우측에 있는 화살표로 슬라이더 이동이 가능하게 했다.

Mordal , BoxAnimation

Mordal은 AnimationPresence와 styled 컴포넌트를 이용하여 제작했다.

정말 좋은 기능이다. 앞으로도 많이 사용할것 같으니 여러번 연습이 필요하다.

import { ISlider } from "./Slider";
import { motion, AnimatePresence, useScroll } from "framer-motion";
import styled from "styled-components";
import { useHistory, useRouteMatch } from "react-router-dom";
import { useQuery } from "react-query";
import { getDetail, IDetail, IMovie } from "../api";
import { makeImagePath } from "../Routes/utils";

const Overlay = styled(motion.div)`
  position: fixed;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  opacity: 0;
`;

const BigMovie = styled(motion.div)`
  position: absolute;
  z-index: 999;
  width: 40vw;
  height: 80vh;
  left: 0;
  right: 0;
  margin: 0 auto;
  border-radius: 15px;
  overflow: hidden;
  background-color: ${(props) => props.theme.black.lighter};
`;

const BigCover = styled.div`
  width: 100%;
  background-size: cover;
  background-position: center center;
  height: 400px;
`;

const BigTitle = styled.h3`
  color: ${(props) => props.theme.white.lighter};
  padding: 20px;
  font-size: 46px;
  position: relative;
  top: -80px;
`;

const BigOverview = styled.p`
  padding: 20px;
  position: relative;
  top: -80px;
  color: ${(props) => props.theme.white.lighter};
`;

const DetailWrap = styled.div`
  padding: 0 20px;
`;

const Runtime = styled.span`
  margin-left: 1em;
`;

const Bigdate = styled.div`
  font-size: 1em;
  position: relative;
  top: -60px;
  margin-left: 1.3rem;
  display: flex;
  justify-content: flex-start;
  align-items: center;
`;

const BigVote = styled.div`
  font-size: 1rem;
  margin-left: 1rem;
  span {
    color: gray;
  }
`;

const GenresList = styled.ul`
  margin-left: 2rem;
  display: flex;
  justify-content: flex-end;
  span {
    color: gray;
  }
  li {
    margin-left: 5px;
  }
`;

const BigTagline = styled.span`
  display: block;
  font-size: 1.25em;
  line-height: 2;
`;

function Detail({ data, category, type, url }: ISlider) {
  const history = useHistory();
  const { scrollY } = useScroll();
  const bigMovieMatch = useRouteMatch<{ movieId: string }>(
    `/${url}/${type}/:movieId`
  );
  const clickedMovie =
    bigMovieMatch?.params.movieId &&
    data?.results.find(
      (movie: IMovie) => +movie.id === +bigMovieMatch.params.movieId
    );
  const onOverlayClick = () => {
    history.goBack();
  };
  const { data: detail } = useQuery<IDetail>(
    ["details", `detail_${bigMovieMatch?.params.movieId}`],
    () => getDetail("movie", bigMovieMatch?.params.movieId)
  );

  return (
    <AnimatePresence>
      {bigMovieMatch ? (
        <>
          <Overlay
            onClick={onOverlayClick}
            exit={{ opacity: 0 }}
            animate={{ opacity: 1 }}
          />
          <BigMovie
            style={{ top: scrollY.get() + 100 }}
            layoutId={bigMovieMatch.params.movieId}
          >
            {clickedMovie && detail ? (
              <>
                <BigCover
                  style={{
                    backgroundImage: `linear-gradient(to top, black, transparent), url(${makeImagePath(
                      clickedMovie.backdrop_path
                    )})`,
                  }}
                />
                <DetailWrap>
                  <BigTitle>
                    <h3>{clickedMovie.title || clickedMovie.name}</h3>
                  </BigTitle>
                  <Bigdate>
                    <span>
                      {(
                        clickedMovie.release_date || clickedMovie.first_air_date
                      ).substr(0, 4)}
                    </span>
                    {detail.runtime ? (
                      <Runtime>
                        {Math.floor(detail?.runtime / 60)}시간
                        {detail?.runtime % 60}</Runtime>
                    ) : null}
                    <BigVote>
                      <span>평점:</span>
                      {clickedMovie.vote_average}
                    </BigVote>
                    <GenresList>
                      <span>장르: </span>
                      {detail?.genres?.map((genre) => (
                        <li key={genre.id}>{genre.name},</li>
                      ))}
                    </GenresList>
                  </Bigdate>
                  <BigOverview>
                    <BigTagline>{detail?.tagline}</BigTagline>
                    {clickedMovie.overview}
                  </BigOverview>
                </DetailWrap>
              </>
            ) : null}
          </BigMovie>
        </>
      ) : null}
    </AnimatePresence>
  );
}

export default Detail;

결론

어떻게든 돌아가게 만들긴 했는데 5개의 챌린지중 2개만 구현했다.

기회가 된다면 처음부터 짜임새있게 다시 코딩하고싶다.

0개의 댓글