Day 03 - 220513
- 각 컴포넌트 클래스(className) 할당
- 스타일 적용 (module.css)
- 배포 (gh-pages)
import styles from './Load.module.css';
const Load = () => {
return (
<div className={styles.Load}>
<h1>
Loading <div className={styles.typing}>. . .</div>
</h1>
</div>
);
};
export default Load;
.Load {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.typing {
margin: 0;
white-space: nowrap;
animation-name: typingDot;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(31);
overflow: hidden;
}
@keyframes typingDot {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
white-space
를 nowrap으로 해줌.
white-space
는 스페이스와 탭, 줄바꿈, 자동줄바꿈을 어떻게 처리할지 정하는 속성이다.
-> 기본값 : normal
-> nowrap : 줄바꿈 or 연속스페이스를 하나의 스페이스로. 줄바꿈은 X.
import { Link } from 'react-router-dom';
import { Group_key_arr, Group_obj } from '../atom/NavList';
import styles from './Navbar.module.css';
const Navbar = () => {
return (
<div className={styles.container}>
{/* Page Name */}
<div className={styles.pageName}>
<Link to="/">YeonFlix</Link>
</div>
{/* Group Links */}
<div className={styles.groupLink}>
{Group_key_arr.map((groupName) => {
return (
<div key={groupName} className={styles.Link}>
<div className={styles.LinkSep}>
<Link to={`/page/${Group_obj[groupName]}/1`}>{groupName}</Link>
</div>
</div>
);
})}
</div>
</div>
);
};
export default Navbar;
/* container pageName groupLink Link LinkSep */
.container {
position: relative;
width: 100%;
height: 80px;
padding: 0 30px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
z-index: 10;
transition: background-color 0.25s ease-in-out;
}
.container:hover {
background-color: #4000a7a9;
}
.pageName {
position: relative;
font-size: 36px;
color: #fff;
margin-left: 30px;
transition: transform 0.1s ease-in;
}
.groupLink {
display: flex;
flex-direction: row;
font-size: 20px;
color: #fff;
}
.Link {
margin-right: 85px;
transition: transform 0.1s ease-in;
}
.pageName:hover {
transform: scale(1.1);
color: #ffff00;
text-shadow: 1px -1px 0 #4000a7, -1px 2px 1px #4000a7;
animation-name: spinTitle;
animation-duration: 3s;
animation-iteration-count: infinite;
}
.Link:hover {
transform: scale(1.1);
text-shadow: 1px -1px 0 #4000a7, -1px 2px 1px #4000a7;
}
@keyframes spinTitle {
from {
transform: rotate3d(1, 0, 0, 0deg);
}
to {
transform: rotate3d(1, 0, 0, 359deg);
}
}
import styles from './Slide.module.css';
import { useCallback, useEffect, useState } from 'react';
import MovieSlide from './MovieSlide';
import {
IoIosArrowDropleftCircle,
IoIosArrowDroprightCircle,
} from 'react-icons/io';
import Load from '../components/Load';
// Home
const Slide = ({ movieApi }) => {
const [loading, setLoading] = useState(true);
const [movies, setMovies] = useState([]);
const [trans, setTrans] = useState(0);
const onClickLeft = () => {
if (trans >= 0) {
// trans가 0 이상이면
return;
}
setTrans((prevTrans) => prevTrans + 460);
// 한 영화 슬라이드의 width가 460px임
};
const onClickRight = () => {
if (trans <= -1380) {
// 460 * 3 = 1380
return;
}
setTrans((prevTrans) => prevTrans - 460);
};
const getMovies = useCallback(async () => {
setLoading(true);
const json = await (await fetch(movieApi)).json();
setMovies(json.data.movies);
setLoading(false);
}, [movieApi]);
useEffect(() => {
getMovies();
}, [getMovies]);
// 최초 한번만 API를 불러옴
return (
<div className={styles.container}>
<div className={styles.slideShow}>
{loading ? (
<Load />
) : (
<div
className={styles.slides}
style={{ transform: `translateX(${trans}px)` }}
>
{/* eslint-disable-next-line array-callback-return */}
{movies.map((movie) => {
if (movie.medium_cover_image != null) {
return (
// MovieSlide is redering code with Slide!
<MovieSlide
key={movie.id}
id={movie.id}
coverImg={movie.medium_cover_image}
rating={movie.rating}
runtime={movie.runtime}
title={movie.title}
/>
);
}
})}
</div>
)}
</div>
{/* Left, Right 버튼 */}
{loading ? null : (
<div className={styles.buttons}>
<button className={styles.left} onClick={onClickLeft}>
<IoIosArrowDropleftCircle />
</button>
<button className={styles.right} onClick={onClickRight}>
<IoIosArrowDroprightCircle />
</button>
</div>
)}
</div>
);
};
export default Slide;
/* container, slideShow, slides, buttons, left, right */
/* reset CSS */
button {
border: none;
background-color: transparent;
}
.container {
position: relative;
}
/* 230 * 4 = */
.slideShow {
position: relative;
width: 920px;
height: 500px;
margin: 0 auto;
overflow: hidden;
}
.slides {
padding-top: 30px;
display: flex;
flex-direction: row;
transition: transform 0.4s ease-in-out;
}
.left,
.right {
color: #fff;
font-size: 60px;
cursor: pointer;
transition: opacity 0.1s ease-in;
filter: drop-shadow(2px 2px #3f00a7);
}
.buttons button {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.left {
left: 130px;
}
.right {
right: 130px;
}
.left:hover,
.right:hover {
opacity: 0.6;
}
-> 참고로, translateX는 JSX에서 인라인 style로 넣어줬음. (변수로 계산해야 하므로)
padding-top
을 설정해줬음.import styles from './MovieSlide.module.css';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const MovieSlide = ({ id, coverImg, rating, runtime, title }) => {
return (
<div className={styles.movie}>
<div className={styles.coverImg}>
<Link to={`/movie/${id}`}>
<img src={coverImg} alt={title} />
</Link>
</div>
<div className={styles.text}>
<div className={styles.title}>
<h3>
<Link to={`/movie/${id}`}>
{title.length > 35 ? `${title.slice(0, 35)}...` : title}
</Link>
</h3>
</div>
<div className={styles.info}>
<span>{rating && `rating: ${rating} / 10`}</span>
<span>{runtime && `runtime: ${runtime} min`}</span>
</div>
</div>
</div>
);
};
MovieSlide.propTypes = {
id: PropTypes.number.isRequired,
coverImg: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
export default MovieSlide;
/*
movie coverImg text title info
*/
.movie {
width: 230px;
height: 500px;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 6px;
transition: all 0.2s ease-in;
border-right: 2px solid rgba(0, 0, 0, 0.2);
}
.movie:hover {
transform: translateY(-15px);
}
.coverImg img {
border-radius: 6px 6px 0 0;
}
.text {
padding: 3px 4px;
}
.title {
text-align: center;
font-size: 16px;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 3px;
transition: all 0.1s ease-in;
}
.title:hover {
color: #4000a7;
text-decoration: underline;
transform: scale(1.03);
}
.info {
font-size: 13px;
display: flex;
flex-direction: column;
color: #666;
text-align: center;
}
import styles from './MovieGroup.module.css';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const MovieGroup = ({
id,
coverImg,
title,
rating,
runtime,
year,
summary,
}) => {
return (
<div className={styles.movie}>
<div className={styles.layout}>
<div className={styles.shortview}>
<div className={styles.shortviewImg}>
<img src={coverImg} alt={title} />
</div>
<div className={styles.shortviewText}>
<div className={styles.shortviewTitle}>
<h3>
<Link to={`/movie/${id}`}>
{title.length > 35 ? `${title.slice(0, 35)}...` : title}
</Link>
</h3>
</div>
<div className={styles.shortviewInfo}>
<div className={styles.year}>{year && `year: ${year}`}</div>
<div className={styles.rating}>
{rating && `rating: ${rating} / 10`}
</div>
<div className={styles.runtime}>
{runtime && `runtime: ${runtime} min`}
</div>
<div className={styles.desc}>
{summary
? summary.length > 140
? `${summary.slice(0, 140)}...`
: summary
: 'no summary data'}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
MovieGroup.propTypes = {
id: PropTypes.number.isRequired,
img: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
rating: PropTypes.number,
runtime: PropTypes.number,
summary: PropTypes.string,
};
export default MovieGroup;
/*
movie(Container) shortview shortviewImg shortviewText shortviewTitle
shortviewInfo -> year, rating, runtime, desc
*/
.movie {
color: #111111;
display: flex;
justify-content: center;
}
.layout {
display: flex;
flex-direction: row;
margin-right: 30px;
margin-bottom: 30px;
}
.shortview {
display: flex;
flex-direction: row;
margin: 10px;
padding: 10px;
width: 480px;
height: 370px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 20px;
overflow: hidden;
transition: all 0.2s ease-in;
}
.shortview:hover {
transform: translateY(-5px);
box-shadow: 4px 8px 6px 0px rgba(0, 0, 0, 0.3);
}
.shortviewImg {
margin-right: 20px;
}
.shortviewImg img {
display: block;
width: 230px;
height: 345px;
border-radius: 10px;
}
.shortviewText {
width: 250px;
height: 350px;
padding: 10px;
}
.shortviewTitle {
font-size: 18px;
margin-bottom: 6px;
transition: all 0.1s ease-in;
}
.shortviewTitle:hover {
text-decoration: underline;
transform: scale(1.05);
}
.year,
.rating {
margin-bottom: 3px;
}
.runtime {
margin-bottom: 8px;
}
.desc {
font-size: 15px;
color: #333333;
letter-spacing: 0.01em;
}
import styles from './MovieDetail.module.css';
import PropTypes from 'prop-types';
const MovieDetail = ({
background_image_original,
coverImg,
rating,
runtime,
description_full,
title,
genres,
}) => {
return (
<div className={styles.movie}>
<div className={styles.background}>
<img src={background_image_original} alt="background" />
</div>
<div className={styles.shortview}>
<div className={styles.shortviewImg}>
<img src={coverImg} alt={title} />
</div>
<div className={styles.shortviewText}>
<div className={styles.titleInfo}>
<h3 className={styles.shortviewTitle}>{title}</h3>
<div className={styles.shortviewInfo}>
{rating && (
<div className={styles.rating}>{`rating: ${rating} / 10`}</div>
)}
{runtime && (
<div
className={styles.runtime}
>{`runtime: ${runtime} min`}</div>
)}
{genres && (
<div className={styles.genres}>
<h4>genres</h4>
<ul>
{genres.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
)}
</div>
<div className={styles.summary}>
{description_full &&
(description_full.length > 450 ? (
<div className={styles.desc}>
<p>{`${description_full.slice(0, 450)} ...`}</p>
</div>
) : (
<div className={styles.desc}>
<p>{description_full}</p>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
MovieDetail.propTypes = {
rating: PropTypes.number,
runtime: PropTypes.number,
coverImg: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
description_full: PropTypes.string,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
};
export default MovieDetail;
/* movie background
Short view shortviewImg shortviewText shortviewTitle
shortviewInfo
rating runtime genres desc*/
.movie {
color: #fff;
}
.background {
z-index: -1;
}
.background img {
display: block;
width: 100%;
height: 100%;
filter: brightness(50%);
}
.shortview {
position: absolute;
top: 35%;
left: 15%;
z-index: 2;
display: flex;
}
.shortviewImg {
margin-right: 50px;
}
.shortviewImg img {
display: block;
width: 300px;
}
.shortviewText {
width: 60%;
}
.shortviewTitle {
font-size: 38px;
letter-spacing: 0.01em;
margin-bottom: 16px;
}
.rating {
color: #ffff00;
font-size: 20px;
margin-bottom: 8px;
}
.runtime {
font-size: 20px;
margin-bottom: 16px;
}
.genres {
background-color: rgba(255, 255, 255, 0.2);
padding: 10px 12px;
border-radius: 20px;
margin-bottom: 36px;
display: flex;
flex-direction: row;
}
.genres h4 {
font-size: 20px;
margin: 0 16px;
text-shadow: 2px 2px #666;
}
.genres h4::before {
content: ' ';
}
.genres ul {
padding: 6px;
display: flex;
flex-direction: row;
}
.genres li {
display: block;
margin-left: 20px;
font-size: 14px;
}
.genres ul:last-child {
margin-right: 0;
}
.desc {
font-size: 18px;
letter-spacing: -0.01em;
}
.desc::before {
content: '📝 summary';
display: block;
background-color: #fff;
color: #666;
width: 20%;
text-align: center;
padding: 3px 2px;
border-radius: 6px;
margin-bottom: 8px;
}
import styles from './Home.module.css';
import { Link } from 'react-router-dom';
import Slide from '../components/Slide';
import { ImPlay } from 'react-icons/im';
import { Group_obj, Group_key_arr } from '../atom/NavList';
const Home = () => {
return (
<div className={styles.container}>
{Group_key_arr.map((group) => (
<div key={group} className={styles.slideBox}>
<div className={styles.title}>
<Link
to={`/page/${Group_obj[group]}/1`}
style={{
display: 'flex',
flexDirection: 'row',
alignContent: 'center',
justifyContent: 'center',
}}
>
<ImPlay />
<h3>{group}</h3>
</Link>
</div>
<Slide
movieApi={`https://yts.mx/api/v2/list_movies.json?limit=10&${Group_obj[group]}&sort_by=rating`}
/>
</div>
))}
<div className={styles.footer}>
<div className={styles.author}>
<h4>thisisyjin</h4>
</div>
<ul className={styles.links}>
<li>
<a href="https://github.com/thisisyjin">githubs</a>
</li>
<li>
<a href="https://mywebproject.tistory.com">dev log</a>
</li>
<li>
<a href="mailto:thisisyjin@naver.com">contact</a>
</li>
</ul>
</div>
</div>
);
};
export default Home;
/*
container slideBox title
footer author links
*/
.title {
margin-top: 40px;
transition: all 0.2s ease-in;
}
.title h3 {
font-size: 42px;
letter-spacing: 0.03em;
}
.title:hover {
text-decoration: underline;
color: #ff0000;
}
.title svg {
margin-right: 5px;
}
.footer {
margin-top: 100px;
width: 100%;
height: 65px;
background-color: rgb(255, 255, 255, 0.8);
display: flex;
justify-content: space-between;
align-items: center;
}
.author {
margin-left: 80px;
font-size: 26px;
letter-spacing: 0.05em;
}
.author h4 {
background: linear-gradient(
to right,
rgba(237, 193, 218, 1) 0%,
rgba(136, 124, 211, 1) 50%,
rgba(117, 174, 242, 1) 100%
);
color: transparent;
-webkit-text-fill-color: transparent;
background-clip: text;
-webkit-background-clip: text;
}
.links {
display: flex;
margin-right: 80px;
}
.links li {
color: #111;
font-size: 20px;
list-style: none;
margin-left: 70px;
transition: all 0.2s ease;
}
.links li:hover {
color: #4000a7;
transform: scale(1.1);
}
import styles from './Group.module.css';
import { useEffect, useState, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import MovieGroup from '../components/MovieGroup';
import Load from '../components/Load';
const List_arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const Group = () => {
const { group, page } = useParams();
const [loading, setLoading] = useState(true);
const [movies, setMovies] = useState([]);
const getMovies = useCallback(async () => {
setLoading(true);
const json = await (
await fetch(
`https://yts.mx/api/v2/list_movies.json?page=${page}&${group}&sort_by=rating`
)
).json();
setMovies(json.data.movies);
setLoading(false);
}, [group, page]);
useEffect(() => {
getMovies();
return;
}, [getMovies]); // group이나 page 바뀔때마다
return (
<div className={styles.container}>
{loading ? (
<Load />
) : (
<div className={styles.movies}>
{movies.map((movie) => (
<MovieGroup
key={movie.id}
id={movie.id}
title={movie.title}
coverImg={movie.medium_cover_image}
rating={movie.rating}
runtime={movie.runtime}
summary={movie.summary}
year={movie.year}
/>
))}
</div>
)}
{loading ? null : (
<div className={styles.footer}>
<div className={styles.list}>
{List_arr.map((lst) => (
<Link key={lst} to={`/page/${group}/${lst}`}>
{lst}
</Link>
))}
</div>
</div>
)}
</div>
);
};
export default Group;
/*
container movies footer list
*/
.container {
margin-top: 40px;
margin: 40px 20px;
}
.movies {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.footer {
margin-top: 40px;
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
.list a {
font-size: 24px;
margin-left: 50px;
transition: all 0.1s ease;
}
.list a:hover {
color: #4000a7;
}
import styles from './Detail.module.css';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import MovieDetail from '../components/MovieDetail';
import Load from '../components/Load';
const Detail = () => {
const { id } = useParams();
const [loading, setLoading] = useState(true);
const [movie, setMovie] = useState([]);
const getMovie = useCallback(async () => {
setLoading(true);
const json = await (
await fetch(`https://yts.mx/api/v2/movie_details.json?movie_id=${id}`)
).json();
setLoading(false);
setMovie(json.data.movie);
}, [id]);
useEffect(() => {
getMovie();
}, [getMovie]);
return (
<div className={styles.container}>
{loading ? (
<Load />
) : (
<MovieDetail
key={movie.id}
id={movie.id}
coverImg={movie.medium_cover_image}
rating={movie.rating}
runtime={movie.runtime}
description_full={movie.description_full}
background_image_original={movie.background_image_original}
title={movie.title}
genres={movie.genres}
/>
)}
</div>
);
};
export default Detail;
.container {
z-index: 1;
}
우선 상태관리를 위해 recoil 라이브러리를 썼는데, 이게 맞나 싶다.
그냥 모듈로 쓴것 같아서 뭔가 찝찝하다.
우선 초기 버전일 뿐이고, 계속 수정해갈 예정이다.
그리고 중간에 Load 컴포넌트가 계속해서 렌더링이 안되는 에러가 발생했었다.
내가 getMovies()가 비동기 async/await 함수임을 까먹고 먼저 setLoading(false)를 해버려서
그런 것이였다.
차분히 console.log 찍어보고 다행히 해결했다. good👍
또, getMovies 함수를 useCallback으로 감싸주고 useEffect에 넣어줘야 에러가 안발생한다.
es-lint는 너무 엄격해서 화난다. (..?)
더 화려한 CSS 효과 + UI 개선
recoil 라이브러리 기능 추가
search 페이지 추가
-> 사실 day01에서 서치 기능을 추가하려 했으나,
우선 ui적으로도 헤더에 자리가 없기도 하고 해서 제외시켰다.
나중에 서치 기능도 추가할 예정. (이것도 useParams로 구현 가능)