Blogrow Project Day 04

thisisyjin·2022년 5월 21일
0

Dev Log 🐥

목록 보기
14/23

Blogrow Project

📝 DAY 04- 220521

  • 포스트 조회 기능 구현
  1. 포스트 읽기 페이지 (UI)
  2. API 연동

포스트 조회 기능

PostViewer UI

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)

PostPage.js 렌더링 수정

pages/PostPage.js 수정

import HeaderContainer from '../containers/common/HeaderContainer';
import PostViewer from '../components/post/PostViewer';

const PostPage = () => {
  return (
    <>
      <HeaderContainer />
      <PostViewer />
    </>
  );
};

export default PostPage;
  • Header의 경우에는 모든 페이지에 공통으로 들어감.
  • 단, Header 컴포넌트 대신 HeaderContainer을 렌더링 한 것은, 리덕스 상태관리가 들어가야 하기 때문. (로그인 상태가 Header에 나타남)

http://localhost:3000/@tester/sampleid 에 접속하면 아래와 같은 UI가 나옴.


API 연동

  • 백엔드에서 작성했던 posts API를 연동하기.
  • API를 연동하는 것은 auth API들을 불러온 것과 마찬가지로 리덕스+미들웨어를 사용함.

lib/api/posts 생성

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 함수를 생성함.

  • writePost : /api/posts POST 요청
  • readPost : /api/posts/${id} GET 요청 (특정 유저의 포스트 조회)

post 모듈 작성

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;

액션

  • READ_POST,READ_POST_SUCCESS,READ_POST_FAILURE : readPost API 요청에 대한 액션
  • UNLOAD_POST : 포스트 페이지에서 벗어날 때 데이터 비우는 액션 (useEffect의 return으로)

왜 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 생성

-> 리덕스 스토어와 연결해주기 위해 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를 얻을 수 있다!

PostPage 렌더링 변경

  • PostPage.js에서 PostViewer 대신 PostViewerContainer 렌더링
import HeaderContainer from '../containers/common/HeaderContainer';
import PostViewerContainer from '../containers/post/PostViewerContainer';

const PostPage = () => {
  return (
    <>
      <HeaderContainer />
      <PostViewerContainer />
    </>
  );
};

export default PostPage;

PostViewer 컴포넌트 수정

  • PostViewerContainer에서 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로 이동한다.


포스트 목록 페이지 구현

PostList UI

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;
  • SubInfo 컴포넌트의 props로 username,publishedDate, hasMarginTop을 전달해준다.
  • hasMarginTop은 SubInfoBlock에 다시 props로 넘겨줘서 조건부 스타일링을 한다.
    (css 함수 이용)

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;
  • Tags 컴포넌트에서는 tags를 props로 전달해준다.
    -> PostViewer에서는 state인 localTags를 넣어서 전달.

PostViewer 수정

  • PostViewer에 작성했던 SubInfo, Tags 대신 임포트해서 props로 전달해줌.
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;

PostList 수정

  • 기존 SubInfo와 Tags 대신 위에서 작성한 common 컴포넌트를 넣어줌.

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>
  );
};

...

PostListPage 렌더링 교체

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;

Result

localhost:3000/에 접속하면 아래와 같이 PostList가 렌더링 된다.


포스트 목록 조회 API 연동

listPosts 함수

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;
  • 액션
    -> LIST_POSTS (SUCCESS/FAILURE) - API 요청 결과에 의해 액션 디스패치 (saga)

루트 리듀서, 루트 사가

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;

PostListContainer 생성

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;

PostList 컴포넌트 수정

props로 받아온 값을 적절히 렌더링함.

  • posts -> posts.map(post => <PostItem post={post} /> 로 보내줌
    -> publishedData, user, tags, title, body, _id 값 사용.
  • showWriteButton이 true면 (=user가 true) -> 로그인 된 상태이므로 -> 글쓰기 버튼이 보이게 함.

PostListPage 렌더링 교체

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;

Result

  • body 부분에 html 태그가 그대로 보인다.
  • 이 태그를 없애려면 서버에서 작업을 해줘야 한다.

다음 포스팅

  1. html 필터링 (sanitze-html)
  2. 페이지네이션 구현
profile
기억은 한계가 있지만, 기록은 한계가 없다.

0개의 댓글