📝 DAY 04- 220521
- 포스트 조회 기능 구현
- 포스트 읽기 페이지 (UI)
- API 연동
components/post/PostViewer.js 생성
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const PostViewerBlock = styled(Responsive)`
margin-top: 4rem;
`;
const PostHead = styled.div`
border-bottom: 1px solid ${palette.gray[2]};
padding-bottom: 3rem;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
line-height: 1.5;
margin: 0;
}
`;
const SubInfo = styled.div`
margin-top: 1rem;
color: ${palette.gray[6]};
span + span::before {
color: ${palette.gray[5]};
padding-left: 0.3rem;
padding-right: 0.3rem;
content: '\\B7';
}
`;
const Tags = styled.div`
margin-top: 0.5rem;
.tag {
display: inline-block;
color: ${palette.teal[7]};
text-decoration: none;
margin-right: 0.5rem;
&:hover {
color: ${palette.teal[6]};
}
}
`;
const PostContent = styled.div`
font-size: 1.3rem;
color: ${palette.gray[8]};
`;
const PostViewer = () => {
return (
<PostViewerBlock>
<PostHead>
<h1>제목</h1>
<SubInfo>
<span>
<b>tester</b>
</span>
<span>{new Date().toLocaleDateString()}</span>
</SubInfo>
<Tags>
<div className="tag">태그1</div>
<div className="tag">태그2</div>
<div className="tag">태그3</div>
</Tags>
</PostHead>
<PostContent
dangerouslySetInnerHTML={{ __html: '<p>HTML <b>내용</b> 입니다.</p>' }}
/>
</PostViewerBlock>
);
};
export default PostViewer;
🔺 dangerouslySetInnerHTML 이란?
브라우저 DOM에서 innerHTML을 사용하기 위한 React의 대체 방법으로, 직접적으로 HTML을 삽입할 수 있다.
->__html
키로 객체를 전달.
하지만, 일반적으로 코드에서 HTML를 설정하는 것은 XSS 공격에 쉽게 노출될 수 있기 때문에 위험하다.
✅ 참고 - toLocaleString 차이 (String / DateString / TimeString)
pages/PostPage.js 수정
import HeaderContainer from '../containers/common/HeaderContainer';
import PostViewer from '../components/post/PostViewer';
const PostPage = () => {
return (
<>
<HeaderContainer />
<PostViewer />
</>
);
};
export default PostPage;
http://localhost:3000/@tester/sampleid 에 접속하면 아래와 같은 UI가 나옴.
lib/api/posts.js 생성
import client from './client';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
export const readPost = (id) => client.get(`/api/posts/${id}`);
(지난번엔 writePost 함수를 작성했었음)
readPost 함수를 생성함.
modules/post.js 생성
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/createRequestSaga';
import * as postsAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const [READ_POST, READ_POST_SUCCESS, READ_POST_FAILURE] =
createRequestActionTypes('page/READ_POST');
const UNLOAD_POST = 'page/UNLOAD_POST';
export const readPost = createAction(READ_POST, (id) => id);
export const unloadPost = createAction(UNLOAD_POST);
// Saga 생성
const readPostSaga = createRequestSaga(READ_POST, postsAPI.readPost);
export function* postSaga() {
yield takeLatest(READ_POST, readPostSaga);
}
const initialState = {
post: null,
error: null,
};
const post = handleActions(
{
[READ_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
[READ_POST_FAILURE]: (state, { payload: error }) => ({
...state,
error,
}),
[UNLOAD_POST]: () => initialState,
},
initialState,
);
export default post;
액션
왜 UNLOAD_POST 액션이 필요한지?
- 만약 데이터를 비우지 않으면, 나중에 목록으로 돌아가 다른 포스트를 읽을 때, 이전에 불러왔던 포스트가 나타나는 깜빡임 현상이 일어남.
modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import post, { postSaga } from './post';
const rootReducer = combineReducers({
auth,
loading,
user,
post,
});
export function* rootSaga() {
yield all([authSaga(), userSaga(), postSaga()]);
}
export default rootReducer;
-> 리덕스 스토어와 연결해주기 위해 PostViewerContainer 컴포넌트를 생성.
containers/post/PostViewerContainer.js
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from '../../components/post/PostViewer';
import { useParams } from 'react-router';
const PostViewerContainer = ({ match }) => {
const { postId } = useParams();
const dispatch = useDispatch();
const { post, error, loading } = useSelector(({ post, loading }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
// loading - post/READ_POST : true 또는 false임
// createRequestSaga에서 startLoading, finishLoading에 의해 loading state에 type 필드가 생김
}));
useEffect(() => {
dispatch(readPost(postId)); // readPost에 postId(payload)를 전달하여 API 호출
return () => {
dispatch(unloadPost());
}; // return 안에는 정리함수 (componentWillUnmount)
}, [dispatch, postId]);
return <PostViewer post={post} loading={loading} error={error} />;
};
export default PostViewerContainer;
🙋♂️ 컨테이너에서 할일
1. useDispatch -dispatch(액션생성함수(payload))
-> useEffect 내부에서 (첫 렌더링시) API 요청
2. useSelector - 모듈에서 state 불러옴
3. 프레젠테이셔널 컴포넌트에게 Props로 값 전달해줌. (state에서 뽑아낸 값들)
참고 - withRouter 대신 useParams
- React-router-dom v6부터 withRouter이 사라짐. (HOC)
- 대신 hooks인 useParams()를 사용하면 됨.
- App.js에서 Route 컴포넌트에서 :postId와 같이 해주었고, 라우트 컴포넌트가 (=element) PostPage이다.
<Route path=":postId" element={<PostPage />} />
-> const { postId } = useParams() 를 하면 params를 얻을 수 있다!
import HeaderContainer from '../containers/common/HeaderContainer';
import PostViewerContainer from '../containers/post/PostViewerContainer';
const PostPage = () => {
return (
<>
<HeaderContainer />
<PostViewerContainer />
</>
);
};
export default PostPage;
props
로 넘겨준 post, error, loading을 사용하기.components/post/PostViewer.js
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const PostViewerBlock = styled(Responsive)`
margin-top: 4rem;
`;
const PostHead = styled.div`
border-bottom: 1px solid ${palette.gray[2]};
padding-bottom: 3rem;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
line-height: 1.5;
margin: 0;
}
`;
const SubInfo = styled.div`
margin-top: 1rem;
color: ${palette.gray[6]};
span + span::before {
color: ${palette.gray[5]};
padding-left: 0.3rem;
padding-right: 0.3rem;
content: '\\B7';
}
`;
const Tags = styled.div`
margin-top: 0.5rem;
.tag {
display: inline-block;
color: ${palette.teal[7]};
text-decoration: none;
margin-right: 0.9rem;
&:hover {
color: ${palette.teal[6]};
}
}
`;
const PostContent = styled.div`
font-size: 1.3rem;
color: ${palette.gray[8]};
`;
const PostViewer = ({ post, error, loading }) => {
if (error) {
if (error.response && error.response.status === 404) {
return <PostViewerBlock>존재하지 않는 포스트입니다.</PostViewerBlock>;
}
return <PostViewerBlock> 오류 발생</PostViewerBlock>;
}
if (loading || !post) {
return null;
}
const { title, body, user, publishedDate, tags } = post;
return (
<PostViewerBlock>
<PostHead>
<h1>{title}</h1>
<SubInfo>
<span>
<b>{user.username}</b>
</span>
<span>{new Date(publishedDate).toLocaleDateString()}</span>
</SubInfo>
<Tags>
{tags.map((tag) => (
<div className="tag">#{tag}</div>
))}
</Tags>
</PostHead>
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewerBlock>
);
};
export default PostViewer;
localhost:3000/write에 가서 새로운 글을 작성하고,
포스트 등록 버튼을 누르면 자동으로 해당 포스트 조회 url로 이동한다.
components/posts/PostList.js 생성
import styled from 'styled-components';
import Responsive from '../common/Responsive';
import Button from '../common/Button';
import palette from '../../lib/styles/palette';
const PostListBlock = styled(Responsive)`
margin-top: 3rem;
`;
const WritePostButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 3rem;
`;
const PostItemBlock = styled.div`
padding-top: 3rem;
padding-bottom: 3rem;
&:first-child {
padding-top: 0;
}
& + & {
border-top: 1px solid ${palette.gray[2]};
}
h2 {
font-size: 2rem;
margin-bottom: 0;
margin-top: 0;
&:hover {
color: ${palette.gray[6]};
}
}
p {
margin-top: 2rem;
}
`;
const SubInfo = styled.div`
color: ${palette.gray[6]};
span + span:before {
color: ${palette.gray[4]};
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const Tags = styled.div`
margin-top: 0.5rem;
color: ${palette.teal[7]};
text-decoration: none;
margin-right: 0.5rem;
&:hover {
color: ${palette.teal[6]};
}
`;
const PostItem = () => {
return (
<PostItemBlock>
<h2>제목</h2>
<SubInfo>
<span>
<b>username</b>
</span>
<span>{new Date().toLocaleDateString()}</span>
</SubInfo>
<Tags>
<div className="tag">#태그1</div>
<div className="tag">#태그2</div>
</Tags>
<p>포스트 내용 일부분 ...</p>
</PostItemBlock>
);
};
const PostList = () => {
return (
<PostListBlock>
<WritePostButtonWrapper>
<Button teal to="/write">
새 글 작성
</Button>
</WritePostButtonWrapper>
<div>
<PostItem />
<PostItem />
<PostItem />
</div>
</PostListBlock>
);
};
export default PostList;
-> 여기서 SubInfo, Tags는 PostViewer에서 사용한 것과 중복되므로,
components/common 디렉터리에 따로 빼준다.
components/common/SubInfo.js 생성
import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import palette from '../../lib/styles/palette';
// props에 따른 조건부 스타일링
const SubInfoBlock = styled.div`
${(props) =>
props.hasMarginTop &&
css`
margin-top: 1rem;
`}
color: ${palette.gray[6]};
span + span::before {
color: ${palette.gray[4]};
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const SubInfo = ({ username, publishedDate, hasMarginTop }) => {
return (
<SubInfoBlock hasMarginTop={hasMarginTop}>
<span>
<b>
<Link to={`/@${username}`}>{username}</Link>
</b>
</span>
<span>{new Date(publishedDate).toLocaleDateString()}</span>
</SubInfoBlock>
);
};
export default SubInfo;
components/common/Tags.js 생성
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import { Link } from 'react-router-dom';
const TagsBlock = styled.div`
margin-top: 0.5rem;
.tag {
display: inline-block;
color: ${palette.teal[7]};
text-decoration: none;
margin-right: 0.5rem;
&:hover {
color: ${palette.teal[6]};
}
}
`;
const Tags = ({ tags }) => {
return (
<TagsBlock>
{tags.map((tag) => (
<Link className="tags" to={`/?tag=${tag}`} key={tag}>
#{tag}
</Link>
))}
</TagsBlock>
);
};
export default Tags;
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
...
const { title, body, user, publishedDate, tags } = post;
return (
<PostViewerBlock>
<PostHead>
<h1>{title}</h1>
<SubInfo
username={user.username}
publishedDate={publishedDate}
hasMarginTop
/>
<Tags tags={tags} />
</PostHead>
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewerBlock>
);
};
export default PostViewer;
components/posts/PostList.js
import styled from 'styled-components';
import Responsive from '../common/Responsive';
import Button from '../common/Button';
import palette from '../../lib/styles/palette';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
const PostItem = () => {
return (
<PostItemBlock>
<h2>제목</h2>
// 🔻 SubInfo, Tags에 props로 데이터(임시)를 넣어줌
<SubInfo username="username" publishedData={new Date()} />
<Tags tags={['태그1', '태그2', '태그3']} />
<p>포스트 내용 일부분 ...</p>
</PostItemBlock>
);
};
...
pages/PostListPage.js 수정
import HeaderContainer from '../containers/common/HeaderContainer';
import PostList from '../components/posts/PostList';
const PostListPage = () => {
return (
<div>
<HeaderContainer />
<PostList />
</div>
);
};
export default PostListPage;
localhost:3000/에 접속하면 아래와 같이 PostList가 렌더링 된다.
lib/api/posts.js 수정
import client from './client';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
export const readPost = (id) => client.get(`/api/posts/${id}`);
// 🔻 listPosts 함수 추가
export const listPosts = ({ page, username, tag }) => {
return client.get('/api/posts', { params: { page, username, tag } });
// 파라미터로 값을 넣어준다면 - 해당 유저의 포스트 목록.
};
modules/posts.js 생성
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/createRequestSaga';
import * as postsAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const [LIST_POSTS, LIST_POSTS_SUCCESS, LIST_POSTS_FAILURE] =
createRequestActionTypes('posts/LIST_POSTS');
export const listPosts = createAction(
LIST_POSTS,
({ tags, username, page }) => ({ tags, username, page }),
);
// Saga 생성
const listPostsSaga = createRequestSaga(LIST_POSTS, postsAPI.listPosts);
export function* postsSaga() {
yield takeLatest(LIST_POSTS, listPostsSaga);
}
const initialState = {
posts: null,
error: null,
};
const posts = handleActions(
{
[LIST_POSTS_SUCCESS]: (state, { payload: posts }) => ({
...state,
posts,
}),
[LIST_POSTS_FAILURE]: (state, { payload: error }) => ({
...state,
error,
}),
},
initialState,
);
export default posts;
modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import write, { writeSaga } from './write';
import post, { postSaga } from './post';
import posts, { postsSaga } from './posts';
const rootReducer = combineReducers({
auth,
loading,
user,
post,
write,
posts,
});
export function* rootSaga() {
yield all([authSaga(), userSaga(), writeSaga(), postSaga(), postsSaga()]);
}
export default rootReducer;
containers/posts/PostListContainer.js 생성
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { listPosts } from '../../modules/posts';
import PostList from '../../components/posts/PostList';
import { useParams, useSearchParams } from 'react-router-dom';
const PostListContainer = () => {
const { username } = useParams();
const [searchParams] = useSearchParams();
const dispatch = useDispatch();
const { posts, error, loading, user } = useSelector(
({ posts, loading, user }) => ({
posts: posts.posts,
error: posts.error,
loading: loading['posts/LIST_POSTS'],
user: user.user, // 로그인 상태
}),
);
useEffect(() => {
const tag = searchParams.get('tag');
const page = parseInt(searchParams.get('page')) || 1;
dispatch(listPosts({ tag, page, username }));
}, [dispatch, searchParams, username]);
return (
<PostList
posts={posts}
error={error}
loading={loading}
showWriteButton={user}
/>
);
};
export default PostListContainer;
props로 받아온 값을 적절히 렌더링함.
posts -> posts.map(post => <PostItem post={post} />
로 보내줌pages/PostListPage.js 수정
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import Responsive from '../common/Responsive';
import Button from '../common/Button';
import palette from '../../lib/styles/palette';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
const PostListBlock = styled(Responsive)`
margin-top: 3rem;
`;
const WritePostButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 3rem;
`;
const PostItemBlock = styled.div`
padding-top: 3rem;
padding-bottom: 3rem;
&:first-child {
padding-top: 0;
}
& + & {
border-top: 1px solid ${palette.gray[2]};
}
h2 {
font-size: 2rem;
margin-bottom: 0;
margin-top: 0;
&:hover {
color: ${palette.gray[6]};
}
}
p {
margin-top: 2rem;
}
`;
const PostItem = ({ post }) => {
const { publishedData, user, tags, title, body, _id } = post;
return (
<PostItemBlock>
<Link to={`/@${user.username}/${_id}`}>{title}</Link>
<SubInfo
username={user.username}
publishedData={new Date(publishedData)}
/>
<Tags tags={tags} />
<p>{body}</p>
</PostItemBlock>
);
};
const PostList = ({ posts, loading, error, showWriteButton }) => {
return (
<PostListBlock>
<WritePostButtonWrapper>
{showWriteButton && (
<Button teal to="/write">
새 글 작성
</Button>
)}
</WritePostButtonWrapper>
{!loading && posts && (
<div>
{posts.map((post) => (
<PostItem post={post} key={post._id} />
))}
</div>
)}
</PostListBlock>
);
};
export default PostList;
다음 포스팅
- html 필터링 (sanitze-html)
- 페이지네이션 구현