next js에는 with-route-as-modal 이라는 예제가 있다.
react-modal을 활용하는 예제이다.
react-modal을 활용하는 방식에는 두가지가 있는데,
하나는 모달창으로 직접 라우팅을 시키는 방식이고,
import Link from 'next/link'
<Link
key={index}
href="/article/[articleId]"
as={`/article/${id}`}
className={styles.postCard}
>
{id}
</Link>
하나는 쿼리 스트링을 이용하여 모달이 위에 보이도록 하는 방식이다.
<Link
key={index}
href={`/?postId=${id}`}
as={`/post/${id}`}
className={styles.postCard}
>
{id}
</Link>
두번째 방식이 우리가 흔히 아는 모달창의 모습으로 띄워지게 되며,
띄워지는 속도도 빠르다.
하지만 새로고침하는 경우에 스타일이 깨지는 현상이 발생하며,
SSG를 사용하려는 목적이라면 첫번째 방식으로 '모달 처럼 보여지는 어떤 페이지'를 static하게 만들어야 한다.
영화 소개 페이지에서 해볼 것은 다음과 같다.
앞서 말했듯, static한 페이지를 이용하기 위해서는 동적 라우팅으로 구현해야 한다.
몇가지 삽질 끝에, 좋은 답변들을 구해서 next.js의 예제에 있는 모달을 수정하여 (여전히 로딩은 쿼리 방식에 비해 느리지만) 잘 작동하는 모습을 확인할 수 있었다.
기존 코드
<>
<Modal
isOpen={true} // The modal should always be shown on page load, it is the 'page'
onRequestClose={() => router.push("/")}
contentLabel="Post modal"
>
<Article id={articleId} pathname={router.pathname + "스태틱이당"} />
</Modal>
</>
바뀐 코드
<>
<Modal
isOpen={true} // The modal should always be shown on page load, it is the 'page'
onRequestClose={() => router.push("/")}
contentLabel="Post modal"
>
<Article id={articleId} pathname={router.pathname + "스태틱이당"} />
</Modal>
<Grid />
</>
index.js와 똑같게 'Grid'라는 컴포넌트가 삽입되었다.
scroll={false}
shallow={true}
를 넣어주면 된다. 페이지를 리렌더링 하지 않게되고, 스크롤도 유지된다.
<Link
key={index}
href="/article/[articleId]"
as={`/article/${id}`}
className={styles.postCard}
scroll={false}
shallow={true}
>
{id}
</Link>
route.push는 scroll이 true가 기본값이며, route의 세번째 인자로 {scroll: false}를 주면 된다.
<>
<Modal
isOpen={true} // The modal should always be shown on page load, it is the 'page'
onRequestClose={() => router.push("/", undefined, { scroll: false })}
contentLabel="Post modal"
>
<Article id={articleId} pathname={router.pathname + "스태틱이당"} />
</Modal>
<Grid />
</>
기타 예제
https://github.com/toomuchdesign/next-use-contextual-routing
인스타그램 클론코딩에 contextual-routing을 꽤나 까다롭게 구현한 예시이다.
[Hooks] 모달 띄웠을 때 스크롤을 막는 방법
모달창에서 스크롤이 발생할 때에 모달창 내의 스크롤이 이동하도록 하는 예제이다.
return (
<Link
key={id}
href="/movie/[movieId]"
as={`/article/${id}`}
scroll={false}
shallow={true}
>
<figure ref={imgRef}>
...
</figure>
</Link>
)
export default MovieRowContent;
pages/movie/[movieId].tsx
에 동적으로 생성할 페이지를 만든다.import { Movie } from "../../types/moive";
ReactModal.setAppElement("#__next");
interface iProps {
movie: Movie;
}
const MovieDetailPage = ({ movie }: iProps) => {
const router = useRouter();
useEffect(() => {
router.prefetch("/main");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<ReactModal
isOpen={true} // The modal should always be shown on page load, it is the 'page'
onRequestClose={() =>
router.push("/main", undefined, { scroll: false })
}
contentLabel="Movie modal"
>
<MovieDetail movie={movie} />
</ReactModal>
</>
);
};
router.prefetch(url)은 미리 로딩해둘 페이지의 주소를 의미한다.
여기서 고민...
'movie' 객체를 어디에서 받아올 것인가...
MovieRowContent에서 데이터를 따로 빼서 movie 객체로 넘겨주는 방식이 있을 것이다.
하지만 본 모달페이지가 isg도 아닌 ssg로 작동할 것을 고려하여 그냥 서버에서 받아오기로 한다.
export async function getStaticProps({ params: { movieId } }) {
const { data: movie } = await getMovieDetail(movieId);
return { props: { movieId: movieId, movie: movie } };
}
export function getStaticPaths() {
return {
paths: [{ params: { movieId: "176762" } }],
fallback: true,
};
}
getMovieDetail은 tmdb에 https://api.themoviedb.org/3/movie/${movieId}?language=ko-kr
이라는 요청을 보내는 axios 요청이다. 우리가 필요로하는 영화 정보는 res.data에 담겨서 반환된다. async, await와 구조분해할당을 이용하여 movie라는 식별자로 data를 받아주었다.
Dynamic Routing과 SSG를 동시에 사용할 때에는 getStaticPaths를 설정하라고 경고를 띄운다.
176762는 앤트맨과 와스프 퀀터매니아의 movie id이다.
fallback(대체) 옵션은 3가지이다.
가령
export function getStaticPaths() {
return {
paths: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(e => {
return { params: { movieId: e.toString() } }
}
fallback: false,
};
}
위와 같이 작성하였다면, path가 0이거나 11인 경우 등에는 404 페이지로 이동시킨다.
fallback: 'blocking'
build타임에 만들어지지 않은 스태틱 페이지는 모두 SSR로 처리한다.
fallback: true
build타임에 만들어지지 않은 스태틱 페이지에 대해서 새로운 정적 페이지를 생성한다.
fallback: true를 했을 때의 문제는 내가 예측하지 못하는 경로로 유저가 접속할 수 있다는 점이다.
가령 tmdb는 0번과 1번의 movieId에 해당하는 영화가 없다.
이 경우 axios failed with 404 에러가 뜨면서 작동을 멈춘다.
이러한 상황에는 return값에 {notFound: true} 를 주면 된다.
공식문서 data fetching 참고
export async function getStaticProps({ params: { movieId } }) {
const { data: movie } = await getMovieDetail(movieId).catch(() => ({
data: null,
}));
if (!movie) {
return {
notFound: true,
};
}
return { props: { movieId: movieId, movie: movie } };
}
위 코드가 에러를 catch해서 억지로 data 객체를 반환해서 지저분한데,
try-catch로 묶은 후, catch 상황에서 return을 할 수도 있다.여기 참조했음
import React from "react";
import StyledModalCard from "../styles/StyledModalCard";
const MovieDetail = ({ movie }) => {
console.log(movie);
return (
<>
<StyledModalCard>
<pre>
{`
{
adult: ${movie.adult},
backdrop_path: ${movie.backdrop_path},
belongs_to_collection: ${movie.belongs_to_collection},
budget: ${movie.budget},
genres: ${JSON.stringify(movie.genres)},
homepage: ${movie.homepage},
id: ${movie.id},
imdb_id: ${movie.imdb_id},
original_language: ${movie.original_language},
original_title: ${movie.original_title},
overview: ${movie.overview}',
popularity: ${movie.popularity}',
poster_path: ${movie.poster_path},
production_companies: ${JSON.stringify(movie.production_companies)},
production_countries: ${JSON.stringify(movie.production_countries)},
release_date: ${movie.release_date},
revenue: ${movie.revenue},
runtime: ${movie.runtime},
spoken_languages: ${JSON.stringify(movie.spoken_languages)},
status: ${movie.status},
tagline: ${movie.tagline},
title: ${movie.title},
video: ${movie.video},
vote_average: ${movie.vote_average},
vote_count: ${movie.vote_count},
}
`}
</pre>
</StyledModalCard>
</>
);
};
export default MovieDetail;
테스트를 위해 movie 객체를 받아서 data 값을 템플릿 리터럴을 이용해서 그대로 보여주고 있다.
이때 꿀팁 1) 배열, 객체 등은 JSON.stringfy로 감싸서 보여주는게 제일 간단하다. 2) pre태그로 감싸면 white-space속성이 변환되어서 템플릿 리터럴의 엔터키가 그대로 먹힌다.
이렇게 했더니 매우 심각한 디자인의 페이지가 나온다.
ReactModal 디자인 방법을 찾아보며 디자인을 해보자.
영화 디테일 페이지는 기존 프로젝트에서 디자인이 부족했던 페이지 중 하나이다.
다시 디자인 컨셉을 잡고 디자인하고자 한다.
Valeria Zharova - Movie page concept - dribbble
문자만으로 이루어진 심플한 디자인이다. 이미 만들어두었던 랜딩 페이지 디자인과 결합이 가능할 것 같다.
George Mironidis - Daily UI Movie Card - Codepen
버티컬 디자인이라 반응형으로 대응되는 모달로 활용하기 적합해보인다. body에 그레디언트가 들어간 점이 랜딩페이지와도 어울린다.
Cheryl Codes - JS Movie Database - Codepen
심플하면서도 백드롭필터가 인상적인 디자인이다. 백드롭 필터를 이용해 이미지 최적화가 가능한 점, 현재 사용중인 영화 디테일과 결합이 가능한 점, 반응형 대응이 잘 되어 있는 점 등이 눈길을 끈다.
Kotla Soma Srinivasu - Movie Card UI - Codepen
매우 심플한 타이포그래피 만으로도 예쁘게 잘 뽑혀 나왔다. 반응형 대응은 다소 부족하다.
Steve - Breaking Bad Movie Card - Codepen
위와 마찬가지로 심플한 타이포그래피 디자인이다. 반응형도 지원되며, 모바일에서는 디자인이 달라지는 특징이 있다.
<StyledModal
isOpen={true}
onRequestClose={() =>
router.push("/main", undefined, { scroll: false })
}
contentLabel="Post modal"
<StyledModal
isOpen={true}
onRequestClose={() =>
router.push("/main", undefined, { scroll: false })
}
contentLabel="Post modal"
style={{
overlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.75)'
},
content: {
position: 'absolute',
top: '40px',
left: '40px',
right: '40px',
bottom: '40px',
border: '1px solid #ccc',
background: '#fff',
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
borderRadius: '4px',
outline: 'none',
padding: '20px'
}
}}
>
<MovieDetail />
</StyledModal>
리액트 모달은 위와 같이 기본 스타일을 제공한다. style에 overlay와 content 프로퍼티가 있으며, 각각의 프로퍼티에 각각의 스타일 객체를 전달해주면 된다.
위에서 말한 프로퍼티는 각각
ReactModal__Overlay
ReactModal__Content
라는 클래스명을 갖는다.
아래와 같은 방식으로 body와 html 태그에 overflow: hidden을 주어 스크롤을 방지할 수 있다.
.ReactModal__Body--open,
.ReactModal__Html--open {
overflow: hidden;
}
ReactModal의 스타일을 상속받아 styledComponents를 만들 수 있다.
이때 기본적으로 content 클래스에 대한 스타일을 지정할 수 있다.
const StyledModal = styled(ReactModal)`
/* background-color: black; */
border: 1px solid black;
border-radius: 5px;
outline: none;
padding: 0px;
position: sticky;
width: 50vw;
height: 30vw;
overflow: hidden;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
`;
본 프로젝트에서 content 스타일은 오버라이딩하여 구현하고,
overlay는 인라인으로 구현했다... 지저분하긴 하지만
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "unset";
};
}, []);
return (
<>
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: "url('/main.jpg')",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
}}
>
<StyledModal
isOpen={true}
onRequestClose={() =>
router.push("/main", undefined, { scroll: false })
}
contentLabel="Post modal"
style={{
overlay: {
background: "rgba(255, 0, 0, 0)",
position: "absolute",
top: 0,
left: 0,
backdropFilter: "blur(30px)",
width: "100%",
height: "500%",
},
}}
>
<MovieDetail/>
</StyledModal>
</div>
</>
);
};
export default MovieDetailPage;
const StyledModal = styled(ReactModal)`
/* background-color: black; */
border: 1px solid black;
border-radius: 5px;
outline: none;
padding: 0px;
position: sticky;
width: 50vw;
height: 30vw;
overflow: hidden;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
`;
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "unset";
};
}, []);
는 모달창이 마운트 됐을 때 스크롤을 방지한다. 스크롤은 방지되지만 스크롤의 위치는 기억된다.
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: "url('/main.jpg')",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
}}
>
백그라운드 이미지를 지정해주는 div 태그다. main.jpg는 메인 화면을 캡처한 후 저화질로 만든 2kb정도의 이미지다.
<StyledModal
isOpen={true}
onRequestClose={() =>
router.push("/main", undefined, { scroll: false })
}
contentLabel="Post modal"
style={{
overlay: {
background: "rgba(255, 0, 0, 0)",
position: "absolute",
top: 0,
left: 0,
backdropFilter: "blur(30px)",
width: "100%",
height: "500%",
},
}}
>
리액트 모달 스타일의 Overlay에서 backdropFilter를 준다. 이렇게 하면 배경 이지미자 blur된다.
<MovieDetail/>
이 사이에 MovieDetail을 넣는다.
MovieDetail은 StyledModal의 스타일을 따르고 있다.
이런 저런 삽질 끝에 완성된 디자인..
넣고 싶은 기능이 많았는데 포기포기;
부트스트랩을 쓰려다가, 답답해서 순수 css로 만들었다.
반응형을 위해서 StyledModal도 수정했다.
전체적인 디자인 요소는 랜딩 페이지 디자인에 사용된 요소들을 이용했다.
스타일드 컴포넌트를 재사용하고 싶었으나, 안타깝게도 달라지는 부분이 너무 많아서...
디자인에 대한 자세한 정보는 생략한다...!
추가로 해당 디자인에는 고화질 backdrop 이미지와 poster가 필요하다.
<Link
key={id}
href="/movie/[movieId]"
as={`/movie/${id}`}
scroll={false}
shallow={true}
onMouseEnter={() => {
const imageBackdrop = new Image();
const imagePoster = new Image();
imageBackdrop.src = `https://image.tmdb.org/t/p/original/${movie.backdrop_path}`;
imagePoster.src = `https://image.tmdb.org/t/p/w300/${movie.poster_path}`;
return { imageBackdrop, imagePoster };
}}
>
mouseEnter는 링크 태그 위에 마우스가 들어왔다는 것을 의미하는데,
이 때에 새로운 이미지 객체를 만들어서 이미지를 프리로딩 한다.
개발 환경일 때에는 느린데, 위처럼 build가 되고 난 다음에는 (실제 모달보다는 느리지만) 나름 빠르고 자연스럽게 작동하는 것을 확인할 수 있다.
다른 기능들도 많이 넣고 싶었지만, 카드 글쓰기 기능만 컴포넌트를 재사용해서 넣는 것으로...