게시글 상세 페이지를 만들어보도록 하겠습니다.
빌려주세요 타입과 빌려줄게요 타입에 관한 게시글 뷰어 두 가지 형태를 구현하겠습니다.
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('post/READ_POST');
const UNLOAD_POST = 'post/UNLOAD_POST';
export const readPost = createAction(READ_POST, postId => postId);
export const unloadPost = createAction(UNLOAD_POST);
const readPostSaga = createRequestSaga(READ_POST, postsAPI.readPostById);
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;
이전에 만들었던 posts 모듈과 비슷한 구조입니다. 다만 다른 액션이 있다면 unloadPost인데요, 이 액션 없이 페이지에서 나가게 되면 state값에 이전 게시글 상태가 존재하게 되므로 이를 방지하기 위해 state를 초기화해주는 액션입니다.
import { combineReducers } from "redux";
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import user, { userSaga } from "./user";
import write, { writeSaga } from "./write";
import post, { postSaga } from "./post";
import loading from './loading';
const rootReducer = combineReducers(
{
loading,
auth,
user,
write,
post,
},
);
export function* rootSaga() {
yield all([authSaga(), userSaga(), writeSaga(), postSaga()]);
}
export default rootReducer;
이렇게 리덕스 모듈, 사가를 만들었으니 이를 이용할 페이지를 구성해보도록 하겠습니다.
다음의 라이브러리를 설치하도록 하겠습니다.
npm install react-responsive-carousel
import React from 'react';
import HeaderTemplate from '../components/common/HeaderTemplate';
const PostDetailPage = () => {
return(
<>
<HeaderTemplate />
</>
);
};
export default PostDetailPage;
import React from 'react';
import { Route } from 'react-router-dom';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import MyPage from './pages/MyPage';
import PostDetailPage from './pages/PostDetailPage';
import PostPage from './pages/PostPage';
import RegisterPage from './pages/RegisterPage';
import WritePage from './pages/WritePage';
const App = () => {
return(
<>
<Route
component={ HomePage }
path="/"
exact
/>
<Route
component={ MyPage }
path="/user/my-account"
exact
/>
<Route
component={ LoginPage }
path="/auth/login"
exact
/>
<Route
component={ RegisterPage }
path="/auth/register"
exact
/>
<Route
component={ PostPage }
path="/posts"
exact
/>
<Route
component={ WritePage }
path="/posts/write"
exact
/>
<Route
component={ PostDetailPage }
path="/posts/post/:postId"
/>
</>
);
};
export default App;
위와 같이 postId를 경로에 넣어 해당 게시글로 이동할 수 있게 라우팅을 진행하겠습니다. post-service를 구동하지 않은 상태이지만 임의의 값을 넣어도 페이지 이동이 가능합니다.
게시글을 볼 수 있는 뷰어 컴포넌트를 만들도록 하겠습니다.
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
import FullButton from '../common/FullButton';
import Shortcut from '../common/Shortcut';
import paper_plane_outline from '../../static/img/paper-plane-outline.svg';
import CommentContainer from '../comment/CommentContainer';
import ImageSlider from '../common/ImageSlider';
const PostViewerBlock = styled.div`
padding-top: 200px;
margin-left: 10rem;
margin-right: 10rem;
`;
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`
width: 80%;
margin-top: 1rem;
color: ${ palette.gray[6] };
span + span:before {
color: ${ palette.gray[5] };
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const MessageArea = styled.div`
float: left;
display: flex;
margin-right: 50px;
align-items: center;
`;
const RentalArea = styled.div`
float: left;
display: flex;
align-items: center;
`;
const PostArticle = styled.div`
display: flex;
justify-content: center;
align-item: center;
width: 100%;
border-bottom: 1px solid ${ palette.gray[2] };
`;
const PostContent = styled.div`
font-size: 1.3125rem;
color: ${ palette.gray[8] };
`;
const PostNav = styled.div`
display: flex;
justify-content: flex-end;
width: 100%;
border-bottom: 1px solid ${ palette.gray[2] };
`;
const RentalButton = styled(FullButton)`
width: 100px;
&:hover {
width: 100px;
}
`;
const StyledShorcut = styled(Shortcut)`
color: ${palette.blue[4]};
`;
const PostViewer = ({
post,
error,
loading
}) => {
const dummyData = [
{ "images": "https://picsum.photos/id/0/1000/1000.jpg" },
{ "images": "https://picsum.photos/id/1/1000/1000.jpg" },
{ "images": "https://picsum.photos/id/2/1000/1000.jpg" },
{ "images": "https://picsum.photos/id/3/1000/1000.jpg" },
{ "images": "https://picsum.photos/id/4/1000/1000.jpg" },
]
// if(error) {
// if(error.response && error.response.status === 404) {
// return <PostViewerBlock>존재하지 않는 포스트입니다.</PostViewerBlock>
// }
// return <PostViewerBlock>오류 발생!</PostViewerBlock>
// }
// if(loading || !post) {
// return null;
// }
return(
<PostViewerBlock>
<PostHead>
<h1>title</h1>
<SubInfo>
<span>
<b>writer</b>
</span>
<span>
{/* { post.createdAt } */}
</span>
</SubInfo>
</PostHead>
<PostArticle>
<ImageSlider Images={ dummyData }/>
</PostArticle>
<PostContent
// dangerouslySetInnerHTML={{ __html: post.content }}
/>
{
// post.type === '빌려줄게요' &&
<PostNav>
<MessageArea>
<StyledShorcut
path='/messages'
src={ paper_plane_outline }
/>
</MessageArea>
<RentalArea>
<Link to={{
pathname: '/rentals',
state: {
post: post
}}
}
>
<RentalButton>
빌리기
</RentalButton>
</Link>
</RentalArea>
</PostNav>
}
<CommentContainer />
</PostViewerBlock>
);
};
export default PostViewer;
이미지 슬라이더를 위한 더미 데이터를 만들었고 이 더미 데이터를 사용할 수 있도록 이미지 슬라이더를 만들고, 댓글창도 동시에 구현해보도록 하겠습니다.
import React from 'react';
import styled from 'styled-components';
const ImageBlock = styled.div`
width: 100%;
height: 60%;
`;
const Image = styled.img`
`;
const ImageCard = ({ image, i }) => {
return(
<ImageBlock>
<Image
src={ image.images }
alt="legend"
/>
</ImageBlock>
);
};
export default ImageCard;
import React from 'react';
import { Carousel } from 'react-responsive-carousel';
import "react-responsive-carousel/lib/styles/carousel.min.css";
import styled from 'styled-components';
import ImageCard from './ImageCard';
const SliderBlock = styled.div`
width: 500px;
`;
const ImageSlider = ({ Images }) => {
return(
<SliderBlock>
<Carousel showArrows={ true }>
{
Images.map((image, i) => {
return <ImageCard
image={ image }
i={ i }
/>
})
}
</Carousel>
</SliderBlock>
);
};
export default ImageSlider;
댓글 리덕스 모듈도 같이 만들어보도록 하겠습니다.
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 INITIALIZE = 'comment/INITIALIZE';
const CHANGE_FIELD = 'comment/CHANGE_FIELD';
const [
COMMENT,
COMMENT_SUCCESS,
COMMENT_FAILURE,
] = createRequestActionTypes('comment/WRITE_COMMENT');
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value
}));
export const write = createAction(COMMENT, ({
comment,
writer,
postId
}) => ({
comment,
writer,
postId
}));
const writeSaga = createRequestSaga(COMMENT, postsAPI.writeComment);
export function* writeCommentSaga() {
yield takeLatest(COMMENT, writeSaga);
}
const initialState = {
comment: '',
writer: '',
postId: '',
success: '',
failure: '',
};
const writeComment = handleActions(
{
[INITIALIZE]: state => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
...state,
[key]: value,
}),
[COMMENT]: state => ({
...state,
success: null,
failure: null,
}),
[COMMENT_SUCCESS]: (state, { payload: success }) => ({
...state,
success,
}),
[COMMENT_FAILURE]: (state, { payload: failure }) => ({
...state,
failure,
}),
},
initialState,
);
export default writeComment;
post-service에서 댓글 쓰기에 대한 반환 값을 댓글의 id값으로 했으므로 전체 상태를 담을 필요가 없어 payload값을 success, failure에 담도록 하겠습니다.
import { combineReducers } from "redux";
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import user, { userSaga } from "./user";
import write, { writeSaga } from "./write";
import post, { postSaga } from "./post";
import postList, { postListSaga } from "./postList";
import writeComment, { writeCommentSaga } from "./writeComment";
import loading from './loading';
const rootReducer = combineReducers(
{
loading,
auth,
user,
write,
post,
postList,
writeComment
},
);
export function* rootSaga() {
yield all([
authSaga(),
userSaga(),
writeSaga(),
postSaga(),
postListSaga(),
writeCommentSaga()
]);
}
export default rootReducer;
import React from 'react';
import Input from '../common/Input';
const WriteBar = ({ onChange }) => {
return <Input name="writeBar"
type="text"
placeholder="댓글을 입력해주세요"
onChange={ onChange }
/>;
};
export default WriteBar;
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
import writeComment from '../../modules/writeComment';
const ButtonBlock = styled.div`
width: 60px;
float: left;
`;
const Button = styled.button`
width: 60px;
height: 40px;
border-radius: 4px;
background-color: ${ palette.blue[1] };
color: #ffffff;
outline: none;
border: none;
&: hover {
width: 60px;
height: 40px;
border-radius: 4px;
background-color: ${ palette.blue[2] };
color: #ffffff;
outline: none;
border: none;
}
`;
const WriteButton = () => {
const dispatch = useDispatch();
const {
comment,
writer,
postId,
} = useSelector(({
writeComment,
user,
post,
}) => ({
comment: writeComment.comment,
writer: user.nickname,
postId: post.postId
}));
const onSubmit = e => {
e.preventDefault();
dispatch(writeComment({ postId, comment, writer }));
};
return (
<ButtonBlock>
<Button onClick={ onSubmit }>
댓글 달기
</Button>
</ButtonBlock>
);
};
export default WriteButton;
import React from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { changeField } from '../../modules/writeComment';
import WriteBar from './WriteBar';
import WriteButton from './WriteButton';
const WriteContainerBlock = styled.div`
width: 100%;
display: flex;
align-items: center;
margin-bottom: 1.5rem;
`;
const WriteContainer = () => {
const dispatch = useDispatch();
const onChange = e => {
e.preventDefault();
const { name, value } = e.target;
dispatch(changeField({
key: name,
value
}));
};
return (
<WriteContainerBlock>
<WriteBar onChange={ onChange }/>
<WriteButton />
</WriteContainerBlock>
);
};
export default WriteContainer;
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
const ItemBlock = styled.div`
width: 100%;
border-bottom: 1px solid ${ palette.gray[2] };
margin-bottom: 1rem;
padding-bottom: 0.5rem;
`;
const Header = styled.div`
float: left;
width: 100%;
display: flex;
align-items: center;
`;
const WriterBlock = styled.div`
font-size: 1.3rem;
font-weight: bold;
`;
const CreatedAtBlock = styled.div`
font-size: 0.8rem;
margin-left: 15px;
color: ${ palette.gray[6] }
`;
const Content = styled.div`
float: left;
margin-top: 1.5rem;
margin-bottom: 0.8125rem;
`;
const CommentItem = ({item, i}) => {
return(
<ItemBlock>
<Header>
<WriterBlock>
{ item.writer }
</WriterBlock>
<CreatedAtBlock>
{ item.createdAt }
</CreatedAtBlock>
</Header>
<Content>
{ item.comment }
</Content>
</ItemBlock>
);
};
export default CommentItem;
import React from 'react';
import styled from 'styled-components';
import CommentItem from './CommentItem';
const ListBlock = styled.div`
width: 100%;
display: flex;
flex-direction: column;
float: left;
`;
const CommentList = () => {
const dummyData = [
{"commentId": "1", "comment": "test-01", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "2", "comment": "test-02", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "3", "comment": "test-03", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "4", "comment": "test-04", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "5", "comment": "test-05", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "6", "comment": "test-06", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "7", "comment": "test-07", "createdAt": "2000-01-01", "writer": "test-01"},
{"commentId": "8", "comment": "test-08", "createdAt": "2000-01-01", "writer": "test-01"},
];
return(
<ListBlock>
{
dummyData.map((item, i) => {
return (
<CommentItem
item={ item }
i={ i }
/>
)
})
}
</ListBlock>
);
};
export default CommentList;
import React from 'react';
import styled from 'styled-components';
import CommentList from './CommentList';
import WriteContainer from './WriteContainer';
const CommentBlock = styled.div`
margin-top: 3rem;
`;
const CommentContainer = () => {
return(
<CommentBlock>
<WriteContainer />
<CommentList />
</CommentBlock>
);
};
export default CommentContainer;
여기까지가 PostViewer를 위한 컴포넌트 구성요소들입니다. 그러면 이 PostViewer를 PostViewerContainer에서 호출하고 리덕스 모듈을 이용하여 데이터를 가져오는 코드와 연결시키도록 하겠습니다.
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { withRouter } from 'react-router';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from './PostViewer';
const PostViewerContainer = ({ match }) => {
const { postId } = match.params;
const dispatch = useDispatch();
const { post, error, loading } = useSelector(({ post, loading }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
}));
useEffect(() => {
dispatch(readPost({ postId }));
return() => {
dispatch(unloadPost());
};
}, [dispatch, postId]);
return <PostViewer
post={ post }
loading={ loading }
error={ error }
/>;
};
export default withRouter(PostViewerContainer);
우선 테스트를 위해 리덕스 모듈에 관한 부분을 주석처리하고 페이지가 잘 작동하는지 확인해보도록 하겠습니다.
페이지가 잘 작동하는 것 같습니다. 테스트 페이지를 기준으로 해서 대략적으로 설명할 부분이 있는데 이미지 슬라이더의 경우 측면의 화살표, 하단의 네비게이션을 이용을 할 수 있습니다. 그리고 이미지 슬라이더 아래 컴포넌트에는 게시글 주인에게 메시지를 보낼 수 있으며, 빌리기 버튼을 통해 대여를 진행할 수 있습니다.
여기까지 포스트를 마치고 다음 포스트에서는 게시글 리스트를 볼 수 있는 페이지 디자인을 만들고 글쓰기와 post-service를 연동해보도록 하겠습니다.