# Header ## CSS ### ISSUE 1 - **Nav**에 `width: 100%` 와 `justify-content: space-between`을 주고 **padding**하게 되면 전체 넓이를 **padding + width: 100%**로 계산하게 되면서 컴포넌트가 밖으로 나가는 현상이 발생한다. → 이를 막기 위해 `box-sizing: border-box`를 추가하면 **padding**과 **border**가 **width**에 포함되어 계산된다. ### ISSUE 2 - **position**이 **absolute**에서 컴포넌트를 중앙을 맞추고 싶으면 left : 0, right: 0, margin : 0 auto를 해주면 된다. ### ISSUE 3 - Nav의 배경색 변경이 **transition**의 **duration이** 적용되지 않는 문제 발생한다. → **backgroundColor**는 GPU 가속이 적용되지 않아 duration이 무시되는 경향이 있어 이를 해결하기 위해서는 **CSS**에서 **transition**을 직접 주는 방식을 사용해야 한다. ```jsx const Nav = styled(motion.nav)` display: flex; justify-content: space-between; align-items: center; width: 100%; position: fixed; top: 0; height: 80px; font-size: 14px; padding: 20px 60px; color: white; box-sizing: border-box; transition: background-color 0.5s linear; z-index: 100; ` 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: 1.5px; stroke: white; } ` const Itmes = 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.form` color: ${props => props.theme.white.darker}; display: flex; align-items: center; 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) <{ scroll: boolean }>` transform-origin: right center; position: absolute; right: 60px; padding: 5px 10px; padding-left: 40px; z-index: -1; color: white; font-size: 16px; background-color: ${props => props.scroll ? "transparent" : "rgba(0, 0, 0, 1)"}; border: 1px solid ${props => props.theme.white.lighter}; ` ``` ## Animation ### ISSUE 1 - 이미지를 시작할 때 오른쪽에서 시작하고 싶으면 `transform-origin: right center`을 해주면 된다. ### ISSUE 2 - Border를 가지고 있는 컴포넌트(검색 바)의 애니매이션을 주게 되면 잔상이 남는 오류가 발생한다. → 이를 해결하기 위해서 `borderColor: searchOpen ? "#fff" : "transparent”` 같이 **Border**를 투명하게 하거나 **Border를 없게 하는 방식**을 사용하면 이를 해결 할 수 있다. → 추가적으로 브라우저의 **sub-pixel**까지 없애기 위해 `AnimatePresence`와 `조건부 랜더링`을 사용하였다. ### ISSUE 3 → **useAnimation**을 사용하면 조건에 따른 애니메이션을 가시성 좋게 구현할 수 있다. ```jsx const logoVariants = { normal: { fillOpacity: 1 }, active: { fillOpacity: [0, 1, 0], transition: { repeat: Infinity, duration: 1, }, } } const navVariants = { top: { backgroundColor: "transparent" }, scroll: { backgroundColor: "rgba(0, 0, 0, 1)" } } ... <Nav variants={navVariants} initial="top" animate={navAnimation} transition={{ ease: "linear" }}> <Search> <motion.svg onClick={toggleSearch} animate={{ x: searchOpen ? -215 : 0 }} transition={{ ease: "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> <AnimatePresence> {searchOpen && ( <Input as={motion.input} initial={{ scaleX: 0, opacity: 0, borderWidth: 0 }} animate={{ scaleX: 1, opacity: 1, borderWidth: 1 }} exit={{ scaleX: 0, opacity: 0, borderWidth: 0 }} transition={{ ease: "linear" }} placeholder="Search for movie or tv show..." /> )} </AnimatePresence> </Search> </Nav> ``` # Slider ## CSS ### ISSUE 1 - 영화를 최신, 인기 등 여러 개로 나누기 위해 여러 슬라이드가 필요했고 이를 구현하기 위해 **Slider** 컴포넌트를 따로 구현하였다. ### ISSUE 2 - **LeftArrow**와 **RightArrow**을 따로 만들어서 좌우로 넘길 수 있는 버튼을 만들었다. ```jsx const Overlay = styled.div` position: relative; top: -100px; height: 200px; `; const SilderTitle = styled.h1` position: absolute; top: -50px; left: 60px; font-size: 30px; font-weight: 600; `; const LeftArrow = styled(motion.div)` display: flex; position: absolute; left: 10px; top: 0px; width: 50px; height: 100%; font-size: 20px; color: white; z-index: 100; cursor: pointer; align-items: center; justify-content: center; `; const RightArrow = styled(motion.div)` display: flex; position: absolute; right: 10px; top: 0px; width: 50px; height: 100%; font-size: 20px; color: white; z-index: 100; cursor: pointer; align-items: center; justify-content: center; `; const Row = styled(motion.div)` display: grid; gap: 10px; padding: 0px 60px; grid-template-columns: repeat(6, 1fr); position: absolute; width: 100%; `; const Box = styled(motion.div)` background-color: black; background-size: cover; background-position: center center; height: 200px; font-size: 66px; cursor: pointer; &:first-child { transform-origin: center left; } &:last-child { transform-origin: center right; } `; const BoxImage = styled(motion.div) <{ bgPhoto: string }>` background-image: url(${(props) => props.bgPhoto}); background-size: cover; background-position: center center; height: 200px; width: 100%; `; const Info = styled(motion.div)` padding: 10px; background-color: ${(props) => props.theme.black.lighter}; opacity: 0; position: absolute; width: 100%; bottom: 0; h4 { text-align: center; font-size: 18px; } `; ``` ## Animation ### ISSUE 1 - **좌/우** 애니메이션을 넘기기 위해 **decreaseIndex, increaseIndex**을 따로 분리하여 구현하였다. ### ISSUE 2 - 또한 넷플릭스와 비슷한 느낌을 주기 위해 버튼이 평소에는 없다가 hover시 나타나도록 구현하였다. ```jsx const decreaseIndex = () => { if (data) { if (leaving) return; toggleLeaving(); setIsBack(true); const totalMovies = data?.results.length - (removeFirst ? 1 : 0); const maxIndex = Math.floor(totalMovies / offset) - 1; setIndex((prev) => prev === 0 ? maxIndex : prev - 1); } } const increaseIndex = () => { if (data) { if (leaving) return; toggleLeaving(); setIsBack(false); const totalMovies = data?.results.length - (removeFirst ? 1 : 0); const maxIndex = Math.floor(totalMovies / offset) - 1; setIndex((prev) => prev === maxIndex ? 0 : prev + 1); } } <LeftArrow onClick={decreaseIndex} initial={{ opacity: 0 }} whileHover={{ opacity: 1, scale: 1.2, transition: { duration: 0.3 } }} > ◀ </LeftArrow> <AnimatePresence initial={false} onExitComplete={toggleLeaving} custom={isBack}> <Row key={index} custom={isBack} variants={rowVariants} initial="hidden" animate="visible" exit="exit" transition={{ type: "tween", duration: 1 }}> {data?.results.slice(removeFirst ? 1 : 0).slice(offset * index, offset * index + offset).map((movie) => ( <Box key={movie.id} variants={boxVariants} initial="normal" whileHover="hover" transition={{ type: "tween" }} onClick={() => onBoxClick(movie.id)} animate={{ transition: { opacity: { duration: 0.5 } } }} > <BoxImage layoutId={`cover-${title}-${movie.id}`} bgPhoto={makeImagePath(movie.backdrop_path ?? "", "w500")} /> <Info variants={infoVariants}><h4>{movie.title}</h4></Info> </Box> ))} </Row> </AnimatePresence> <RightArrow onClick={increaseIndex} initial={{ opacity: 0 }} whileHover={{ opacity: 1, scale: 1.2, transition: { duration: 0.3 } }} > ▶ </RightArrow> ``` # Home ## CSS ```jsx const Wrapper = styled.div` background: black; padding-bottom: 200px; `; const Loader = styled.div` height: 20vh; display: flex; justify-content: center; align-items: center; `; 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; `; const Title = styled.h2` font-size: 68px; margin-bottom: 20px; ; `; const Overview = styled.p` font-size: 30px; width: 50%; `; const MoviesCategoryList = styled.div` display: flex; flex-direction: column; gap: 150px; `; ``` ### API ### ISSUE 1 - 각 영화의 정보를 가져오기 위해 [`tmdb.org`](http://tmdb.org/) 사이트의 API을 이용하였다. ### ISSUE 2 - 각 API의 Reponse에서 가져오는 결과들을 **interface**로 정의하였다. ```jsx // Interface export interface IMovie { id: number; backdrop_path: string; poster_path: string; title: string; overview: string; } export interface IGetMoviesResult { dates: { maximum: string; minimum: string; }; page: number; results: IMovie[]; total_pages: number; total_results: number; } // await export const getNowPlayingMovies = async (lang: string, page?: number): Promise<IGetMoviesResult> => { const response = await axios.get(`${BASE_URL}/movie/now_playing?api_key=${API_KEY}&language=${lang}&page=${page ? page : 1}`); return response.data as IGetMoviesResult; } // request const { data: nowPlayingData, isLoading: nowPlayingLoading } = useQuery<IGetMoviesResult>({ queryKey: ["movies", "nowPlaying"], queryFn: () => getNowPlayingMovies("ko-KR", 1), }); ``` # Card ### CSS ### ISSUE 1 - 영화를 클릭했을 때 **Detail**을 구현하기 위해 Card 컴포넌트를 따로 제작하였다. ### ISSUE 2 - 넷플릭스에서는 **Modal**의 **슬라이더 바** 없이 외부의 슬라이더 바로 해당 Card 컴포넌트를 조정했는데, 스스로 해보다가 방법을 찾을 수 없어 우선 Modal에 슬라이더 바를 추가하였다. ### ISSUE 3 - **BigMovie**에서 **overflow-y: auto**을 추가해 슬라이드 바를 추가했다. ```jsx const Loader = styled.div` height: 20vh; display: flex; justify-content: center; align-items: center; `; const Overlay = styled(motion.div)` position: fixed; top: 0; width: 100%; height: 100vh; background-color: rgba(0, 0, 0, 0.5); opacity: 0; overflow-y: auto; `; const BigMovie = styled(motion.div)` position: fixed; max-width: 1000px; max-height: 90vh; top: 0; left: 0; right: 0; bottom: 0; margin: auto; border-radius: 15px; background-color: ${(props) => props.theme.black.lighter}; z-index: 101; overflow-y: auto; `; const BigCover = styled(motion.div) <{ bgPhoto: string }>` width: 100%; background-image: linear-gradient(to top, black, transparent), url(${(props) => props.bgPhoto}); background-size: cover; background-position: center center; height: 600px; `; const BigTitle = styled.h3` color: ${(props) => props.theme.white.lighter}; padding: 20px; font-size: 32px; position: relative; top: -80px; `; const BigOverview = styled.p` padding: 20px; position: relative; top: -80px; color: ${(props) => props.theme.white.lighter}; `; const InfoContainer = styled.div` position: relative; top: -60px; display: flex; justify-content: space-between; align-items: center; padding: 0 40px; `; const RightContainer = styled.div` display: flex; flex : 1; flex-direction: column; justify-content: center; min-width: 0; gap: 12px; `; const LeftContainer = styled.div` display: flex; flex : 2; flex-direction: column; justify-content: center; width: 100%; gap: 12px; `; const Info = styled.div` display: flex; align-items: center; flex-direction: row; font-size: 16px; gap: 8px; width: 100%; min-width: 0; `; const InfoTitle = styled.h3` font-weight: 600; color: gray; flex-shrink: 0; `; const InfoContent = styled.div` overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 400; flex: 1 1 0; min-width: 0; `; const SimilarMovies = styled.div` display: grid; gap: 10px; padding: 0px 60px; grid-template-columns: repeat(3, 1fr); position: absolute; width: 100%; `; ``` ### Animation ```jsx <Overlay onClick={onOverlayClick} animate={{ opacity: 1 }} exit={{ opacity: 0 }} /> <BigMovie initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.3 }} > </BigMovie> ``` # 결과 영상 ![[Netflix Result.mkv]]