📝 DAY 05 - 220522 (END)
- 포스트 수정 / 삭제 기능 구현
- 프로젝트 마무리
components/post/PostActionButtons.js
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]};
font-weight: 800;
border: none;
outline: none;
font-size: 0.875rem;
cursor: pointer;
&:hover {
background: ${palette.gray[1]};
color: ${palette.teal[7]};
}
& + & {
margin-left: 0.25rem;
}
`;
const PostActionButtons = () => {
return (
<PostActionButtonsBlock>
<ActionButton>수정</ActionButton>
<ActionButton>삭제</ActionButton>
</PostActionButtonsBlock>
);
};
export default PostActionButtons;
PostViewer의 PostHead 하단에 버튼이 렌더링되어야 함.
그러나 직접 PostViewer에 렌더링 시 props가 불필요하게 PostViewer에 전달될 수 있음.
✅ 해결방법
- PostActionButtons의 컨테이너를 만들어 PostViewer에 렌더링.
- PostViewer의 Props로 jsx를 직접 전달해줌.
-> props로 JSX(=컴포넌트)를 직접 전달해줄 수 있다.
컨테이너를 만들 필요가 없고, PostViewerContainer만 수정하면 되는 2번째 방법으로 구현할 것임.
// 🔻 PostActionButtons 임포트
import PostActionButtons from '../../components/post/PostActionButtons';
const PostViewerContainer = () => {
...
return (
<PostViewer
post={post}
loading={loading}
error={error}
// 🔻 actionButtons 라는 props로 컴포넌트 자체를 전달해줌.
actionButtons={<PostActionButtons />}
/>
);
};
export default PostViewerContainer;
// 🔻 actionButtons 라는 props를 받아옴
const PostViewer = ({ post, error, loading, actionButtons }) => {
...
return (
<PostViewerBlock>
<PostHead>
<h1>{title}</h1>
<SubInfo
username={user.username}
publishedDate={publishedDate}
hasMarginTop
/>
<Tags tags={tags} />
</PostHead>
// 🔻 actionButtons는 JSX 코드, 즉 값이므로 아래와 같이 렌더링 가능.
{actionButtons}
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewerBlock>
);
};
export default PostViewer;
TEST
- actionButtons props가 잘 렌더링 되어있음.
SET_ORIGINAL_POST
액션 추가. (기존 포스트 설정)modules/write.js 수정
// 🔻 Type 생성
const SET_ORIGINAL_POST = 'write/SET_ORIGINAL_POST';
// 🔻 액션 생성함수
export const setOriginalPost = createAction(SET_ORIGINAL_POST, (post) => post);
const initialState = {
title: '',
body: '',
tags: [],
post: null,
postError: null,
// 🔻 state 추가
originalPostId: null,
};
const write = handleActions(
{
...
[SET_ORIGINAL_POST]: (state, { payload: post }) => ({
...state,
title: post.title,
body: post.body,
tags: post.tags,
originalPostId: post._id,
}),
},
initialState,
);
export default write;
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from '../../components/post/PostViewer';
import { useParams, useNavigate } from 'react-router-dom';
import PostActionButtons from '../../components/post/PostActionButtons';
// 🔻 setOriginalPost 불러옴
import { setOriginalPost } from '../../modules/write';
const PostViewerContainer = () => {
const { postId } = useParams();
const dispatch = useDispatch();
const navigate = useNavigate();
// 🔻 state.user의 user필드를 가져옴
const { post, error, loading, user } = useSelector(
({ post, loading, user }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
user: user.user, // 로그인 상태 (user)
}),
);
useEffect(() => {
dispatch(readPost(postId)); // readPost에 postId(payload)를 전달하여 API 호출
return () => {
dispatch(unloadPost());
}; // return 안에는 정리함수 (componentWillUnmount)
}, [dispatch, postId]);
// 🔻 setOriginalPost를 해서 현재 글의 상태(post.post)를 그대로 가져오고 write로 이동
const onEdit = () => {
dispatch(setOriginalPost(post));
navigate('/write');
};
// 🔻 로그인상태(user._id) 와 글 작성자(post.user._id)가 일치하는지 확인.
const ownPost = (user && user._id) === (post && post.user._id);
return (
<PostViewer
post={post}
loading={loading}
error={error}
// 🔻 ownPost가 true면 (작성자라면) onEdit함수를 전달하여 JSX 전달.
actionButtons={ownPost && <PostActionButtons onEdit={onEdit} />}
/>
);
};
export default PostViewerContainer;
onEdit
을 호출하도록 함. ...
const PostActionButtons = ({ onEdit }) => {
return (
<PostActionButtonsBlock>
// 🔻 수정 버튼 클릭시 onEdit이 실행되도록
<ActionButton onClick={onEdit}>수정</ActionButton>
<ActionButton>삭제</ActionButton>
</PostActionButtonsBlock>
);
};
export default PostActionButtons;
OnEdit 실행 -> dispatch(setOriginalPost(post)) -> SET_ORIGINAL_POST 액션 디스패치 -> post.title,post.body, post.tags, post._id 를 write의 state에 저장함
-> /write
로 이동 -> 저장된 state대로 글 작성
body
를 quillInstance.current.root.innerHTML
에 넣어줌.components/wrtie/Editor.js 수정
...
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return; // body가 변경되어도 처음에만 불러오도록
mounted.current = true;
quillInstance.current.root.innerHTML = body;
}, [body]);
deps를 [ ]로 작성해도 되지만,
ESLint 규칙은 useEffect에서 사용되는 모든 외부 값을 deps에 넣어줘야 한다.
-> 이렇게 하려면 주석으로/* eslint-disable-line */
을 넣어주자.
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}`);
export const listPosts = ({ page, username, tag }) => {
return client.get('/api/posts', { params: { page, username, tag } });
};
// 🔻 updatePost API 추가
export const updatePost = ({ id, title, body, tags }) =>
client.patch(`api/posts/${id}`, { title, body, tags });
액션 생성
UPDATE_POST (UPDATE_POST_SUCCESS, UPDATE_POST_FAILURE)
saga 생성
updatePostSaga
modules/write.js
const [UPDATE_POST, UPDATE_POST_SUCCESS, UPDATE_POST_FAILURE] =
createRequestActionTypes('write/UPDATE_POST');
export const updatePost = createAction(
UPDATE_POST,
({ id, title, body, tags }) => ({
id,
title,
body,
tags,
}),
);
// saga 생성
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 write = handleActions(
{
...
[UPDATE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
[UPDATE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
},
initialState,
);
export default write;
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import WriteActionButtons from '../../components/write/WriteActionButtons';
import { updatePost, writePost } from '../../modules/write';
import { useNavigate } from 'react-router-dom';
const WriteActionButtonsContainer = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { title, body, tags, post, postError, originalPostId } = useSelector(
({ write }) => ({
title: write.title,
body: write.body,
tags: write.tags,
post: write.post,
postError: write.postError,
// 🔻 state.write.originalPostId 가져옴
originalPostId: write.originalPostId,
}),
);
const onPublish = () => {
if (originalPostId) {
// 🔻 수정하는 것이라면 - originalPostId가 존재 - updatePost 디스패치
dispatch(updatePost({ title, body, tags, id: originalPostId }));
return;
}
dispatch(writePost({ title, body, tags }));
};
...
return (
<WriteActionButtons
onPublish={onPublish}
onCancel={onCancel}
// 🔻 originalPostId가 true면 isEdit=true
isEdit={!!originalPostId}
/>
);
};
export default WriteActionButtonsContainer;
...
const WriteActionButtons = ({ onPublish, onCancel, isEdit }) => {
return (
<WriteActionButtonsBlock>
<StyledButton teal onClick={onPublish}>
// 🔻 isEdit이 true면 (=originalPostId 존재시) - 포스트 수정
포스트 {isEdit ? '수정' : '등록'}
</StyledButton>
<StyledButton onClick={onCancel}>취소</StyledButton>
</WriteActionButtonsBlock>
);
};
-> 포스트 수정이라고 나옴.
-> 태그와 본문 모두 잘 수정되었음.
+) 🔻 참고로, 남이 쓴 글은 수정/삭제 버튼이 안보임!
components/common/AskModal.js 생성
import styled from 'styled-components';
import Button from './Button';
const Fullscreen = styled.div`
position: fixed;
z-index: 30;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.25);
display: flex;
justify-content: center;
align-items: center;
`;
const AskModalBlock = styled.div`
width: 320px;
background: #fff;
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.75em;
}
`;
const AskModal = ({
title,
description,
visible,
confirmText = '확인',
cancelText = '취소',
onConfirm,
onCancel,
}) => {
if (!visible) return null;
return (
// 🔻 Fullscreen이 최상위에 있어야 함.
<Fullscreen>
<AskModalBlock>
<h2>{title}</h2>
<p>{description}</p>
<div className="buttons">
<StyledButton onClick={onCancel}>{cancelText}</StyledButton>
<StyledButton onClick={onConfirm}>{confirmText}</StyledButton>
</div>
</AskModalBlock>
</Fullscreen>
);
};
export default AskModal;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
-> 전체 화면을 다 채우게. (position: fixed)
display: flex;
align-items: center;
justify-content: center;
-> 자식요소(AskModalBlock)를 정 가운데에 배치함.
visible props를 true/false로 조절하여 AskModal을 보이게 할지, 안보이게 할지 조정함.
(모달 닫기/열기)
components/post/AskRemoveModal.js 생성
import AskModal from '../common/AskModal';
const AskRemoveModal = ({ visible, onConfirm, onCancel }) => {
return (
<AskModal
visible={visible}
title="포스트 삭제"
description="포스트를 정말 삭제하시겠습니까?"
confirmText="삭제"
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
};
export default AskRemoveModal;
import { useState } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import AskRemoveModal from './AskRemoveModal';
...
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>
</PostActionButtonsBlock>
<AskRemoveModal
visible={modal}
onCancel={onCancel}
onConfirm={onConfirm}
/>
</>
);
};
export default PostActionButtons;
-> 삭제 버튼 클릭시 모달이 나타남.
lib/api/posts.js 수정
...
export const removePost = (id) => client.delete(`api/posts/${id}`);
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from '../../components/post/PostViewer';
import { useParams, useNavigate } from 'react-router-dom';
import { setOriginalPost } from '../../modules/write';
import PostActionButtons from '../../components/post/PostActionButtons';
import { removePost } from '../../lib/api/posts';
const PostViewerContainer = () => {
const { postId } = useParams();
const dispatch = useDispatch();
const navigate = useNavigate();
const { post, error, loading, user } = useSelector(
({ post, loading, user }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
user: user.user, // 로그인 상태 (user)
}),
);
useEffect(() => {
dispatch(readPost(postId)); // readPost에 postId(payload)를 전달하여 API 호출
return () => {
dispatch(unloadPost());
}; // return 안에는 정리함수 (componentWillUnmount)
}, [dispatch, postId]);
const onEdit = () => {
dispatch(setOriginalPost(post));
navigate('/write');
};
// 🔻 removePost API는 postId만 전달해주면 되므로 바로 컨테이너에서 호출함.
const onRemove = async () => {
try {
await removePost(postId); // useParams로 받은 postId
navigate('/'); // 삭제 후 홈으로 이동.
} catch (e) {
console.log(e);
}
};
const ownPost = (user && user._id) === (post && post.user._id);
return (
<PostViewer
post={post}
loading={loading}
error={error}
actionButtons={
// 🔻 PostActionButtons에 onRemove 함수 전달
ownPost && <PostActionButtons onEdit={onEdit} onRemove={onRemove} />
}
/>
);
};
export default PostViewerContainer;
API.함수
를 넣어서 성공/실패 액션을 실행했음.$ yarn add react-helmet-async
src/index.js 파일 수정
import { HelmetProvider } from 'react-helmet-async';
...
root.render(
<Provider store={store}>
<BrowserRouter>
<HelmetProvider>
<App />
</HelmetProvider>
</BrowserRouter>
</Provider>,
);
-> HelmetProvider로 App 컴포넌트를 감싸줌.
<Helmet>
컴포넌트를 사용하면 됨.src/App.js 수정
import { Routes, Route } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
import LoginPage from './pages/LoginPage';
import WritePage from './pages/WritePage';
import PostListPage from './pages/PostListPage';
import PostPage from './pages/PostPage';
import RegisterPage from './pages/RegisterPage';
function App() {
return (
<>
<Helmet>
<title>BLOGROW🌱</title>
</Helmet>
<Routes>
<Route path="/" element={<PostListPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/write" element={<WritePage />} />
<Route path="/@:username">
<Route index element={<PostListPage />} />
<Route path=":postId" element={<PostPage />} />
</Route>
</Routes>
</>
);
}
export default App;
Helmet은 더 깊숙한 곳에 위치한 Helmet이 우선권을 차지함.
-> App보다 WritePage가 더 깊숙한 곳이므로 WritePage에서 설정한 title 값이 나타남.
pages/WritePage.js 수정
import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';
import WriteActionButtonsContainer from '../containers/write/WriteActionButtonsContainer';
import { Helmet } from 'react-helmet-async';
const WritePage = () => {
return (
<Responsive>
<Helmet>
<title>글 작성하기 - BLOGROW </title>
</Helmet>
<EditorContainer />
<TagBoxContainer />
<WriteActionButtonsContainer />
</Responsive>
);
};
export default WritePage;
import { Helmet } from 'react-helmet-async';
return (
<PostViewerBlock>
<Helmet>
<title>{title} - BLOGROW</title>
</Helmet>
<PostHead>
...
);
-> post.title이 들어감.
$ yarn build
build 디렉터리 내의 파일을 사용할 수 있도록 하기 위해
koa-static
라이브러리로 정적 파일 제공 기능을 구현.
백엔드(서버) 터미널에서 진행.
$ yarn add koa-static
백엔드의 src/main.js 수정
require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';
// 🔻 koa-static의 serve 함수
import serve from 'koa-static';
import path from 'path';
// 🔻 koa-send의 send 함수
import send from 'koa-send';
import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';
// process.env 내부 값에 대한 레퍼런스
const { PORT, MONGO_URI } = process.env;
// mongoDB와 연결
mongoose
.connect(MONGO_URI)
.then(() => {
console.log('Connected to MongoDB');
})
.catch((e) => {
console.error(e);
});
const app = new Koa();
const router = new Router();
router.use('/api', api.routes()); // api 라우트 적용
// 라우터 적용 전에 미들웨어를 적용해야 함
app.use(bodyParser());
app.use(jwtMiddleware);
// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());
// 🔻 추가된 부분.
// 1. ../../blog-frontend/build 디렉터리
const buildDirectory = path.resolve(__dirname, '../../blog-frontend/build');
// .2 serve함수로 ../../blog-frontend/build 디렉터리를 사용할 수 있게 함
app.use(serve(buildDirectory));
// 3. 404 에러 & /api 로 시작하지 않는 경우
app.use(async (ctx) => {
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);
});
send 함수 - 미들웨어 작성
- 클라이언트 기반 라우팅이 작동하게 해줌.
이 미들웨어를 적용하지 않으면 url에 직접 localhost:4000/write를 입력해서 들어갈 경우, 404 에러가 발생함.
-> 즉, 서버측과 클라이언트측 url을 연동시켜주는 역할.
import client from './lib/api/client';
client.defaults.baseURL = 'http://localhost:4000';
nginx를 사용하여 사용자가 요청한 경로에 따라 다른 서버에서 처리하게 하면 됨.
nginx를 사용하면 정적 파일제공을 nginx가 자체적으로 하는 것이 더 빠름.