📝 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가 자체적으로 하는 것이 더 빠름.