S-FLEX(NOMFLIX CLONE) - Home

짜스의 하루 ·2024년 7월 2일

삽질의 시간 ing

지난 6일간 저어어어어엉말 삽질과 삽질의 시간을 지나서
어느정도 대강 (?) 만들어봤다고 생각을 해서 기록을 남기러 왔다
사실 이전에도 중간 중간 기록을 할 까 했다가 아 ... 이건 기록을 하면 안되겠다
내일이면 무조건 바뀐다 싶어서 기록을 못했다가 지금은 틀이 어느정도 정해진 것 같아서
기록을 남기러 왔다!

Home - Movie파트

니꼬쌤과 했던 부분에서
여러 api를 불러오고, 버튼도 달아보고 좀 다양하게 , 사람들이 보기 편하게 만들어 보는게 목표였다.

영화는 Now Playing, Upcoming, Top Rated로 리스트를 불러왔다.

Movie API

export interface IMovie {
  id: number;
  backdrop_path: string;
  poster_path: string;
  title: string;
  overview: string;
  vote_average: string;
  release_date: string;
  genre_ids: [];
}

export interface IGetMoviesResult {
  dates: {
    maximum: string;
    minimum: string;
  };
  page: number;
  total_pages: number;
  total_results: number;
  results: IMovie[];
}

export interface IGetMovieDetail {
  runtime: number;
  tagline: string;
  genres: IDetail[];
}

export interface IDetail {
  id: number;
  name: string;
}

export function getMovieDetail(movieId: string) {
  return fetch(
    `${BASE_PATH}/movie/${movieId}?api_key=${API_KEY}&language=ko-KR`
  ).then((response) => response.json());
}

// getMovies 함수 정의
export async function getMovies(): Promise<IGetMoviesResult> {
  const response = await fetch(
    `${BASE_PATH}/movie/now_playing?api_key=${API_KEY}&language=ko-KR`
  );
  if (!response.ok) {
    throw new Error('현재 상영 중인 영화를 가져오지 못했습니다.');
  }
  return response.json();
}

// getUpcomingMovies 함수 정의
export async function getUpcomingMovies(): Promise<IGetMoviesResult> {
  const response = await fetch(
    `${BASE_PATH}/movie/upcoming?api_key=${API_KEY}&language=ko-KR`
  );
  if (!response.ok) {
    throw new Error('개봉 예정 영화를 가져오지 못했습니다.');
  }
  return response.json();
}

export async function getTopRatedMovies(): Promise<IGetMoviesResult> {
  const response = await fetch(
    `${BASE_PATH}/movie/top_rated?api_key=${API_KEY}&language=ko-KR`
  );
  if (!response.ok) {
    throw new Error('Top Rated 영화를 가져오지 못했습니다.');
  }
  return response.json();
}

export async function getDistinctMovies(): Promise<{
  nowPlaying: IMovie[];
  upcoming: IMovie[];
  topRanked: IMovie[];
}> {
  const nowPlayingResult = await getMovies();
  const upcomingResult = await getUpcomingMovies();
  const topRankedResult = await getTopRatedMovies();

  // 현재 상영 중인 영화 ID 목록
  const nowPlayingIds = new Set(
    nowPlayingResult.results.map((movie) => movie.id)
  );

  // 개봉 예정 영화 중 현재 상영 중이지 않은 영화 필터링
  const upcomingMovies = upcomingResult.results.filter(
    (movie) => !nowPlayingIds.has(movie.id)
  );

  // Top Rated 영화 중 현재 상영 중이거나 개봉 예정이 아닌 영화 필터링
  const topRankedMovies = topRankedResult.results.filter(
    (movie) =>
      !nowPlayingIds.has(movie.id) &&
      !upcomingMovies.some((upcomingMovie) => upcomingMovie.id === movie.id)
  );

  return {
    nowPlaying: nowPlayingResult.results,
    upcoming: upcomingMovies,
    topRanked: topRankedMovies,
  };
}

Now Playing, Upcoming, Top Rated를 api로 불러오면서
"겹치는 영화가 생각보다 많네?"라는 생각이 들어서
굳이 겹치는 영화를 보여줄 필요가 있을 까? 싶은 생각이 들어서

getDistinctMovies()를 통해서 필터링을 통해서 데이터를 불러왔다.
상영 중인 영화의 ID를 받아서, 개봉 예정 영화 중 현재 상영 중이지 않은 영화 필터링, Top Rated 영화 중 현재 상영 중이거나 개봉 예정이 아닌 영화 필터링을 통해서 겹치는 영화를 제거한 뒤,

getDistinctMovies() 함수를 통해서 세가지 data를 불러올 수 있게 되었다.

Movie 컴포넌트

const Movie = () => {
  const navigate = useNavigate();
  const { scrollY } = useScroll();
  const bigMovieMatch = useMatch('/movies/:category/:movieId');
  const movieId = bigMovieMatch?.params.movieId;
  const category = bigMovieMatch?.params.category;

  const { data, isLoading } = useQuery('distinctMovies', getDistinctMovies);
  const [videoData, setVideoData] = useState<IGetVideosResult>();
  const { data: movieDetail } = useQuery<IGetMovieDetail>(
    ['detail', movieId],
    () => getMovieDetail(movieId || '')
  );

  const onOverlayClicked = () => {
    navigate('/');
  };

  useEffect(() => {
    const fetchVideoData = async () => {
      if (movieId) {
        try {
          const videoData = await getVideos(movieId);
          setVideoData(videoData);
        } catch (error) {
          console.error('Error fetching video data:', error);
        }
      }
    };

    fetchVideoData();
  }, [movieId]);

  const clickedMovie =
    movieId &&
    data &&
    category &&
    data[category as keyof typeof data]?.find(
      (movie: IMovie) => movie.id + '' === movieId
    );

  const formatRuntime = (runtime: number) => {
    const hours = Math.floor(runtime / 60);
    const minutes = runtime % 60;
    return `${hours}시간 ${minutes}분`;
  };

  return (
    <Container>
      {data && (
        <>
          <SliderComponent
            title="Now Playing"
            movies={data.nowPlaying}
            category="nowPlaying"
          />
          <SliderComponent
            title="Upcoming"
            movies={data.upcoming}
            category="upcoming"
          />
          <SliderComponent
            title="Top Rated"
            movies={data.topRanked}
            category="topRanked"
          />
        </>
      )}
      <AnimatePresence>
        {bigMovieMatch && clickedMovie ? (
          <>
            <Overlay
              onClick={onOverlayClicked}
              exit={{ opacity: 0 }}
              animate={{ opacity: 1 }}
            />
            <BigMovie style={{ top: scrollY.get() + 100 }} layoutId={movieId}>
              {clickedMovie && videoData && videoData.results.length > 0 ? (
                <Video videos={videoData.results} />
              ) : (
                <>
                  <BigCover
                    style={{
                      backgroundImage: `linear-gradient(to top , black, transparent), url(${makeImagePath(
                        clickedMovie!.backdrop_path,
                        'w500'
                      )})`,
                    }}
                  />
                </>
              )}
              <BigTitle>{clickedMovie.title}</BigTitle>
              <Explanation>
                <CategoryLabel>
                  {category === 'nowPlaying' && nowPlayingFont}
                  {category === 'upcoming' && upcomingFont}
                  {category === 'topRanked' && topRankedFont}
                </CategoryLabel>
                <BigRelease>
                  {clickedMovie.release_date.slice(0, 4) +
                    ' ' +
                    clickedMovie.release_date.slice(5, 7)}
                </BigRelease>
                {movieDetail && (
                  <RunTime> {formatRuntime(movieDetail.runtime)}</RunTime>
                )}
                <BigVoteAverage>
                  ⭐️ {Number(clickedMovie.vote_average).toFixed(2)}
                </BigVoteAverage>
              </Explanation>
              <ExplanationSub>
                <BigOverview>{clickedMovie.overview}</BigOverview>
                {movieDetail && (
                  <Detail>
                    {movieDetail.genres.map((genre) => (
                      <GenreTitle>{genre.name}</GenreTitle>
                    ))}
                    {movieDetail.tagline && (
                      <TagLine> : {movieDetail.tagline}</TagLine>
                    )}
                  </Detail>
                )}
              </ExplanationSub>
            </BigMovie>
          </>
        ) : null}
      </AnimatePresence>
    </Container>
  );
};

export default Movie;

처음에는 Now Playing, Upcoming, Top Rated 의 각각 컴포넌트를 만들어서 데이터를 받아왔지만, 이미 getDistinctMovies()함수 하나를 통해서 각각의 데이터를 받아올 수 있고, 똑같은 구성을 가지고 있어서

Movie컴포넌트와, Slider 컴포넌트로 구성해서 각각의 데이터를 넘겨주는 식으로 코드를 작성했다
(최소한으로 겹치는 부분을 제거하려고 노력했다)

slider component

이 부분은 영화를 불러오고, 이전, 다음버튼으로 영화를 넘길 수 있는 부분을 코드로 작성한 부분이다.
(Movie 컴포넌트에서 보내준 데이터를 받아서 화면에 뿌리는 것! )

const SliderComponent = ({ title, movies, category }: SliderComponentProps) => {
  const navigate = useNavigate();
  const [index, setIndex] = useState(0);
  const [leaving, setLeaving] = useState(false);
  const [direction, setDirection] = useState(true); // true for next, false for previous

  const increaseIndex = () => {
    if (movies) {
      if (leaving) return;
      const totalMovies = movies.length - 1;
      const maxIndex = Math.ceil(totalMovies / offset) - 1;

      setDirection(true);
      toggleLeaving();
      setIndex((prev) => (prev === maxIndex ? 0 : prev + 1));
    }
  };

  const decreaseIndex = () => {
    if (movies) {
      if (leaving) return;
      const totalMovies = movies.length - 1;
      const maxIndex = Math.ceil(totalMovies / offset) - 1;

      setDirection(false);
      toggleLeaving();
      setIndex((prev) => (prev === 0 ? maxIndex : prev - 1));
    }
  };

  const toggleLeaving = () => {
    setLeaving((prev) => !prev);
  };

  const onBoxClicked = async (movieId: number) => {
    navigate(`/movies/${category}/${movieId}`);
  };

  return (
    <>
      <TitleArea>
        <SectionTitle>{title}</SectionTitle>
        <ButtonWrapper>
          <Button onClick={decreaseIndex}>이전</Button>
          <Button onClick={increaseIndex}>다음</Button>
        </ButtonWrapper>
      </TitleArea>
      <Slider>
        <AnimatePresence
          initial={false}
          custom={direction}
          onExitComplete={toggleLeaving}
        >
          <Row
            variants={rowVariants}
            initial="hidden"
            animate="visible"
            exit="exit"
            key={index}
            custom={direction} // Pass the direction to variants
            transition={{ type: 'tween', duration: 1 }}
          >
            {movies
              .slice(offset * index, offset * index + offset)
              .map((movie) => (
                <Box
                  layoutId={movie.id + ''}
                  onClick={() => onBoxClicked(movie.id)}
                  transition={{ type: 'tween' }}
                  variants={BoxVariants}
                  whileHover="hover"
                  initial="normal"
                  key={movie.id}
                  bgPhoto={makeImagePath(movie.backdrop_path, 'w500')}
                >
                  <Info variants={infoVariants}>
                    <h4>{movie.title}</h4>
                  </Info>
                </Box>
              ))}
          </Row>
        </AnimatePresence>
      </Slider>
    </>
  );
};

export default SliderComponent;

Slider 컴포넌트는 영화를 불러오고, 영화가 슬라이드 형식으로 넘어가는 부분인데,
framer-motion 라이브러리를 사용하여 슬라이더의 각 Row가 화면 안팎으로 이동할 때 적용할 애니메이션을 설정해 주었다.

const rowVariants = {
  hidden: (direction: boolean) => ({
    x: direction ? window.outerWidth + 5 : -window.outerWidth - 5,
  }),
  visible: {
    x: 0,
  },
  exit: (direction: boolean) => ({
    x: direction ? -window.outerWidth - 5 : window.outerWidth + 5,
  }),
};
const [direction, setDirection] = useState(true); 
  const increaseIndex = () => {
    if (movies) {
      if (leaving) return;
      const totalMovies = movies.length - 1;
      const maxIndex = Math.ceil(totalMovies / offset) - 1;

      setDirection(true);
      toggleLeaving();
      setIndex((prev) => (prev === maxIndex ? 0 : prev + 1));
    }
  };

  const decreaseIndex = () => {
    if (movies) {
      if (leaving) return;
      const totalMovies = movies.length - 1;
      const maxIndex = Math.ceil(totalMovies / offset) - 1;

      setDirection(false);
      toggleLeaving();
      setIndex((prev) => (prev === 0 ? maxIndex : prev - 1));
    }
  };

direction 상태: 슬라이더가 이동할 방향을 나타내며, true는 "다음", false는 "이전"을 의미하는데,

increaseIndexdecreaseIndex는 슬라이더의 인덱스를 조정하여 각각 "다음" 및 "이전"으로 이동시키고, 슬라이더의 방향에 따라 애니메이션을 적용할 수 있는 역할을 한다.

즉, direction --> true 다음으로 이동 , direction --> false 이전으로 이동, 이에 맞춰서 rowVariants를 (animation)을 설정해 두었다.

Video api & Component

이후, 업로드중..

 {clickedMovie && videoData && videoData.results.length > 0 ? (
                <Video videos={videoData.results} />
              ) : (
                <>
                  <BigCover
                    style={{
                      backgroundImage: `linear-gradient(to top , black, transparent), url(${makeImagePath(
                        clickedMovie!.backdrop_path,
                        'w500'
                      )})`,
                    }}
                  />
                </>

이 화면에서 볼 수 있듯이 예고편을 보여줄 수 있으면, 예고편을 보여주고, 아니면 배경이미지를 보여줄 수 있도록 코드를 작성해 두었다.

export function getVideos(movieId: string) {
  return fetch(
    `${BASE_PATH}/movie/${movieId}/videos?api_key=${API_KEY}&language=ko-KR`
  ).then((response) => response.json());
}

형태로 video의 api를 받아올 때, 영화의 id를 넘겨주어서, 해당 영화를 찾고

interface IVideoProps {
  videos: IVideos[];
}

const Video = ({ videos }: IVideoProps) => {
  const playVideo = (key: string) => {
    window.open(`https://www.youtube.com/watch?v=${key}`, '_blank');
  };

  const video = videos[0];

  return (
    <VideoContainer>
      <VideoBox key={video.id}>
        <VideoTitle>{video.name}</VideoTitle>
        <PlayButton onClick={() => playVideo(video.key)}>▶</PlayButton>
      </VideoBox>
    </VideoContainer>
  );
};

export default Video;

해당 영화의 key를 받아서 playVideo()함수에 넘겨주어서 링크로 이동할 수 있도록! video 컴포넌트를 제작해 두었다.

영화 정보 제공

(사실 넷플을 안본지 1-2년된 것 같은데)
내가 생각했을 때, 받아올 수 있는 api중, 필요한 정보만 화면에 뿌려주면 좋을 것 같다는 생각을 해서
현재 어떤 타입의 영화인지(Now Playing), 개봉일, runtime, 평점
줄거리, 장르, 간단한 한줄 평

이정도면 영화 하나에 대한 정보는 충분히 넘겨준다고 생각을 했다

기본적으로 Now Playing, Upcoming, Top Rated 에 대한 api를 받아올 때, 개봉일, 평점, 줄거리 에 대해서는 보내주지만, 다른 것들은 보내주지 않았다.

흠.. 어떻게 해야 할 까 고민을 하다가

export function getMovieDetail(movieId: string) {
  return fetch(
    `${BASE_PATH}/movie/${movieId}?api_key=${API_KEY}&language=ko-KR`
  ).then((response) => response.json());
}

getMovieDetail() api를 받아왔다. 즉 movieId를 넘겨서 더 많은 영화에 대한 디테일한 정보를 받아올 수 있는 api이다.

export interface IGetMovieDetail {
  runtime: number;
  tagline: string;
  genres: IDetail[];
}

export interface IDetail {
  id: number;
  name: string;
}

  const { data: movieDetail } = useQuery<IGetMovieDetail>(
    ['detail', movieId],
    () => getMovieDetail(movieId || '')
  );

getMovieDetail() 를 통해서 runtime, tagline, genres를 모두 불러올 수 있었다!

profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글