4. 포스트 리스팅 페이지 구현

이번 섹션에서는 여러 포스트를 불러오는 기능을 구현해보겠습니다. 이전에 로그인/회원가입을 구현 했을 때 처럼 UI 를 먼저 완성하고 나서 데이터를 연동해주겠습니다.

UI 준비하기

포스트 리스팅에 관련 된 컴포넌트는 components 디렉터리에서 postList 라는 이름으로 분류해주겠습니다.

PostList 만들기

우선 PostList 컴포넌트를 작성해보세요.

components/postList/PostList.js

import React from 'react';
import { Link } from 'react-router-dom';
import './PostList.scss';

const PostList = ({ posts }) => {
  return (
    <div className="PostList">
      <div className="post-item">
        <Link to="/posts/1" className="title">
          title
        </Link>
        <div className="meta">
          <span className="username">
            by <b>username</b>
          </span>
          <span className="separator">&middot;</span>
          <span className="date">1시간 전</span>
        </div>
        <p>body</p>
      </div>
      <div className="post-item">
        <Link to="/posts/1" className="title">
          title
        </Link>
        <div className="meta">
          <span className="username">
            by <b>username</b>
          </span>
          <span className="separator">&middot;</span>
          <span className="date">1시간 전</span>
        </div>
        <p>body</p>
      </div>
    </div>
  );
};

export default PostList;

components/postList/PostList.scss

.PostList {
  .post-item {
    padding-top: 2rem;
    padding-bottom: 2rem;
    .title {
      color: $oc-gray-9;
      font-size: 2rem;
      font-weight: 800;
      &:hover {
        color: $oc-teal-6;
      }
    }
    .meta {
      margin-top: 0.5rem;
      color: $oc-gray-6;
      font-style: italic;
      .separator {
        margin-left: 0.25rem;
        margin-right: 0.25rem;
      }
    }
    p {
      color: $oc-gray-7;
      font-size: 1.125rem;
      line-height: 1.5;
    }
  }
  .post-item + .post-item {
    border-top: 1px solid $oc-gray-2;
  }
}

이 컴포넌트에서는 추후 posts 배열을 props 로 받아와서 map 을 사용하여 여러 포스트들을 렌더링해 줄 것입니다. 지금 당장은 posts 가 없기 때문에 포스트 항목을 직접 하드코딩하여 보여주고 있습니다.

이 컴포넌트를 PostListPage.js 에서 헤더 하단에 렌더링하세요.

pages/PostListPage.js

import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import PostList from '../components/postList/PostList';

/**
 * 여러 포스트 목록을 보여주는 페이지
 */
const PostListPage = () => {
  return (
    <>
      <HeaderContainer />
      <Responsive>
        <div className="PostListPage">
          <PostList />
        </div>
      </Responsive>
    </>
  );
};

export default PostListPage;

그리고, 이전에 Sass 가 작동하는지 확인하기 위해서 작성했었던 PostListPage.scss 내부의 코드를 지워주세요.

pages/PostListPage.scss

.PostListPage {
}

저장을 하고나서 이런 화면이 나타나는지 확인하세요.

WritePostButton 만들기

이 컴포넌트는 로그인 했을 때만 보여지게 될 글쓰기 버튼입니다. 여기서는 react-icons 에서 아이콘을 불러와서 사용하게 되니 react-icons 를 설치하세요.

$ yarn add react-icons

components/pageList/WritePostButton.js

import React from 'react';
import { MdNoteAdd } from 'react-icons/md';
import { Link } from 'react-router-dom';
import './WritePostButton.scss';

const WritePostButton = () => {
  return (
    <div className="WritePostButton">
      <Link to="/write">
        <MdNoteAdd />
        <div className="text">새 글 작성하기</div>
      </Link>
    </div>
  );
};

export default WritePostButton;

components/pageList/WritePostButton.scss

.WritePostButton {
  display: flex;
  justify-content: flex-end;
  a {
    background: $oc-gray-9;
    color: white;
    display: flex;
    align-items: center;
    padding: 0.5rem 1rem;

    &:hover {
      background: $oc-gray-6;
    }
    .text {
      font-weight: 600;
      margin-left: 0.5rem;
    }
  }
}

이 컴포넌트를 PostListPage 에서 PostList 상단에 렌더링해보세요.

버튼이 잘 렌더링 됐나요?

Pagination 컴포넌트 만들기

Pagination 컴포넌트는 다음 페이지 혹은 이전 페이지로 이동 할 수 있게 해주는 컴포넌트입니다.

components/postList/Pagination.js

import React from 'react';
import { MdChevronLeft, MdChevronRight } from 'react-icons/md';
import { Link } from 'react-router-dom';
import './Pagination.scss';

const Pagination = ({ page = 1, lastPage }) => {
  return (
    <div className="Pagination">
      {page === 1 ? (
        <div className="disabled-btn">
          <MdChevronLeft />
        </div>
      ) : (
        <Link to={`/?page=${page - 1}`} disabled={page === 1}>
          <MdChevronLeft />
        </Link>
      )}
      <div className="page">
        <b>{page}</b> 페이지
      </div>
      {page >= lastPage ? (
        <div className="disabled-btn">
          <MdChevronRight />
        </div>
      ) : (
        <Link to={`/?page=${page + 1}`}>
          <MdChevronRight />
        </Link>
      )}
    </div>
  );
};

export default Pagination;

components/pageList/Pagination.scss

.Pagination {
  display: flex;
  margin-bottom: 3rem;
  align-items: center;
  justify-content: center;
  a,
  .disabled-btn {
    width: 2rem;
    height: 2rem;
    background: $oc-gray-7;
    border-radius: 1rem;
    color: white;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1.25rem;
  }
  a {
    cursor: pointer;
    &:hover {
      background: $oc-gray-5;
    }
  }
  .disabled-btn {
    background: $oc-gray-4;
    color: $oc-gray-2;
  }
  .page {
    margin-left: 1rem;
    margin-right: 1rem;
    color: $oc-gray-7;
    b {
      color: $oc-gray-9;
      font-weight: 600;
    }
  }
}

이제 이 컴포넌트를 PostListPage 에서 PostList 하단에 렌더링해줄 차례입니다. 이번에는 리액트 라우터에서 넘겨주는 location.search 값에서 쿼리 파라미터를 파싱하여 page 값을 전달해줘야 하므로, qs 라이브러리를 설치해서 사용하겠습니다.

$ yarn add qs

pages/PostListPage.js

import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import PostList from '../components/postList/PostList';
import WritePostButton from '../components/postList/WritePostButton';
import Pagination from '../components/postList/Pagination';
import qs from 'qs';

/**
 * 여러 포스트 목록을 보여주는 페이지
 */
const PostListPage = ({ location }) => {
  const query = qs.parse(location.search, {
    ignoreQueryPrefix: true
  });
  return (
    <>
      <HeaderContainer />
      <Responsive>
        <div className="PostListPage">
          <WritePostButton />
          <PostList />
          <Pagination page={parseInt(query.page || '1')} />
        </div>
      </Responsive>
    </>
  );
};

export default PostListPage;

Pagination 컴포넌트가 잘 보여지고 있나요?

API 연동

이제 API 를 연동해줄 차례입니다. 우선 사용 할 API 를 함수화 하여 저장하겠습니다.

lib/api/posts.js

import client from './client';

export const listPosts = (page = 1) => client.get(`/api/posts?page=${page}`);
export const read = id => client.get(`/api/posts/${id}`);

listPosts 는 여러 포스트를 불러오는 API 이고, read 는 단일 포스트를 불러오는 API 입니다.

API 함수를 모두 작성하셨으면 posts 리덕스 모듈을 작성하세요.

modules/posts.js

import { handleActions } from 'redux-actions';
import createPromiseThunk from '../lib/createPromiseThunk';
import * as postsAPI from '../lib/api/posts';

const LIST_POSTS = 'posts/LIST_POSTS';
const LIST_POSTS_SUCCESS = 'posts/LIST_POSTS_SUCCESS';

export const listPosts = createPromiseThunk(LIST_POSTS, postsAPI.listPosts);

const initialState = {
  list: null
};

export default handleActions(
  {
    [LIST_POSTS_SUCCESS]: (state, { payload }) => ({
      ...state,
      list: payload.data
    })
  },
  initialState
);

그 다음엔 루트리듀서에 포함시켜주세요.

modules/index.js

import { combineReducers } from 'redux';
import auth from './auth';
import user from './user';
import posts from './posts';

const rootReducer = combineReducers({
  auth,
  user,
  posts
});

export default rootReducer;

이제 PostListContainer 를 만들어서 현재 페이지 값에 따라 API 를 요청하도록 구현해주겠습니다.

containers/postList/PostListContainer.js

import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import qs from 'qs';
import { listPosts } from '../../modules/posts';
import PostList from '../../components/postList/PostList';

class PostListContainer extends Component {
  get page() {
    const query = qs.parse(this.props.location.search, {
      ignoreQueryPrefix: true
    });
    return parseInt(query.page || '1', 10);
  }

  loadPosts = async () => {
    await this.props.listPosts(this.page);
    window.scrollTo(0, 0);
  };

  componentDidUpdate(prevProps, prevState) {
    if (prevProps.location.search !== this.props.location.search) {
      this.loadPosts();
    }
    if (prevProps.posts !== this.props.posts) {
      window.scrollTo(0, 0);
    }
  }

  componentDidMount() {
    this.loadPosts();
  }

  render() {
    if (!this.props.list) return null;
    return <PostList posts={this.props.list.posts} />;
  }
}

export default withRouter(
  connect(
    state => ({
      list: state.posts.list
    }),
    {
      listPosts
    }
  )(PostListContainer)
);

작성 후, PostListPage 에서 기존의 PostList 를 PostListPage 로 대체시키세요.

이제 진짜 데이터를 보여줄 차례입니다. 진짜 데이터를 보여주기 전에, date-fns 라는 라이브러리를 설치하겠습니다. 이 라이브러리는 날짜를 원하는 형식으로 쉽게 변환해주는 역할을 합니다.

$ yarn add date-fns

그리고, lib 디렉터리에 common.js 라는 파일을 만들어서 그 안에 formatDate 라는 함수를 만들도록 하겠습니다. common.js 에 작성하는 이유는 추후 단일 포스트를 읽을 때에도 똑같은 작업을 할 것이기 때문입니다.

lib/common.js

import { format, distanceInWordsToNow } from 'date-fns';
import koLocale from 'date-fns/locale/ko';

export function formatDate(date) {
  const now = new Date();
  const d = new Date(date);
  const diff = now.getTime() - d.getTime();

  if (diff < 1000 * 60) {
    return '방금 전';
  }
  if (diff < 1000 * 60 * 60 * 24 * 7) {
    return distanceInWordsToNow(d, {
      locale: koLocale,
      addSuffix: true
    });
  }
  return format(d, 'YYYY-MM-DD');
}

그 다음엔 PostList 컴포넌트를 다음과 같이 수정해주세요.

components/postList/PostList.js

import React from 'react';
import { Link } from 'react-router-dom';
import './PostList.scss';
import { formatDate } from '../../lib/common';

const PostList = ({ posts }) => {
  return (
    <div className="PostList">
      {posts.map(post => (
        <div className="post-item" key={post.id}>
          <Link to={`/posts/${post.id}`} className="title">
            {post.title}
          </Link>
          <div className="meta">
            <span className="username">
              by <b>{post.user.username}</b>
            </span>
            <span className="separator">&middot;</span>
            <span className="date">{formatDate(post.created_at)}</span>
          </div>
          <p>{post.body}</p>
        </div>
      ))}
    </div>
  );
};

export default PostList;

이렇게 해주고 나면 실제 포스트를 화면에서 볼 수 있게 될 것입니다.

그 다음에는 PaginationContainer 도 만들어주겠습니다. 이 컴포넌트는 가장 마지막 페이지를 리덕스 스토어에서 받아와서 Pagination 에게 전달해줍니다.

containers/postList/PaginationContainer.js

import React, { Component } from 'react';
import Pagination from '../../components/postList/Pagination';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import qs from 'qs';

class PaginationContainer extends Component {
  get page() {
    const query = qs.parse(this.props.location.search, {
      ignoreQueryPrefix: true
    });
    return parseInt(query.page || '1', 10);
  }

  render() {
    if (!this.props.hasPosts) return null;
    return <Pagination page={this.page} lastPage={this.props.lastPage} />;
  }
}

export default withRouter(
  connect(state => ({
    hasPosts: !!state.posts.list,
    lastPage: state.posts.list ? Math.ceil(state.posts.list.count / 10) : 1
  }))(PaginationContainer)
);

다 작성하신 후 기존의 Pagination 을 PaginationContainer 로 대체시키세요.

pages/PostListPage.js

import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import WritePostButton from '../components/postList/WritePostButton';
import PostListContainer from '../containers/postList/PostListContainer';
import PaginationContainer from '../containers/postList/PaginationContainer';

/**
 * 여러 포스트 목록을 보여주는 페이지
 */
const PostListPage = () => {
  return (
    <>
      <HeaderContainer />
      <Responsive>
        <div className="PostListPage">
          <WritePostButton />
          <PostListContainer />
          <PaginationContainer />
        </div>
      </Responsive>
    </>
  );
};

export default PostListPage;

이제 마지막 페이지까지 갔을 때 우측 버튼이 비활성화 되는지 확인해보세요.

로그인 했을 때만 글쓰기 버튼 보여주기

상단의 새 글 작성하기 버튼은 로그인 상태에서만 보여져야 합니다. 때문에, WritePostButton 컴포넌트를 위한 컨테이너를 또 따로 만들어주어야 합니다. 이번에는 connect 를 대신에, Render Props 를 사용하여 WithUser 라는 컴포넌트를 만들고, 그 컴포넌트를 통하여 WritePostButton 렌더링 여부를 정하겠습니다.

containers/common/WithUser.js

// Render Props 를 사용하여 원하는 곳에서 쉽게 유저 조회하는 예제
// PostListPage 에서 사용중

import { connect } from 'react-redux';
const WithUser = ({ children, user }) => {
  return children(user);
};

export default connect(state => ({
  user: state.user.user
}))(WithUser);

pages/PostListPage.js

import React from 'react';
import './PostListPage.scss';
import HeaderContainer from '../containers/base/HeaderContainer';
import Responsive from '../components/base/Responsive';
import WritePostButton from '../components/postList/WritePostButton';
import PostListContainer from '../containers/postList/PostListContainer';
import PaginationContainer from '../containers/postList/PaginationContainer';
import WithUser from '../containers/common/WithUser';

/**
 * 여러 포스트 목록을 보여주는 페이지
 */
const PostListPage = () => {
  return (
    <>
      <HeaderContainer />
      <Responsive>
        <div className="PostListPage">
          <WithUser>{user => (user ? <WritePostButton /> : null)}</WithUser>
          <WritePostButton />
          <PostListContainer />
          <PaginationContainer />
        </div>
      </Responsive>
    </>
  );
};

export default PostListPage;

이렇게 WithUser 컴포넌트를 만들어두면, user 값을 조회해야 하는 상황에 더욱 쉽게 구현 할 수 있게 됩니다.

results matching ""

    No results matching ""