PostActionButtons 컴포넌트
포스트를 읽는 화면에서 포스트 작성자에게만 포스트 상단에 수정 버튼과 삭제 버튼이 나타나도록 렌더링 해보겠습니다. 이 버튼의 style은 common Button스타일과 다르므로 그냥 만들도록 하겠습니다.
src/components/post/PostActionButtons.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
const PostActionButtonsBlock = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 2rem;
margin-top: -1.5rem;
`;
const ActionButton = styled.button`
padding: 0.25rem, 0.5rem;
border-radius: 4px;
color: ${palette.gray[6]};
border: none;
outline: none;
font-size: 0.875rem;
cursor: pointer;
&:hover {
background: ${palette.gray[1]};
color: ${palette.cyan[7]};
}
& + & {
margin-left: 0.25rem;
}
`;
const PostActionButtons = () => {
return (
<PostActionButtonsBlock>
<ActionButton>수정</ActionButton>
<ActionButton>삭제</ActionButton>
</PostActionButtonsBlock>
);
};
export default PostActionButtons;
이제 이를 PostViewer의 PostHead하단에 보여 주어야 합니다. 그런데 이 컴포넌트를 PostView에서 직접 렌더링하면, 나중에 PostActionButtons에 onEdit, onRemove등의 props를 전달할 때 무조건 PostView를 거쳐서 전달해야 합니다. 정작 PostView 내부에서는 사용하지 않지만 내부의 컴포넌트에서 필요하기 때문에 한 번 거쳐 전달하는 것은 조금 불안합니다.
이렇게 컴포넌트를 거쳐서 props를 전달하는 것이 싫다면, 그 대신 두 가지 방법을 고려할 수 있습니다. 첫 번째 방법은 PostActionButtons의 컨테이너 컴포넌트를 만들고 PostView내부에서 바로 렌더링하는 것입니다. 두 번째 방법은 props를 JSX형태로 받아 와서 렌더링하는 것입니다.
저는 두번째 방법으로 코딩해보도록 하겠습니다.
src/components/post/PostView.js
import React from 'react';
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 PostViewBlock = 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 PostContent = styled.div`
font-size: 1.3125rem;
color: ${palette.gray[8]};
`;
const PostView = ({ post, loading, error, actionButtons }) => {
// 에러 발생 시
if (error) {
if (error.response && error.response.status === 409) {
return <PostViewBlock>존재하지 않는 포스트입니다.</PostViewBlock>;
}
return <PostViewBlock>오류 발생!</PostViewBlock>;
}
// 로딩 중이거나 아직 포스트 데이터가 없을 때
if (loading || !post) {
return null;
}
const { title, body, user, publishedDate, tags } = post;
return (
<PostViewBlock>
<PostHead>
<h1>{title}</h1>
<SubInfo
username={user.username}
publishedDate={publishedDate}
hasMarginTop
/>
<Tags tags={tags} />
</PostHead>
{actionButtons}
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewBlock>
);
};
export default PostView;
src/containers/post/PostView.Container.js
/* eslint-disable no-unused-vars */
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostView from '../../components/post/PostView';
import { useParams } from 'react-router-dom';
import PostActionButtons from '../../components/post/PostActionButtons';
const PostViewContainer = () => {
// 처음 마운트 될 때 포스트 읽기 API요청
const { postId } = useParams();
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 (
<PostView
post={post}
loading={loading}
error={error}
actionButtons={<PostActionButtons />}
/>
);
};
export default PostViewContainer;
버튼이 정상적으로 나타난 것을 보실 수 있습니다.
수정 버튼 클릭 시 글쓰기 페이지로 이동하기
수정 버튼을 누르게 되면 글쓰기 페이지로 이동하고, 현재 보고 있는 포스트가 나타나게 해 볼 것입니다. 모듈에 SET_ORIGINAL_POST라는 액션을 만들겠습니다.
src/modules/write.js
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/api/createRequestSaga';
import * as postAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const INITIALIZE = 'write/INITIALIZE'; // 모든 내용 초기화
const CHANGE_FIELD = 'write/CHANGE_FIELD'; // 특정 key 값 바꾸기
const [WRITE_POST, WRITE_POST_SUCCESS, WRITE_POST_FAILURE] =
createRequestActionTypes('write/WRITE_POST'); // 포스트 작성
const SET_ORIGINAL_POST = 'write/SET_ORIGINAL_POST';
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value,
}));
export const writePost = createAction(WRITE_POST, ({ title, body, tags }) => ({
title,
body,
tags,
}));
export const setOriginalPost = createAction(SET_ORIGINAL_POST, (post) => post);
// 사가 생성
const writePostSaga = createRequestSaga(WRITE_POST, postAPI.writePost);
export function* writeSaga() {
yield takeLatest(WRITE_POST, writePostSaga);
}
const initialState = {
title: '',
body: '',
tags: [],
post: null,
postError: null,
originalPostId: null,
};
const write = handleActions(
{
[INITIALIZE]: (state) => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value } }) => ({
...state,
[key]: value,
}),
[WRITE_POST]: (state) => ({
...state,
// post와 postErro를 초기화
post: null,
postError: null,
}),
// 포스트 작성 성공
[WRITE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
// 포스트 작성 실패
[WRITE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
[SET_ORIGINAL_POST]: (state, { payload: { title, body, tags, _id } }) => ({
...state,
title,
body,
tags,
originalPostId: _id,
}),
},
initialState,
);
export default write;
src/containers/post/PostViewContainer.js
/* eslint-disable no-unused-vars */
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostView from '../../components/post/PostView';
import { useParams } from 'react-router-dom';
import PostActionButtons from '../../components/post/PostActionButtons';
import { setOriginalPost } from '../../modules/write';
import { useNavigate } from 'react-router-dom';
const PostViewContainer = () => {
const navigate = useNavigate();
// 처음 마운트 될 때 포스트 읽기 API요청
const { postId } = useParams();
const dispatch = useDispatch();
const { post, error, loading, user } = useSelector(
({ post, loading, user }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
user: user.user,
}),
);
useEffect(() => {
dispatch(readPost(postId));
// 언마운트될 떄 리덕스에서 포스트 데이터 없애기
return () => {
dispatch(unloadPost());
};
}, [dispatch, postId]);
const onEdit = () => {
dispatch(setOriginalPost(post));
navigate('/write');
};
const ownPost = (user && user._id) === (post && post.user._id);
return (
<PostView
post={post}
loading={loading}
error={error}
actionButtons={ownPost && <PostActionButtons onEdit={onEdit} />}
/>
);
};
export default PostViewContainer;
src/components/post/PostActionButtons.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
const PostActionButtonsBlock = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 2rem;
margin-top: -1.5rem;
`;
const ActionButton = styled.button`
padding: 0.25rem, 0.5rem;
border-radius: 4px;
color: ${palette.gray[6]};
border: none;
outline: none;
font-size: 0.875rem;
cursor: pointer;
&:hover {
background: ${palette.gray[1]};
color: ${palette.cyan[7]};
}
& + & {
margin-left: 0.25rem;
}
`;
const PostActionButtons = ({ onEdit }) => {
return (
<PostActionButtonsBlock>
<ActionButton onClick={onEdit}>수정</ActionButton>
<ActionButton>삭제</ActionButton>
</PostActionButtonsBlock>
);
};
export default PostActionButtons;
하지만 수정 버튼을 눌러보면 body의 내용이 채워지지 않은 것을 보실 수 있습니다. 그래서 내용의 초깃값도 넣어주기 위해 Editor컴포넌트를 수정해 주겠습니다.
src/components/write/Editor.js
import React, { useRef, useEffect } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.bubble.css';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const EditorBlock = styled(Responsive)`
/* 페이지 위 아래 여백 지정 */
padding-top: 5rem;
padding-bottom: 5rem;
`;
const TitleInput = styled.input`
font-size: 3rem;
outline: none;
padding-bottom: 0.5rem;
border: none;
border-bottom: 1px solid ${palette.gray[4]};
margin-bottom: 2rem;
width: 100%;
`;
const QuillWrapper = styled.div`
/* 최소 크기 지정 및 padding 제거 */
.ql-editor {
padding: 0;
min-height: 320px;
font-size: 1.125rem;
line-height: 1.5;
}
.ql-editor.ql-blank::before {
left: 0px;
}
`;
const Editor = ({ title, body, onChangeField }) => {
const quillElement = useRef(null); // Quill을 적용할 DivElement를 설정
const quillInstance = useRef(null); // Quill 인스턴스를 설정
useEffect(() => {
quillInstance.current = new Quill(quillElement.current, {
theme: 'bubble',
placeholder: '내용을 작성하세요...',
modules: {
// 더 많은 옵션
toolbar: [
[{ header: '1' }, { header: '2' }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['blockquote', 'code-block', 'link', 'image'],
],
},
});
const quill = quillInstance.current;
quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
onChangeField({ key: 'body', value: quill.root.innerHTML });
}
});
}, [onChangeField]);
useEffect(() => {
quillInstance.current.root.innerHTML = body;
}, []); /* eslint-disable-line */
const onChangeTitle = (e) => {
onChangeField({ key: 'title', value: e.target.value });
};
return (
<EditorBlock>
<TitleInput
placeholder="제목을 입력하세요"
onChange={onChangeTitle}
value={title}
/>
<QuillWrapper>
<div ref={quillElement} />
</QuillWrapper>
</EditorBlock>
);
};
export default Editor;
처음 마운터 될 때 한번만 body의 값으로 quillInstance의 innerHTML을 바꾸어 줍니다.
리덕스 스토어에도 값이 잘 들어가고 화면에도 잘 나타난 것을 볼 수 있습니다. 다음으로 write상태에 originalPostId값이 주어졌다면 포스트 작성 API대신 수정 API를 사용하는 기능을 구현해 보겠습니다.
src/lib/api/posts.js
import client from './client';
import qs from 'qs';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
export const readPost = (id) => client.get(`/api/posts/${id}`);
export const listPosts = ({ page, username, tag }) => {
const queryString = qs.stringify({
page,
username,
tag,
});
return client.get(`/api/posts?${queryString}`);
};
export const updatePost = ({ id, title, body, tags }) =>
client.patch(`/api/posts/${id}`, {
title,
body,
tags,
});
이제 write 리덕스 모듈에서 UPDATE_POST액션과 updatePostSaga를 만들어 주겠습니다.
src/modules/write.js
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/api/createRequestSaga';
import * as postAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const INITIALIZE = 'write/INITIALIZE'; // 모든 내용 초기화
const CHANGE_FIELD = 'write/CHANGE_FIELD'; // 특정 key 값 바꾸기
const [WRITE_POST, WRITE_POST_SUCCESS, WRITE_POST_FAILURE] =
createRequestActionTypes('write/WRITE_POST'); // 포스트 작성
const SET_ORIGINAL_POST = 'write/SET_ORIGINAL_POST';
const [UPDATE_POST, UPDATE_POST_SUCCESS, UPDATE_POST_FAILURE] =
createRequestActionTypes('write/UPDATE_POST');
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value,
}));
export const writePost = createAction(WRITE_POST, ({ title, body, tags }) => ({
title,
body,
tags,
}));
export const setOriginalPost = createAction(SET_ORIGINAL_POST, (post) => post);
export const updatePost = createAction(
UPDATE_POST,
({ id, title, body, tags }) => ({
id,
title,
body,
tags,
}),
);
// 사가 생성
const writePostSaga = createRequestSaga(WRITE_POST, postAPI.writePost);
const updatePostSaga = createRequestSaga(UPDATE_POST, postAPI.updatePost);
export function* writeSaga() {
yield takeLatest(WRITE_POST, writePostSaga);
yield takeLatest(UPDATE_POST, updatePostSaga);
}
const initialState = {
title: '',
body: '',
tags: [],
post: null,
postError: null,
originalPostId: null,
};
const write = handleActions(
{
[INITIALIZE]: (state) => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value } }) => ({
...state,
[key]: value,
}),
[WRITE_POST]: (state) => ({
...state,
// post와 postErro를 초기화
post: null,
postError: null,
}),
// 포스트 작성 성공
[WRITE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
// 포스트 작성 실패
[WRITE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
[SET_ORIGINAL_POST]: (state, { payload: { title, body, tags, _id } }) => ({
...state,
title,
body,
tags,
originalPostId: _id,
}),
[UPDATE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
[UPDATE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
},
initialState,
);
export default write;
src/containers/write/WriteActionButtonsContainer.js
import React, { useEffect } from 'react';
import WriteActionButton from '../../components/write/WriteActionButton';
import { useSelector, useDispatch } from 'react-redux';
import { writePost, updatePost } from '../../modules/write';
const WriteActionButtonContainer = ({ navigate }) => {
const dispatch = useDispatch();
const { title, body, tags, post, postError, originalPostId } = useSelector(
({ write: { title, body, tags, post, postError, originalPostId } }) => ({
title,
body,
tags,
post,
postError,
originalPostId,
}),
);
// 포스트 등록
const onPublish = () => {
if (originalPostId) {
dispatch(updatePost({ title, body, tags, id: originalPostId }));
} else {
dispatch(
writePost({
title,
body,
tags,
}),
);
}
};
// 취소
const onCancel = () => {
navigate(-1);
};
// 성공 혹은 실패 시 할 작업
useEffect(() => {
if (post) {
const { _id, user } = post;
navigate(`/@${user.username}/${_id}`);
}
if (postError) {
console.log(postError);
}
}, [navigate, post, postError]);
return (
<WriteActionButton
onPublish={onPublish}
onCancel={onCancel}
isEdit={!!originalPostId}
/>
);
};
export default WriteActionButtonContainer;
src/components/write/WriteActionButtons.js
import React from 'react';
import styled from 'styled-components';
import Button from '../common/Button';
const WriteActionButtonBlock = styled.div`
margin-top: 1rem;
margin-bottom: 3rem;
`;
const StyledButton = styled(Button)`
height: 2.125rem;
& + & {
margin-left: 0.5rem;
}
`;
const WriteActionButton = ({ onCancel, onPublish, isEdit }) => {
return (
<WriteActionButtonBlock>
<StyledButton cyan onClick={onPublish}>
포스트 {isEdit ? '수정' : '등록'}
</StyledButton>
<StyledButton onClick={onCancel}>취소</StyledButton>
</WriteActionButtonBlock>
);
};
export default WriteActionButton;
WriteActionButtonsContainer 컴포넌트에서는 originalPostId값이 존재하면 writePost대신 updatePost액션 생성 함수를 사용하도록 수정했습니다. 그리고 isEdit이라는 props를 전달하여 originalPostId값의 존재 유무에 따라 버튼 이름을 포스트 수정 또는 포스트 등록으로 설정해 주었습니다.
이와 같이 정상적으로 수정 된 것을 볼 수 있습니다.
포스트 삭제
마지막으로 구현할 프로젝트의 기능은 포스트 삭제입니다.
삭제 버튼을 누를 때 포스트를 바로 삭제하는 것이 아니라, 사용자의 확인을 한 번 더 요청하고 나서 삭제할 겁니다. 이를 위해선 모델 컴포넌트를 만들어야 합니다. 모달이란 페이지에 나타난 내용 위에 새 레이어로 어떠한 창을 보여 주는 것을 말합니다.
src/components/common/AskModal.js
import React from 'react';
import styled from 'styled-components';
import Button from './Button';
const Fullscreen = styled.div`
position: fixed;
z-index: 100;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
`;
const AskModalBlock = styled.div`
width: 320px;
background: white;
padding: 1.5rem;
border-radius: 4px;
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.125);
h2 {
margin-top: 0;
margin-bottom: 1rem;
}
p {
margin-bottom: 3rem;
}
.buttons {
display: flex;
justify-content: flex-end;
}
`;
const StyledButton = styled(Button)`
height: 2rem;
& + & {
margin-left: 0.75rem;
}
`;
const AskModal = ({
visible,
title,
description,
onCancel,
cancelContext = '취소',
onConfirm,
confirmText = '확인',
}) => {
if (!visible) return null;
return (
<Fullscreen>
<AskModalBlock>
<h2>{title}</h2>
<p>{description}</p>
<div className="buttons">
<StyledButton onClick={onCancel}>{cancelContext}</StyledButton>
<StyledButton cyan onClick={onConfirm}>
{confirmText}
</StyledButton>
</div>
</AskModalBlock>
</Fullscreen>
);
};
export default AskModal;
이를 기반으로 AskRemoveModal이라는 컴포넌트를 만듭니다.
src/components/post/AskRemoveModal.js
import React from 'react';
import AskModal from '../common/AskModal';
const AskRemoveModal = ({ visible, onConfirm, onCancel }) => {
return (
<AskModal
visible={visible}
onConfirm={onConfirm}
onCancel={onCancel}
description="포스트를 정말 삭제하시겠습니까?"
confirmText="삭제"
title="포스트 삭제"
/>
);
};
export default AskRemoveModal;
이제 이전에 작성했던 PostActionButton에 이 AskRemoveModal을 추가해 줍니다.
src/components/post/PostActionButton.js
import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import AskRemoveModal from './AskRemoveModal';
const PostActionButtonsBlock = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 2rem;
margin-top: -1.5rem;
`;
const ActionButton = styled.button`
padding: 0.25rem, 0.5rem;
border-radius: 4px;
color: ${palette.gray[6]};
border: none;
outline: none;
font-size: 0.875rem;
cursor: pointer;
&:hover {
background: ${palette.gray[1]};
color: ${palette.cyan[7]};
}
& + & {
margin-left: 0.25rem;
}
`;
const PostActionButtons = ({ onEdit, onRemove }) => {
const [modal, setModal] = useState(false);
const onRemoveClick = () => {
setModal(true);
};
const onCancel = () => {
setModal(false);
};
const onConfirm = () => {
setModal(false);
onRemove();
};
return (
<PostActionButtonsBlock>
<ActionButton onClick={onEdit}>수정</ActionButton>
<ActionButton onClick={onRemoveClick}>삭제</ActionButton>
<AskRemoveModal
visible={modal}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</PostActionButtonsBlock>
);
};
export default PostActionButtons;
또한 이제 onRemove를 가능하게 만들어 봅시다.
src/lib/api/posts.js
import client from './client';
import qs from 'qs';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
export const readPost = (id) => client.get(`/api/posts/${id}`);
export const listPosts = ({ page, username, tag }) => {
const queryString = qs.stringify({
page,
username,
tag,
});
return client.get(`/api/posts?${queryString}`);
};
export const updatePost = ({ id, title, body, tags }) =>
client.patch(`/api/posts/${id}`, {
title,
body,
tags,
});
export const removePost = (id) => client.delete(`/api/posts/${id}`);
만들어 준 api를 바탕으로 PostViewContainer에 삽입해 줍니다.
src/containers/post/PostViewContainer.js
/* eslint-disable no-unused-vars */
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostView from '../../components/post/PostView';
import { useParams } from 'react-router-dom';
import PostActionButtons from '../../components/post/PostActionButtons';
import { setOriginalPost } from '../../modules/write';
import { useNavigate } from 'react-router-dom';
import { removePost } from '../../lib/api/posts';
const PostViewContainer = () => {
const navigate = useNavigate();
// 처음 마운트 될 때 포스트 읽기 API요청
const { postId } = useParams();
const dispatch = useDispatch();
const { post, error, loading, user } = useSelector(
({ post, loading, user }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
user: user.user,
}),
);
useEffect(() => {
dispatch(readPost(postId));
// 언마운트될 떄 리덕스에서 포스트 데이터 없애기
return () => {
dispatch(unloadPost());
};
}, [dispatch, postId]);
const onEdit = () => {
dispatch(setOriginalPost(post));
navigate('/write');
};
const onRemove = async () => {
try {
await removePost(postId);
navigate('/');
} catch (e) {
console.log(e);
}
};
const ownPost = (user && user._id) === (post && post.user._id);
return (
<PostView
post={post}
loading={loading}
error={error}
actionButtons={
ownPost && <PostActionButtons onEdit={onEdit} onRemove={onRemove} />
}
/>
);
};
export default PostViewContainer;
이제 바로 렌더링 된 컴포넌트를 PostView에서 렌더링 합니다.
src/components/post/PostView.js
import React from 'react';
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';
import { Helmet } from 'react-helmet-async';
const PostViewBlock = 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 PostContent = styled.div`
font-size: 1.3125rem;
color: ${palette.gray[8]};
`;
const PostView = ({ post, loading, error, actionButtons }) => {
// 에러 발생 시
if (error) {
if (error.response && error.response.status === 409) {
return <PostViewBlock>존재하지 않는 포스트입니다.</PostViewBlock>;
}
return <PostViewBlock>오류 발생!</PostViewBlock>;
}
// 로딩 중이거나 아직 포스트 데이터가 없을 때
if (loading || !post) {
return null;
}
const { title, body, user, publishedDate, tags } = post;
return (
<PostViewBlock>
<Helmet>
<title>{title} - REACTERS</title>
</Helmet>
<PostHead>
<h1>{title}</h1>
<SubInfo
username={user.username}
publishedDate={publishedDate}
hasMarginTop
/>
<Tags tags={tags} />
</PostHead>
{actionButtons}
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewBlock>
);
};
export default PostView;
정상적으로 모달창이 만들어 진 것을 볼 수 있습니다.
react-helmet-async로 meta태그 설정하기
검색 엔진에서 웹 페이지를 수집할 때는 meta태그를 읽습니다. 이 meta태그를 리액트 앱에서 설정하는 방법을 알아봅니다.
src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter as Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer, { rootSaga } from './modules';
import createSagaMiddleware from '@redux-saga/core';
import { tempSetUser, check } from './modules/user';
import { HelmetProvider } from 'react-helmet-async';
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(sagaMiddleware)),
);
function loadUser() {
try {
const user = localStorage.getItem('user');
if (!user) return;
store.dispatch(tempSetUser(JSON.parse(user)));
store.dispatch(check());
} catch (e) {
console.log('localStorage is not working');
}
}
sagaMiddleware.run(rootSaga);
loadUser();
ReactDOM.render(
<Provider store={store}>
<Router>
<HelmetProvider>
<App />
</HelmetProvider>
</Router>
</Provider>,
document.getElementById('root'),
);
Helmet컴포넌트를 사용하면 됩니다.
src/App.js
import './App.css';
import PostListPage from './pages/PostListPage';
import RegisterPage from './pages/RegisterPage';
import LoginPage from './pages/LoginPage';
import Writepage from './pages/WritePage';
import PostPage from './pages/PostPage';
import { Routes, Route } from 'react-router';
import { Helmet } from 'react-helmet-async';
function App() {
return (
<div>
<Helmet>
<title>REACTERS</title>
</Helmet>
<Routes>
<Route path={'/@:username'} element={<PostListPage />} />
<Route path={'/'} element={<PostListPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/write" element={<Writepage />} />
<Route path="/@:username/:postId" element={<PostPage />} />
</Routes>
</div>
);
}
export default App;
다음과 같이 수정된 것을 보실 수 있습니다.
프로젝트 빌드하기
우선 백엔드 서버를 통해 리액트 앱을 제공할 수 있도록 빌드해 주어야 합니다.
$ yarn build
또한 서버를 통해 blog-frontend/build 디렉터리 안의 파일을 사용할 수 있도록 koa-static을 사용하여 정적 파일 제공 기능을 구현해 봅니다.
< KoaServer >
src/main.js
/* eslint-disable no-undef */
require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';
import serve from 'koa-static';
import path from 'path';
import send from 'koa-send';
const { PORT } = process.env;
// import createFakeData from './createFakeData';
mongoose
.connect('mongodb://hyunseo:gustj486!!@localhost:27017', {
dbName: 'blog',
})
.then(() => {
console.log('Connected to MongoDB');
// createFakeData();
})
.catch((e) => {
console.error(e);
});
import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';
const app = new Koa();
const router = new Router();
router.use('/api', api.routes());
app.use(bodyParser());
app.use(jwtMiddleware);
app.use(router.routes()).use(router.allowedMethods());
const buildDirectory = path.resolve(__dirname, '../../blog-frontend/build');
app.use(serve(buildDirectory));
app.use(async (ctx) => {
// Not Found이고, 주소가 /api로 시작하지 않는 경우
if (ctx.status === 404 && ctx.path.indexOf('/api') !== 0) {
// index.html의 내용을 반환
await send(ctx, 'index.html', { root: buildDirectory });
}
});
const port = PORT || 4000;
app.listen(port, () => {
console.log(`Listening to port ${port}`);
});
koa-static을 사용하여 blog-frontend/build디렉터리에 있는 파일들을 서버를 통해 조회할 수 있게 해 주었습니다. 추가로 하단에 send라는 함수를 사용하는 미들웨어를 작성했습니다. 이 미들웨어는 클라이언트 기반 라우팅이 제대로 작동하게 해 줍니다. HTTP 상태가 404이고 주소가 /api로 시작하지 않으면, index.html의 내용을 응답하였습니다
'React > ReactJs' 카테고리의 다른 글
React - FrontEnd Project - 5 (0) | 2022.03.20 |
---|---|
React - FrontEnd Project - 4 (0) | 2022.03.20 |
React - React Router v6 (0) | 2022.03.18 |
React - FrontEnd Project - 3 (0) | 2022.03.17 |
React - FrontEnd project - 2 (0) | 2022.03.15 |