# 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]]