Skip to content

Instantly share code, notes, and snippets.

@killi18n
Last active February 18, 2019 07:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save killi18n/3c49dc7be358f5e5ec15b9afc926adef to your computer and use it in GitHub Desktop.
Save killi18n/3c49dc7be358f5e5ec15b9afc926adef to your computer and use it in GitHub Desktop.
react hub developing walkthrough

React + Github API, react-hub

안녕하세요? 이번에 만들어볼 리액트 어플리케이션은 깃헙의 open api를 활용한 react-hub이라는 프로젝트입니다.

튜토리얼 챕터

  1. github api 사용법
  2. 프로젝트 생성 및 구조잡기
  3. github login
  4. github api 이용하기
  5. 전역적 상태관리를 위한 Redux 설정
  6. 로그인 처리하기
  7. 자체 API 통신 서버 개발
  8. 클라이언트에서 로그인 작업 완료
  9. 새로고침 할시 로그인 되어있는지 체크
  10. 자신의 Repository 리스팅
  11. [레포지토리 자세히 보기 페이지]

1. github api 사용법

간단한 깃헙 api 사용법을 써놨으니 아래 링크에서 확인해보시길 바랍니다.

https://gist.github.com/evals4dead/882734d9af2f17f7ddc2e0efe9b4ace6

2. 프로젝트 생성 및 구조잡기

yarn create react-app react-hub
yarn start

프로젝트를 생성하고 실행해줍니다.

필요하지 않은 파일들인, App.css, App.js, App.test.js, index.css, logo.svg 파일을 지워줍니다.

src/index.js

import React from "react";
import ReactDOM from "react-dom";
import Router from "./components/Router";
import * as serviceWorker from "./serviceWorker";

const rootElement = document.getElementById("root");

ReactDOM.render(<Router />, rootElement);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

src/components/Router.js react-router-dom이라는 모듈을 사용하여 브라우저 라우팅을 해줄 component 입니다.

import React, { Component } from "react";

class Router extends Component {
  render() {
    return <div>Router</div>;
  }
}

export default Router;

더 나아가기 전에, vscode를 쓴다면 좋은 확장 프로그램인 prettier를 사용하기 위해서 설정을 해보겠습니다.

설치하지 않았다면 링크를 타고들어가 설치후, 프로젝트 루트 디렉터리 아래에 .prettierrc.js 를 만들어줍니다.

.prettierrc.js

module.exports = {
  "singleQuote": true,
  "semi": true,
  "bracketSpacing": true,
  "jsxBracketSameLine": false,
  "useTabs": false,
  "printWidth": 120,
  "tabWidth": 2,
  "trailingComma": "es5"
}

이제 js파일을 저장할때마다 자동 포매팅이 되는것을 볼수있습니다. 만약 저장시 (ctrl + s 혹은 cmd + s) 자동 포매팅이 안된다면, vscode에서 cmd + , 를 하여 formatOnSave 를 검색하여 체크표시를 해줍니다.

또한, .env파일을 만들어서 NODE_PATH=src 로 설정하여 임포트 할때 기본 폴더를 src폴더로 잡아놓겠습니다.

이제 브라우저에서 라우팅을 할 때 가장 먼저보여지는 component인 page들을 만들어보겠습니다.

src/pages/LoginPage.js

import React from 'react';

const LoginPage = () => {
  return <div>Login Page</div>;
};

export default LoginPage;

src/pages/UserPage.js

import React from 'react';

const UserPage = () => {
  return <div>My Page</div>;
};

export default UserPage;

src/pages/NotFoundPage.js

import React from 'react';

const NotFoundPage = () => {
  return <div>404 NotFound Page</div>;
};

export default NotFoundPage;

src/pages/index.js

const req = require.context('.', true, /.+Page\.js$/);

req.keys().forEach(key => {
  const pageName = key.replace(/^.+\/(.+)Page\.js/, '$1Page');
  if (req(key) && req(key).default) {
    const Page = req(key).default;
    module.exports[pageName] = Page;
  }
});

기본페이지인 LoginPage와 UserPage, NotFoundPage를 먼저 만든후, index.js 에서 webpack을 사용하여 Page.js로 끝나는 이름의 파일을 모두 export 해줍니다.

이제 react-router-dom을 설치하여, Router.js에서 라우팅을 해보겠습니다.

yarn add react-router-dom

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Router from './components/Router';
import * as serviceWorker from './serviceWorker';

const rootElement = document.getElementById('root');

const RootComponent = (
  <BrowserRouter>
    <Router />
  </BrowserRouter>
);

ReactDOM.render(RootComponent, rootElement);

serviceWorker.unregister();

위와같이 index.js 를 수정합니다. 전체를 BrowserRouter를 감싸므로써, 브라우저에서 라우팅이 가능하게 해줍니다.

components/Router.js

import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import { LoginPage, UserPage, NotFoundPage } from 'pages';

class Router extends Component {
  render() {
    return (
      <>
        <Switch>
          <Route path="/login" component={LoginPage} />
          <Route path="/mypage" component={UserPage} />
          <Route component={NotFoundPage} />
        </Switch>
      </>
    );
  }
}

export default Router;

이제 위의 라우팅을 사용하여 브라우저에서 접속해보면, 라우팅된 페이지가 모두 잘뜸을 알수있습니다. 또한, 위의 라우팅에서 걸러지지않은 페이지들은 저희가 다루지않는 , 즉 404페이지 이므로 NotFoundPage를 보여주게 됩니다.

저는 추후 config파일을 만질수도 있을거같아서, yarn eject 를 하여 프로젝트를 eject 하였습니다. (불필요하다면 하지않아도 무방합니다.)

yarn add node-sass open-color include-media

node-sass 라이브러리를 추가하고, open-color와 include-media를 사용하여 기본 스타일설정을 하겠습니다.

styles/base.scss

// originally written by velopert

@import url('//fonts.googleapis.com/earlyaccess/notosanskr.css');

body {
  margin: 0;
  box-sizing: border-box;
  font-family: 'Noto Sans KR', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

// box-sizing 일괄 설정
* {
  box-sizing: inherit;
}

// 링크 스타일 밑줄 및 색상 무효화
a {
  text-decoration: inherit;
  color: inherit;
}

styles/utils.scss

@import '~open-color/open-color';
@import '~include-media/dist/include-media';
@import './material-shadow.scss';

$breakpoints: (
  small: 320px,
  medium: 768px,
  large: 1024px,
  wide: 1400px,
);

styles/material-shadow.scss

// originally written by velopert

@mixin material-shadow($z-depth: 1, $strength: 1, $color: black) {
  @if $z-depth == 1 {
    box-shadow: 0 1px 3px rgba($color, $strength * 0.14), 0 1px 2px rgba($color, $strength * 0.24);
  }
  @if $z-depth == 2 {
    box-shadow: 0 3px 6px rgba($color, $strength * 0.16), 0 3px 6px rgba($color, $strength * 0.23);
  }
  @if $z-depth == 3 {
    box-shadow: 0 10px 20px rgba($color, $strength * 0.19), 0 6px 6px rgba($color, $strength * 0.23);
  }
  @if $z-depth == 4 {
    box-shadow: 0 15px 30px rgba($color, $strength * 0.25), 0 10px 10px rgba($color, $strength * 0.22);
  }
  @if $z-depth == 5 {
    box-shadow: 0 20px 40px rgba($color, $strength * 0.3), 0 15px 12px rgba($color, $strength * 0.22);
  }
  @if ($z-depth < 1) or ($z-depth > 5) {
    @warn "$z-depth must be between 1 and 5";
  }
}

material-shadow 는 그림자 효과를 주는 mixin입니다. 추후에 그림자효과를 줄때에 쓰게 될것입니다. utils.scss에서는 open-color와 include-media, 그리고 화면비율을 utils.scss에 작성해줍니다. base.scss에서는 전체적인 스타일을 초기화 해주는 역할 및 폰트 지정 역할을 합니다.

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Router from './components/Router';
import './styles/base.scss';
import * as serviceWorker from './serviceWorker';

const rootElement = document.getElementById('root');

const RootComponent = (
  <BrowserRouter>
    <Router />
  </BrowserRouter>
);

ReactDOM.render(RootComponent, rootElement);

serviceWorker.unregister();

base.scss를 index.js에서 불러옵니다.

이제 기본적인 라우팅과, sass 세팅은 끝난것 같으니, 다음 챕터에서는 github api를 연동하여 로긴하는 방법을 다루겠습니다.

3. github login

로그인 페이지에서 기본적인 컴포넌트들을 만들어보겠습니다.

src/components/auth/LoginButton 폴더 아래에 index.js, LoginButton.jsx, LoginButton.scss를 만들어주세요.

그리고 스타일링에서 사용할 모듈인 classnames 를 yarn add classnames 를 사용하여 받아주세요.

LoginButton.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './LoginButton.scss';

const cx = classnames.bind(styles);

const LoginButton = () => {
    return (
        <div className={cx('login-button')}>
            LoginButton
        </div>
    );
};

export default LoginButton;

LoginButton.scss

@import '~styles/utils.scss';

.login-button {
    
}

index.js

export { default } from './LoginButton';

위와같이 작성하고, 이제 로긴 버튼을 만들어보겠습니다. 일단 보여져야 하므로, LoginPage.js 에

import React from 'react';
import LoginButton from 'components/auth/LoginButton';

const LoginPage = () => {
  return <div><LoginButton /></div>;
};

export default LoginPage;

로긴버튼을 렌더링 해줍니다.

테마를 좀 어둡게 잡기 위해, styles/base.scss에 body 스타일에 다음을 추가해줍니다.

@import './utils.scss'; // 추가된 부분

body {
  margin: 0;
  box-sizing: border-box;
  font-family: 'Noto Sans KR', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: $oc-gray-8; // 추가된부분
  
}

::selection {
  background: $oc-gray-8;
} //마우스 셀렉션 색깔 변하는 효과

LoginButton.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './LoginButton.scss';

const cx = classnames.bind(styles);

const LoginButton = () => {
    return (
        <div className={cx('login-button')}>
            GITHUB LOGIN
        </div>
    );
};

export default LoginButton;

LoginButton.scss

@import '~styles/utils.scss';

.login-button {
    width: 400px;
    height: 150px;
    background: $oc-gray-2;
    border-radius: 3px;
    border: 1px solid $oc-gray-5;

    @include material-shadow(2, 0.5);

    display: flex;
    justify-content: center;
    align-items: center;

    font-size: 2.5rem;
    font-weight: 800;

    &:hover {
        background: $oc-gray-1;
        cursor: pointer;
    }

    &:active {
        background: $oc-gray-0;
    }
}

커다란 깃헙 로그인 버튼이 보여질 것입니다.

이제 깃헙 로그인 버튼을 감싸줄,

AuthPageWrapper 컴포넌트를 작성해보겠습니다.

components/auth/AuthPageWrapper 폴더 아래에 로그인 버튼을 만들었던 형식으로 파일을 생성해주세요.

AuthPageWrapper.jsx

import React, { Component } from 'react';
import classnames from 'classnames/bind';
import styles from './AuthPageWrapper.scss';

const cx = classnames.bind(styles);

class AuthPageWrapper extends Component {
    render() {
        return (
            <div className={cx('auth-page-wrapper')}>
                {this.props.children}
            </div>
        );
    }
}

export default AuthPageWrapper;

AuthPageWrapper.scss

@import '~styles/utils.scss';

.auth-page-wrapper {
    display: flex;
    align-items: center;
    justify-content: center;

    height: calc(100vh);
}

index.js

export { default } from './AuthPageWrapper';

그리고 LoginPage.js에 렌더링해줍니다.

LoginPage.js

import React from 'react';
import LoginButton from 'components/auth/LoginButton';
import AuthPageWrapper from 'components/auth/AuthPageWrapper';

const LoginPage = () => {
  return <AuthPageWrapper><LoginButton /></AuthPageWrapper>;
};

export default LoginPage;

이제 깃헙 로그인 버튼이 만들어졌으니, 기능을 넣어야겠죠?

그전에 깃헙 로그인을 하기위한, 어플리케이션을 하나 만들어야 합니다. 모두 깃헙에 로그인 하신후, 프로필 버튼을 눌러 settings 메뉴를 누릅니다. 그럼 왼쪽 가장 아래애 Developer Settings 라는 탭이 있는데, 그것을 눌러 OAuth App을 하나 만들어줍니다.

을 작성하고 앱을 만들어줍니다.

프로젝트 루트폴더아래에 .env란 파일을 하나 만들어줍니다. 그리고 .gitignore 파일에 .env를 추가 하여 깃 레포지토리에 .env파일이 올라가지 않게합니다.

.env

NODE_PATH=src

REACT_APP_GITHUB_CLIENT_ID=깃헙 앱의 클라이언트 아이디를 입력
REACT_APP_GITHUB_SECRET_ID=깃헙 앱의 시크릿 아이디를 입력

이렇게 하면 리액트 앱 내에서, process.env.변수명 을 사용하여 값을 가져올수있습니다. 소스코드상에 공개하고 싶지 않은 환경변수를 저장하고 사용할수 있는것이죠.

LoginButton.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './LoginButton.scss';

const cx = classnames.bind(styles);

const LoginButton = () => {

    const handleClickLoginButton = () => {
        window.location.replace(`https://github.com/login/oauth/authorize?scope=user:email&client_id=${process.env.REACT_APP_GITHUB_CLIENT_ID}`)
    }

    return (
        <div className={cx('login-button')} onClick={handleClickLoginButton}>
            GITHUB LOGIN
        </div>
    );
};

export default LoginButton;

LoginButton 을 위와같이 수정하여 줍니다. 일단 설명을 하자면, 버튼을 누를시 , github 로그인 창으로 옮격가게 됩니다.

이제 초록색 authenticate 버튼을 누르면 http://localhost:3000/login/processing?code= 와 같은 페이지로 이동될것입니다. 이제 /login/processing 의 라우팅으로 리액트앱이 라우팅됬을때, 처리를 해주어야 하는데요, 다음과 같이 처리하도록 하겠습니다.

우리는 Router.jsx 에서 react-router-dom을 활용하여 라우팅을 하였기때문에, page들에게는 props로 location이라는 prop이 전달되는데요,

LoginPage.jsx

import React from 'react';
import LoginButton from 'components/auth/LoginButton';
import AuthPageWrapper from 'components/auth/AuthPageWrapper';

const LoginPage = ({location}) => {
  
  return <AuthPageWrapper location={location}><LoginButton /></AuthPageWrapper>;
};

export default LoginPage;

위와같이 AuthPageWrapper에 location 을 전달해줍니다.

AuthPageWrapper.jsx

import React, { Component } from 'react';
import classnames from 'classnames/bind';
import styles from './AuthPageWrapper.scss';

const cx = classnames.bind(styles);

class AuthPageWrapper extends Component {

    componentDidMount() {
        const { location: { pathname, search } } = this.props;
        if(pathname === '/login/processing') {
            console.log(search.split('?code=')[1]);
            // window.close();
        }
    }

    render() {
        return (
            <div className={cx('auth-page-wrapper')}>
                {this.props.children}
            </div>
        );
    }
}

export default AuthPageWrapper;

이제 authenticate버튼을 눌르거나, 이미 authenticate이 되었다면 팝업 창이 띄워짐과 동시에, componentDidMount함수를 이용하여 저희가 필요한 정보인 code query parameter를 찍을수 있습니다. 개발자 콘솔을 켜서 보면 잘 찍히는것이 보이죠?

4. github api 이용하기

이제 이 code 값을 사용하여, github으로부터 access_token을 발급받아서, 유저의 정보를 받아오는 api를 실행해보겠습니다.

먼저 postman을 띄워주신후, 다음과 같은 정보로 실행합니다.

{
	"code": "아까 팝업창에서 받은 code값",
	"client_secret": "github oauth 앱의 client_secret 값",
	"client_id": "github oauth앱의 client_id 값"
}

위와같은 정보로 포스트맨을 실행시키면,

access_token=github액세스토큰값&scope=user%3Aemail&token_type=bearer

와 같은 정보들이 전달될것입니다.

이제 위의 값들중 access_token값을 이용하여 유저 정보를 받아오는 api를 실행해보겠습니다.

  • METHOD: GET
  • HEADERS:
Authroization: bearer 아까받은access_token (bearer 를 쓴뒤 한칸 띄어야 합니다.)
Content-Type: application/json

정보가 잘 받아지나요? 그렇다면 성공입니다! 앞으로 이런식으로 github api를 이용할것입니다.

5. 전역적으로 상태관리하기 위한 Redux 설정

자 이제, 저희가 필요한 정보를 받아오는 것도 완료하였고, github api를 이용하는 방법도 알아보았으니, 팝업창이 정상적으로 code값을 받아오면, 로그인이 되었다는 처리를 하여 전역적으로 state를 관리해야 합니다. 그리고 로그인이 된 UserPage로 넘겨야 하겠죠? 이것을 편하게 하기위해서, Redux를 사용할것입니다.

일단 다음 폴더 및 파일들을 생성해줍니다.

src/store/configure.js, src/store/modules/index.js, src/store/modules/auth.js

그리고 redux를 사용하기 위해 필요한 모듈들을 받아줍니다.

yarn add redux react-redux redux-actions immer redux-pender

store/modules/auth.js

import { createAction, handleActions } from 'redux-actions';
import { applyPenders } from 'redux-pender';
import produce from 'immer';

const SET_LOGGED_IN = 'auth/SET_LOGGED_IN';

const authActions = {
    setLoggedIn: createAction(SET_LOGGED_IN, payload => payload)
}

const initialState = {
    loggedIn: false,
    user: null
};

const reducer = handleActions({
    [SET_LOGGED_IN]: (state, action) => {
        return produce(state, draft => {
            const { loggedIn } = action.payload;
            draft.loggedIn = loggedIn;
        })
    }
}, initialState);

export default applyPenders(reducer, []); // api와 통신할 action들의 reducer역할을 하는 부분입니다.

store/modules/index.js

import auth from './auth';
import {penderReducer} from 'redux-pender';

export default {
    auth,
    pender: penderReducer
}

모든 리듀서들을 모아서 한번에 내보냅니다.

store/configure.js

import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import rootReducer from './modules';
import penderMiddleware from 'redux-pender';

const reducers = combineReducers(rootReducer);
const middlewares = [penderMiddleware()];

const isDev = process.env.NODE_ENV === 'development';
const devTools = isDev && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
const composeEnhancers = devTools || compose;

const configure = (preloadedState) => createStore(reducers, preloadedState, composeEnhancers(applyMiddleware(...middlewares)));

export default configure;

redux-devtools가 리액트의 리덕스를 인식하게 하며, createStore를 사용하여 redux-store를 만들어서 내보냅니다.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import Router from './components/Router';
import configure from 'store/configure';
import './styles/base.scss';
import * as serviceWorker from './serviceWorker';

const rootElement = document.getElementById('root');

const store = configure(); // store 생성

const RootComponent = (
  <Provider store={store}> // Provider에 store 주입
	  <BrowserRouter>
	    <Router />
	  </BrowserRouter>
  </Provider>
);

ReactDOM.render(RootComponent, rootElement);

serviceWorker.unregister();

이제 브라우저의 redux-devtools를 켜보면, 현재의 redux 상태값이 찍혀있을것입니다. 만약, redux-devtools 크롬 확장 프로그램이 설치되있지않다면, https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=ko 에서 설치하여 주세요.

6. 로그인 처리하기

이제 로그인을 처리하기위한 container를 만들고, LoginPage쪽의 컴포넌트 렌더링 구조를 바꾸어 보겠습니다.

src/containers/AuthContainer.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { authActions } from 'store/modules/auth';
import LoginButton from 'components/auth/LoginButton';
import AuthPageWrapper from 'components/auth/AuthPageWrapper';


class AuthContainer extends Component {

    componentDidMount() {
        const { location: { pathname, search } } = this.props;
        if(pathname === '/login/processing') {
            console.log(search.split('?code=')[1]);
            const code = search.split('?code=')[1];
            // window.close();
            if(code) {
               // todo: 로그인 작업
            }
        }
    }

    render() {
        return (
            <AuthPageWrapper><LoginButton /></AuthPageWrapper>
        );
    }
}

export default connect(
    (state) => ({

    }),
    dispatch => ({
        AuthActions: bindActionCreators(authActions, dispatch)
    })
)(AuthContainer);

위와같이 컨테이너를 작성하고,

pages/LoginPage.jsx

import React from 'react';
import AuthContainer from 'containers/AuthContainer';


const LoginPage = ({location}) => {
  return <AuthContainer location={location} />
};

export default LoginPage;

LoginPage에서 컨테이너를 렌더링 하고, props를 전달합니다.

여기까지 완료되었다면, 이제 서버를 하나 만들어야 합니다. client에서 바로 axios 와 같은 통신모듈을 사용하여 github api로 통신해버리면, cors오류가 나게 됩니다. 오리진이 붙어있는 client이기 때문이죠.

7. 자체 api 통신 서버 개발.

이제 github api를 호출하고 클라이언트에게 보내줄 중간 매개자 역할을 할 서버를 만들어보겠습니다.

크게 nodejs 서버는 express와 koa가 있는데, 우리 서버는 중간 api 통신자 역할만 하면 되므로, 좀더 가벼운 koa를 사용하겠습니다.

다음을 설치하여주세요.

yarn add koa koa-router koa-bodyparser

그리고 src와 동일한 레벨에 (프로젝트 루트 아래에) , server라는 폴더를 하나 만든후, app.js를 만들어주세요.

server/app.js

const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const apiRouter = require('./router');

const app = new Koa();
const router = new Router();

router.use('/api', apiRouter.routes());

app.use(bodyParser());

app.use(router.routes()).use(router.allowedMethods());

app.listen(4000, () => {
    console.log('app is listening port', 4000);
});

위와같이 작성하고, server/router 폴더를 만든후에,

server/router/auth/auth.ctrl.js, server/router/auth/index.js 를 만들어 주세요.

auth.ctrl.js

const axios = require('axios');

module.exports.login = async (ctx) => {
    const { code, client_id, client_secret } = ctx.request.body;
    try {
        const response = await axios.post('https://github.com/login/oauth/access_token', {
            code,
            client_id, 
            client_secret
        }, {
            headers: {
                accept: 'application/json'
            }
        });
        const { access_token: accessToken } = response.data;
        ctx.body = {
            accessToken
        };
        ctx.status = 200;
    } catch(e) {
        ctx.throw(e, 500);
    }
};

auth/index.js

const Router = require('koa-router');
const authCtrl = require('./auth.ctrl');

const router = new Router();

router.post('/login', authCtrl.login);

module.exports = router;

그 다음 server/router/index.js를 만든후, 라우팅하여 내보내줍니다.

server/router/index.js

const Router = require('koa-router');
const auth = require('./auth');

const router = new Router();

router.use('/auth', auth.routes());

module.exports = router;

다음 명령어로 서버를 실행해봅시다.

npx nodemon ./server/app.js

서버가 잘 작동하는것을 볼수있습니다.

package.json에 다음을 추가하여 yarn 명령어로 서버를 실행할수 있게 만들수도 있습니다.

 "scripts": {
    "start": "node scripts/start.js",
    "start:server": "npx nodemon ./server/app.js", // 추가된 부분
    "build": "node scripts/build.js",
    "test": "node scripts/test.js"
  },

yarn start:server 으로 서버가 실행이 됩니다.

그리고, 로그인 을 한후 자신의 정보를 받아올 api를 하나더 작성해주겠습니다.

server/router/user/index.js, server/router/user/user.ctrl.js

index.js

const Router = require('koa-router');
const userCtrl = require('./user.ctrl');

const router = new Router();

router.get('/me', userCtrl.getMyInfo);

module.exports = router;

user.ctrl.js

const { createAxios } = require('../../lib/axios');


module.exports.getMyInfo = async(ctx) => {
    const { accesstoken: accessToken } = ctx.headers;
    const axios = createAxios({accessToken});
    try  {
        const response = await axios.get('/user');
        const { data: user } = response;
        ctx.body = {
            user
        };
        ctx.status = 200;
    } catch(e) {
        ctx.throw(e, 500);  
    }
}

이제 부터 사용될 api의 주소의 host가 모두 동일하므로 (https://api.github.com)

server/lib/axios.js

const axios = require('axios');

module.exports.createAxios = ({accessToken}) => {
    const axiosForGithub = axios.create({
        baseURL: 'https://api.github.com',
        withCredentials: true,
        headers: {
            'Authorization': `bearer ${accessToken}`,
	    'Content-Type': 'application/json'
        }
    });

    return axiosForGithub;
};

위와같이 공통 axios 모듈을 하나 만들어서 사용합니다.

8. 클라이언트에서 로그인 작업 완료.

이제 api가 모두 완성되었으니, 클라이언트 쪽에서 로그인 작업을 완료해보겠습니다.

그 전에, api와 통신하는 도중에 클라이언트에서 보여줄 spinner모듈을 하나 받겠습니다.

yarn add better-react-spinkit

그리고 src/components/common/LoadingSpinner/index.js, src/components/common/LoadingSpinner/LoadingSpinner.jsx, src/components/common/LoadingSpinner/LoadingSpinner.module.scss 파일을 다음과 같이 만들어줍니다.

LoadingSpinner.jsx

import React from 'react';
import {
    ChasingDots
} from 'better-react-spinkit';
import classnames from 'classnames/bind';
import styles from './LoadingSpinner.scss';

const cx = classnames.bind(styles);

const LoadingSpinner = () => {
    return (
        <div className={cx('SpinnerWrapper')}>
            <ChasingDots size={30} color="white" />
        </div>
    );
};

export default LoadingSpinner;

index.js

export { default } from './LoadingSpinner';

LoadingSpinner.scss

@import '~styles/utils.scss';

.SpinnerWrapper {
    display: flex;
    justify-content: center;
    align-items: center;

    width: 100%;
    min-height: calc(100vh);
}

store에서 user module과 auth module을 모두 완성하여, api를 통신하겠습니다.

store/modules/auth

import { createAction, handleActions } from 'redux-actions';
import { applyPenders } from 'redux-pender';
import produce from 'immer';
import * as authAPI from 'api/auth';

const SET_LOGGED_IN = 'auth/SET_LOGGED_IN';
const GET_ACCESS_TOKEN = 'auth/GET_ACCESS_TOKEN';

export const authActions = {
    setLoggedIn: createAction(SET_LOGGED_IN, payload => payload),
    getAccessToken: createAction(GET_ACCESS_TOKEN, authAPI.getAccessToken)
};

const initialState = {
    loggedIn: false,
    accessToken: null
};

const reducer = handleActions({
    [SET_LOGGED_IN]: (state, action) => {
        return produce(state, draft => {
            const { loggedIn } = action.payload;
            draft.loggedIn = loggedIn;
        })
    }
}, initialState);

export default applyPenders(reducer, [
    {
        type: GET_ACCESS_TOKEN,
        onSuccess: (state, action) => {
            return produce(state, draft => {
                const { data: { accessToken } } = action.payload;
                if(accessToken) {
                    draft.accessToken = accessToken;
                }
            })
        }
    }
]);

store/modules/user

import * as userAPI from 'api/user';
import { createAction } from 'redux-actions';
import handleActions from 'redux-actions/lib/handleActions';
import { applyPenders } from 'redux-pender/lib/utils';
import produce from 'immer';

const GET_MY_INFO = 'user/GET_MY_INFO';

export const userActions = {
    getMyInfo: createAction(GET_MY_INFO, userAPI.getMyInfo)
};

const initialState = {
    user: null
};

const reducer = handleActions({

}, initialState);

export default applyPenders(reducer, [
    {
        type: GET_MY_INFO,
        onSuccess: (state, action) => {
            const { user } = action.payload.data;
            return produce(state, draft => {
                draft.user = user;
            })
        }
    }
]);

api/auth.js

import axios from 'axios';

export const getAccessToken = ({code}) => axios.post('/api/auth/login', {
    code,
	client_secret: process.env.REACT_APP_GITHUB_SECRET_ID,
	client_id: process.env.REACT_APP_GITHUB_CLIENT_ID
});

api/user.js

import axios from 'axios';

export const getMyInfo = ({accessToken}) => axios.get('/api/user/me', {
    headers: {
        accessToken
    }
});

그리고 이제 AuthContainer로 다시 돌아와서 api와 통신하는 작업을 완료하겠습니다.

AuthContainer.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { authActions } from 'store/modules/auth';
import { userActions } from 'store/modules/user';
import LoginButton from 'components/auth/LoginButton';
import AuthPageWrapper from 'components/auth/AuthPageWrapper';
import LoadingSpinner from 'components/common/LoadingSpinner';


class AuthContainer extends Component {

    state = {
        processing: false
    };

    componentDidMount() {
        const { location: { pathname, search } } = this.props;
	
	const accessToken = localStorage.getItem('accessToken'); // 이미 액세스토큰을 가지고 있다면, 로그인 된것 으로 간주
        if(accessToken) {
            this.props.history.push('/mypage');
	    return;
        }
	
        if(pathname === '/login/processing') {
            this.setState({
                processing: true
            });
            const code = search.split('?code=')[1];
            
            if(code) {
                this.loginProcess({code});
            }
        }
    }

    loginProcess = async ({code}) => {
        try {
            await this.getAccessToken({code});
            await this.getMyInfo({accessToken: this.props.accessToken});
            this.setLoggedIn();
            
        } catch(e) {
            console.log(e);
        }
    }

    getAccessToken = async ({code}) => {
        const { AuthActions } = this.props;
        try {
            await AuthActions.getAccessToken({code});
            localStorage.setItem('accessToken', this.props.accessToken);
        } catch(e) {
            console.log(e);
        }
    }

    getMyInfo = async ({accessToken}) => {
        const { UserActions } = this.props;
        try {
            await UserActions.getMyInfo({accessToken});
        } catch(e) {
            console.log(e);
        }
    }

    setLoggedIn = () => {
        const { AuthActions } = this.props;
        AuthActions.setLoggedIn({loggedIn: true});
        window.close();
    }

    render() {
        if(this.state.processing) {
            return (<LoadingSpinner />);
        }
        return (
            <AuthPageWrapper><LoginButton /></AuthPageWrapper>
        );
    }
}

export default connect(
    ({auth}) => ({
        accessToken: auth.accessToken
    }),
    dispatch => ({
        AuthActions: bindActionCreators(authActions, dispatch),
        UserActions: bindActionCreators(userActions, dispatch)
    })
)(AuthContainer);

이렇게 되면 모든 작업이 마친후, 팝업 창이 닫히는것을 알수 있습니다.

로그인 작업이 완료되면, mypage로 라우팅을 해보겠습니다.

containers/Base.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';


class Base extends Component {
    
    componentDidUpdate(prevProps, prevState) {
        if(prevProps.loggedIn !== this.props.loggedIn) {
            this.props.history.push(`/${this.props.user.login}`);
        }
    }   

    render() {
        return null;
    }
}

export default withRouter(connect(
    ({auth, user}) => ({
        loggedIn: auth.loggedIn,
	user: user.user
    })
)(Base));

이것을 src/components/Router.jsx

import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import { LoginPage, UserPage, NotFoundPage } from 'pages';
import Base from 'containers/Base';

class Router extends Component {
  render() {
    return (
      <>
        <Switch>
          <Route path="/login" component={LoginPage} />
          <Route path="/mypage" component={UserPage} />
          <Route component={NotFoundPage} />
        </Switch>
        <Base />
      </>
    );
  }
}

export default Router;

에 렌더링 해줍니다. 아무것도 보여지지는 않지만, loggedIn 값이 변할때 를 감지하여, mypage로 보내줍니다. 다시 로그인 버튼을 클릭하면, mypage로 보내지는것을 알수 있습니다.

9. 새로고침 할시 로그인 되어있는지 체크.

이제 다시 Base.jsx 로 돌아와서, 리액트 앱을 새로고침 할시 인증이 되었는지 확인을 하여 아니라면, loginPage로, 맞다면 myPage로 보내는 작업을 해보겠습니다.

Base.jsx

...
    componentDidMount() {
        const accessToken = localStorage.getItem('accessToken');
        if(!accessToken) {
            this.props.history.push('/login');
            return;
        };

        this.getMyInfo();
    }

    getMyInfo = async () => {
        const { UserActions } = this.props;

        try {
            await UserActions.getMyInfo({accessToken: localStorage.getItem('accessToken')});
        } catch(e) {
            console.log(e);
            this.props.history.push('/login');
        }
    }
...

먼저 새로고침 하자마자, localStorage에서 accessToken값이 있는지를 확인후, 없다면, 바로 로그인 창으로 이동시키고, 있으면, 실제 서버를 통해서 한번 검증과정을 거친후, 괜찮으면 상태유지, 아니라면, /login 으로 라우팅 합니다.

10.자신의 Repository 정보 리스팅

먼저, 서버에서 자신의 레포지토리를 받아오는 API를 작성해보겠습니다.

server/router/repo/index.js

const Router = require('koa-router');
const repoCtrl = require('./repo.ctrl');

const router = new Router();

router.get('/', repoCtrl.repoList);

module.exports = router;

server/router/repo/repo.ctrl.js

const {createAxios} = require('../../lib/axios');

module.exports.repoList = async (ctx) => {

    const { accesstoken: accessToken } = ctx.headers;;
    let { page, per_page } = ctx.query;
    const axios = createAxios({accessToken});

    if(!page || page <= 0) {
        page = 0;
    };

    if(!per_page || per_page < 10) {
        per_page = 10;
    }

    try {
        const response = await axios.get(`/user/repos?page=${page}&per_page=${per_page}`);
        
        ctx.body = response.data;
        ctx.status = 200;
    } catch(e) {
        ctx.throw(e, 500);
    }
}

이제 클라이언트로 넘어와서 서버로 통신하여 레포지토리 리스트를 받아온후, 보여주는 작업을 해보겠습니다.

containers/UserPageContainer.jsx라는 파일을 하나 만든후,

import React, { Component } from 'react';
import { connect } from 'react-redux';

class UserPageContainer extends Component {
  render() {
    return <div>UserPageContainer</div>;
  }
}

export default connect()(UserPageContainer);

pages/UserPage.jsx

import React from 'react';
import UserPageContainer from 'containers/UserPageContainer';

const UserPage = () => {
  return <UserPageContainer />;
};

export default UserPage;

UserPage에서 렌더링 해줍니다.

그 후, repo쪽에서 사용할 api를 하나 작성해줍니다.

api/repo.js

import axios from 'axios';

export const repoList = ({ accessToken }) =>
  axios.get('/api/repo', {
    headers: {
      accessToken,
    },
  });

마찬가지로 modules 쪽에도 repo.js 파일을 하나 만들어서 리덕스 모듈을 하나 생성합니다.

store/modules/repo.js

import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';
import { applyPenders } from 'redux-pender/lib/utils';
import * as repoAPI from 'api/repo';

const REPO_LIST = 'repo/REPO_LIST';

export const repoActions = {
  repoList: createAction(REPO_LIST, repoAPI.repoList),
};

const initialState = {
  list: [],
};

const reducer = handleActions({}, initialState);

export default applyPenders(reducer, [
  {
    type: REPO_LIST,
    onSuccess: (state, action) => {
      return produce(state, draft => {
        const { data: repoList } = action.payload;
        draft.list = repoList;
      });
    },
  },
]);

와 같이 repoList를 받아오는 리듀서 및 함수를 작성합니다.

modules/index.js

import auth from './auth';
import user from './user';
import repo from './repo';
import { penderReducer } from 'redux-pender';

export default {
  auth,
  user,
  repo,
  pender: penderReducer,
};

module의 index에서 리덕스 스테이트를 적용시킨뒤, 컨테이너로 돌아와서

containers/UserPageContainer.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { repoActions } from 'store/modules/repo';

class UserPageContainer extends Component {
  componentDidMount() {
    this.getRepoList();
  }

  getRepoList = async () => {
    const { RepoActions } = this.props;
    try {
      await RepoActions.repoList({ accessToken: localStorage.getItem('accessToken') });
    } catch (e) {
      console.log(e);
    }
  };

  render() {
    return <div>UserPageContainer</div>;
  }
}

export default connect(
  ({ repo }) => ({
    repoList: repo.list,
  }),
  dispatch => ({
    RepoActions: bindActionCreators(repoActions, dispatch),
  })
)(UserPageContainer);

componentDidMount 함수를 이용하여, repoList를 불러옵니다.

리덕스 데브툴스로 보면 repoList를 받아오는 것을 볼 수 있습니다.

이제 남은 작업은 페이징을 통해서 레포지토리가 여러개일때, 불러오는 것인데요, 일단 그작업을 하기전에 UI부터 만들어야겠습니다.

먼저 RepoListWrapper 컴포넌트를 만들어줍니다.

components/repo/RepoListWrapper/index.js

export { default } from './RepoListWrapper';

compnonents/repo/RepoListWrapper/RepoListWrapper.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './RepoListWrapper.scss';

const cx = classnames.bind(styles);

const RepoListWrapper = ({ children }) => {
  return <div className={cx('RepoListWrapper')}>{children}</div>;
};

export default RepoListWrapper;

components/repo/RepoListWrapper/RepoListWrapper.scss

@import '~styles/utils.scss';

.RepoListWrapper {
  display: flex;
  flex-direction: column;

  align-items: center;
}

그다음 Title 컴포넌트를 만들어주겠습니다.

방식은 위와 동일하며,

components/common/Title/Title.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './Title.scss';

const cx = classnames.bind(styles);

const Title = ({ title }) => {
  return <div className={cx('TitleWrapper')}>{title}</div>;
};

export default Title;

components/common/Title/Title.scss

@import '~styles/utils.scss';

.TitleWrapper {
  padding-top: 1rem;
  padding-bottom: 1rem;
  width: 400px;
  font-size: 1.5rem;
  font-weight: 600;
  color: white;

  border: 1px solid white;
  border-radius: 2px;

  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 2rem;
}

그리고, Container에 렌더링합니다!

containers/UserPageContainer.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { repoActions } from 'store/modules/repo';
import Title from 'components/common/Title';
import RepoListWrapper from 'components/repo/RepoListWrapper';

class UserPageContainer extends Component {
  componentDidMount() {
    this.getRepoList();
  }

  getRepoList = async () => {
    const { RepoActions } = this.props;
    try {
      await RepoActions.repoList({ accessToken: localStorage.getItem('accessToken') });
    } catch (e) {
      console.log(e);
    }
  };

  render() {
    return (
      <RepoListWrapper>
        <Title title="My repo list" />
      </RepoListWrapper>
    );
  }
}

export default connect(
  ({ repo }) => ({
    repoList: repo.list,
  }),
  dispatch => ({
    RepoActions: bindActionCreators(repoActions, dispatch),
  })
)(UserPageContainer);

이제 레포지토리 리스트 UI를 만들겠습니다.

components 폴더 밑에 그림과 같이 파일을 생성한뒤,

Imgur

RepoList.jsx

import React from 'react';
import classnames from 'classnames/bind';
import RepoItem from 'components/repo/RepoItem';
import styles from './RepoList.scss';

const cx = classnames.bind(styles);

const RepoList = ({ list }) => {
  const repoList = list.map(item => <RepoItem key={item.id} repo={item} />);

  return <div className={cx('RepoList')}>{repoList}</div>;
};

export default RepoList;

RepoItem.jsx

import React from 'react';

import classnames from 'classnames/bind';
import styles from './RepoItem.scss';

const cx = classnames.bind(styles);

const RepoItem = ({ repo }) => {
  return <div className={cx('RepoItem')}>{repo.name}</div>;
};

export default RepoItem;

그럼 못생긴 레포 리스트들이 뜰것입니다. 이 못생긴 녀석들을 좀 꾸며보겠습니다.

RepoItem.scss

@import '~styles/utils.scss';

.RepoItem {
  height: 4rem;
  padding: 1rem;

  display: flex;
  flex-direction: column;

  justify-content: center;

  cursor: pointer;

  &:hover {
    background: $oc-gray-5;
    color: black;
    border-radius: 2px;
  }

  &:active {
    background: $oc-gray-6;
  }

  @include media('<=medium') {
    font-size: 1rem;
  }
  
  flex: 1;
}

.RepoItem + .RepoItem {
  border-top: 1px solid $oc-gray-7;
}

RepoList.scss

@import '~styles/utils.scss';

.RepoList {
  background: $oc-gray-9;
  width: 600px;
  margin-top: 1rem;
  //   padding: 1rem;

  border-radius: 2px;
  color: white;
  font-size: 1.25rem;
  font-weight: 600;

  @include media('<=medium') {
    width: 100%;
  }
  
  flex: 1;

  display: flex;
  flex-direction: column;
}

Title.scss

@import '~styles/utils.scss';

.TitleWrapper {
  padding-top: 1rem;
  padding-bottom: 1rem;
  width: 400px;
  font-size: 1.5rem;
  font-weight: 600;
  color: white;

  border: 1px solid white;
  border-radius: 2px;

  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 2rem;

  @include media('<=medium') {
    width: 100%;
    border: none;
  }
}

RepoListWrapper.scss

@import '~styles/utils.scss';

.RepoListWrapper {
  display: flex;
  flex-direction: column;

  align-items: center;

  height: auto;
  
  position: relative;
}

이제 페이징을 하는 UI를 만들어보겠습니다.

Imgur

common 폴더 아래에 다음과같이 파일들을 생성하고,

Pager.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './Pager.scss';

const cx = classnames.bind(styles);

const Pager = () => {
  return (
    <div className={cx('Pager')}>
      <div className={cx('PagerButton')}>prev</div>
      <div className={cx('PagerButton', 'Next')}>next</div>
    </div>
  );
};

export default Pager;

Pager.scss

@import '~styles/utils.scss';

.Pager {
  color: white;

  width: 600px;
  margin-bottom: 1rem;

  display: flex;

  .PagerButton {
    cursor: pointer;

    display: flex;
    align-items: center;
    border-radius: 2px;

    background: $oc-gray-9;
    padding: 0.25rem;

    &:hover {
      background: $oc-gray-7;
    }

    &:active {
      background: $oc-gray-9;
    }

    &.Next {
      margin-left: auto;
    }
  }
}

UserPageContainer.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { repoActions } from 'store/modules/repo';
import Title from 'components/common/Title';
import RepoListWrapper from 'components/repo/RepoListWrapper';
import RepoList from 'components/repo/RepoList';
import LoadingSpinner from 'components/common/LoadingSpinner';
import Pager from 'components/common/Pager';

class UserPageContainer extends Component {
  componentDidMount() {
    this.getRepoList();
  }

  getRepoList = async () => {
    const { RepoActions } = this.props;
    try {
      await RepoActions.repoList({ accessToken: localStorage.getItem('accessToken') });
    } catch (e) {
      console.log(e);
    }
  };

  render() {
    const { repoList } = this.props;
    if (repoList.length === 0) return <LoadingSpinner />;
    return (
      <RepoListWrapper>
        <Title title="My repo list" />
        <RepoList list={repoList} />
        <Pager />
      </RepoListWrapper>
    );
  }
}

export default connect(
  ({ repo }) => ({
    repoList: repo.list,
  }),
  dispatch => ({
    RepoActions: bindActionCreators(repoActions, dispatch),
  })
)(UserPageContainer);

Container에 보여주세요 .

이제 페이징 작업을 위해 리덕스 스테이트를 만들어야 합니다.

modules/repo.js

const initialState = {
  list: [],
  pagingInfo: {
    currentPage: 1,
    perPage: {
      visible: 10,
      clicked: false,
    },
  },
};

initialState에 pagingInfo 스테이트를 추가 후,

UserPageContainer.jsx

  ...

  render() {
    const { repoList, pagingInfo } = this.props;
    if (repoList.length === 0) return <LoadingSpinner />;
    return (
      <RepoListWrapper>
        <Title title="My repo list" />
        <RepoList list={repoList} />
        <Pager pagingInfo={pagingInfo}/>
      </RepoListWrapper>
    );
  }
}

export default connect(
  ({ repo }) => ({
    repoList: repo.list,
    pagingInfo: repo.pagingInfo,
  }),
  dispatch => ({
    RepoActions: bindActionCreators(repoActions, dispatch),
  })
)(UserPageContainer);

pagingInfo를 Pager에 전달해주세요. 다음으로 할일은,

modules/repo.js

...

const CLICK_PER_PAGE = 'repo/CLICK_PER_PAGE';
const SELECT_PER_PAGE = 'repo/SELECT_PER_PAGE';

export const repoActions = {
  repoList: createAction(REPO_LIST, repoAPI.repoList),
  clickPerPage: createAction(HOVER_PER_PAGE, payload => payload),
  selectPerPage: createAction(SELECT_PER_PAGE, payload => payload),
};

...

const reducer = handleActions(
  {
    [CLICK_PER_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { hovered } = action.payload;
        draft.pagingInfo.perPage.clicked = hovered;
      });
    },
    [SELECT_PER_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { perPage } = action.payload;
        draft.pagingInfo.perPage.visible = perPage;
        draft.pagingInfo.perPage.clicked = false;
      });
    },
  },
  initialState
);

...

perPage를 조정하는 곳에 마우스를 올렸을때, hovered를 표시하여 선택란을 보여주는 일을 하는 함수 및 리듀서와 perPage를 선택하였을때 일을 처리하는 함수 및 리듀서를 만들었습니다.

UserPageContainer.jsx

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { repoActions } from 'store/modules/repo';
import Title from 'components/common/Title';
import RepoListWrapper from 'components/repo/RepoListWrapper';
import RepoList from 'components/repo/RepoList';
import LoadingSpinner from 'components/common/LoadingSpinner';
import Pager from 'components/common/Pager';

class UserPageContainer extends Component {
  componentDidMount() {
    this.getRepoList();
  }

  getRepoList = async () => {
    const { RepoActions } = this.props;
    try {
      await RepoActions.repoList({ accessToken: localStorage.getItem('accessToken') });
    } catch (e) {
      console.log(e);
    }
  };

  clickPerPage = ({ clicked }) => {
    const { RepoActions } = this.props;
    RepoActions.clickPerPage({ clicked });
  };

  selectPerPage = ({ perPage }) => {
    const { RepoActions } = this.props;
    RepoActions.selectPerPage({ perPage });
  };

  render() {
    const { repoList, pagingInfo } = this.props;
    const { clickPerPage, selectPerPage } = this;
    if (repoList.length === 0) return <LoadingSpinner />;
    return (
      <RepoListWrapper>
        <Title title="My repo list" />
        <RepoList list={repoList} />
        <Pager pagingInfo={pagingInfo} onClickPerPage={clickPerPage} onSelect={selectPerPage} />
      </RepoListWrapper>
    );
  }
}

export default connect(
  ({ repo }) => ({
    repoList: repo.list,
    pagingInfo: repo.pagingInfo,
  }),
  dispatch => ({
    RepoActions: bindActionCreators(repoActions, dispatch),
  })
)(UserPageContainer);

위와같이 컨테이너에서 함수를 처리한뒤 프롭스로 전달합니다.

Pager.jsx

import React from 'react';
import classnames from 'classnames/bind';
import styles from './Pager.scss';

const cx = classnames.bind(styles);

const Pager = ({ pagingInfo, onClickPerPage, onSelect }) => {
  return (
    <div className={cx('Pager')}>
      <div className={cx('PagerButton')}>
        prev
      </div>
      <div className={cx('PerPage')}>
        <div className={cx('PerPageItems', pagingInfo.perPage.clicked && 'visible')}>
          <div className={cx('Item')} onClick={e => onSelect({ perPage: 10 })}>
            10
          </div>
          <div className={cx('Item')} onClick={e => onSelect({ perPage: 15 })}>
            15
          </div>
          <div className={cx('Item')} onClick={e => onSelect({ perPage: 20 })}>
            20
          </div>
        </div>
        <div
          className={cx('Selector')}
          onClick={e => {
            onClickPerPage({ clicked: !pagingInfo.perPage.clicked });
          }}
        >
          <div className={cx('Item', pagingInfo.perPage.visible === 10 && 'visible')}>10</div>
          <div className={cx('Item', pagingInfo.perPage.visible === 15 && 'visible')}>15</div>
          <div className={cx('Item', pagingInfo.perPage.visible === 20 && 'visible')}>20</div>
        </div>
      </div>
      <div className={cx('PagerButton', 'Next')}>
        next
      </div>
    </div>
  );
};

export default Pager;

Pager.scss

@import '~styles/utils.scss';

.Pager {
  color: white;

  width: 600px;
  margin-bottom: 1rem;

  display: flex;

  .PerPage {
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: center;

    .Selector {
      width: 100px;
      background: $oc-gray-6;
      outline: none;
      border: 1px solid $oc-gray-3;
      font-size: 1rem;
      text-align: center;

      .Item {
        &.visible {
          display: block;
        }
        display: none;
      }
    }

    .PerPageItems {
      cursor: pointer;
      position: absolute;
      bottom: 50px;
      z-index: 40;

      border: 1px solid $oc-gray-3;

      &.visible {
        display: block;
      }

      display: none;

      .Item {
        width: 100px;
        background: $oc-gray-6;
        outline: none;

        font-size: 1rem;
        text-align: center;

        &:hover {
          background: $oc-gray-7;
        }
      }

      .Item + .Item {
        border-top: 1px solid $oc-gray-3;
      }

      @include media('height<784px') {
        display: none;
      }
    }
  }

  .PagerButton {
    cursor: pointer;

    display: flex;
    align-items: center;
    border-radius: 2px;

    background: $oc-gray-9;
    padding-top: 0.25rem;
    padding-bottom: 0.25rem;
    padding-left: 0.75rem;
    padding-right: 0.75rem;

    &:hover {
      background: $oc-gray-7;
    }

    &:active {
      background: $oc-gray-9;
    }

    &.Next {
      margin-left: auto;
    }
  }
}

이렇게 스타일링 을 하면, 마우스가 올라갔을때에는 선택란이 보이고, 선택을 하면 perPage값이 바뀌어 보입니다.

자 이제 pagingInfo를 리덕스에서 관리하게 되었으므로, 이것들을 사용하여 페이징을 하여보겠습니다.

다음 페이지 버튼이나 이전 페이지 버튼을 눌렀을때에, 페이지를 +1 하거나 -1 할 리듀서를 작성해보겠습니다.

modules/repo.js

...

const SET_PAGE = 'repo/SET_PAGE';

export const repoActions = {
  repoList: createAction(REPO_LIST, repoAPI.repoList),
  clickPerPage: createAction(CLICK_PER_PAGE, payload => payload),
  selectPerPage: createAction(SELECT_PER_PAGE, payload => payload),
  setPage: createAction(SET_PAGE, payload => payload), // 추가됨
};

...

const reducer = handleActions(
  {
    [CLICK_PER_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { clicked } = action.payload;
        draft.pagingInfo.perPage.clicked = clicked;
      });
    },
    [SELECT_PER_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { perPage } = action.payload;
        draft.pagingInfo.perPage.visible = perPage;
        draft.pagingInfo.perPage.clicked = false;
      });
    },
    [SET_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { page } = action.payload;
        draft.pagingInfo.currentPage = page;
      });
    }, // 추가됨
  },
  initialState
);

UserPageContainer.jsx

...

  setPage = ({ page }) => {
    const { RepoActions } = this.props;
    RepoActions.setPage({ page });
  };

  componentDidUpdate(prevProps, prevState) {
    if (prevProps.pagingInfo.currentPage !== this.props.pagingInfo.currentPage) {
      this.getRepoList({ page: this.props.pagingInfo.currentPage, perPage: this.props.pagingInfo.perPage.visible });
    }
    
    if (prevProps.pagingInfo.perPage.visible !== this.props.pagingInfo.perPage.visible) {
      this.getRepoList({ page: 1, perPage: this.props.pagingInfo.perPage.visible });
      this.setPage({ page: 1 });
    }
  }

  render() {
    const { repoList, pagingInfo } = this.props;
    const { clickPerPage, selectPerPage, setPage } = this;
    if (repoList.length === 0) return <LoadingSpinner />;
    return (
      <RepoListWrapper>
        <Title title="My repo list" />
        <RepoList list={repoList} />
        <Pager pagingInfo={pagingInfo} onClickPerPage={clickPerPage} onSelect={selectPerPage} setPage={setPage} />
      </RepoListWrapper>
    );
  }
}

...

componentDidUpdate를 사용하여 page값이 바뀌었을때, 다시 repo들을 불러옵니다. 또한, perPage값이 바뀌었을때, 페이지를 1로 돌리고, 그에따라서 다시 불러옵니다.

Pager.jsx

import debounce from 'lodash/debounce';

...

const cx = classnames.bind(styles);

const Pager = ({ pagingInfo, onClickPerPage, onSelect, setPage }) => {
  return (
    <div className={cx('Pager')}>
      <div 
      	className={cx('PagerButton', pagingInfo.currentPage === 1 && 'disabled')}
      	onClick={debounce(e => pagingInfo.currentPage > 1 && setPage({ page: pagingInfo.currentPage - 1 }), 300)}
      >
        prev
      </div>
      
      ...
      
      <div 
      	className={cx('PagerButton', 'Next')} 
	onClick={debounce(e => setPage({ page: pagingInfo.currentPage + 1 }), 300)}
      >
        next
      </div>
    </div>
  );
};

export default Pager;

Pager쪽에서는 next를 클릭했을때에는 +1 을 해주고, prev를 클릭하였을때에는 -1을 하여 페이지를 세팅합니다. 또한 페이지가 1일때에는 더 뒤로갈 페이지가 없으므로, 하얀색으로 disabled처리를 해주고, debounce를 사용하여 300 밀리세컨드 이내에 발생된 이벤트들은 모두 무시합니다.

Pager.scss

@import '~styles/utils.scss';

.Pager {
  color: white;

  width: 600px;
  margin-bottom: 1rem;

  display: flex;

  .PerPage {
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: center;

    .Selector {
      width: 100px;
      background: $oc-gray-6;
      outline: none;
      border: 1px solid $oc-gray-3;
      font-size: 1rem;
      text-align: center;

      .Item {
        &.visible {
          display: block;
        }
        display: none;
      }
    }

    .PerPageItems {
      cursor: pointer;
      position: absolute;
      bottom: 50px;
      z-index: 40;

      border: 1px solid $oc-gray-3;

      &.visible {
        display: block;
      }

      display: none;

      .Item {
        width: 100px;
        background: $oc-gray-6;
        outline: none;

        font-size: 1rem;
        text-align: center;

        &:hover {
          background: $oc-gray-7;
        }
      }

      .Item + .Item {
        border-top: 1px solid $oc-gray-3;
      }

      @include media('height<784px') {
        display: none;
      }
    }
  }

  .PagerButton {
    cursor: pointer;

    display: flex;
    align-items: center;
    border-radius: 2px;

    background: $oc-gray-9;
    padding-top: 0.25rem;
    padding-bottom: 0.25rem;
    padding-left: 0.75rem;
    padding-right: 0.75rem;

    &:hover {
      background: $oc-gray-7;
    }

    &:active {
      background: $oc-gray-9;
    }

    &.Next {
      margin-left: auto;
    }

    &.disabled {
      background: $oc-gray-3;
      color: black;
    } // 추가된 부분
  }
}

더이상 넘어갈 페이지가 없는것을 검사해야 하는데요, 검사의 흐름은 다음과 같습니다.

  1. 현재 페이지에 맞는 리스트를 불러온다.
  2. 리스트를 불러오자 마자 다음 페이지에 해당하는 리스트를 불러온다. 만약 리스트의 개수가 perPage개수보다 작다면, 다음페이지가 마지막 페이지 임을 알수 있다.

modules/repo.js

...

const NEXT_REPO_LIST = 'repo/NEXT_REPO_LIST';
const SHOW_NEXT_REPO_LIST = 'repo/SHOW_NEXT_REPO_LIST';

...

export const repoActions = {
  repoList: createAction(REPO_LIST, repoAPI.repoList),
  clickPerPage: createAction(CLICK_PER_PAGE, payload => payload),
  selectPerPage: createAction(SELECT_PER_PAGE, payload => payload),
  setPage: createAction(SET_PAGE, payload => payload),
  nextRepoList: createAction(NEXT_REPO_LIST, repoAPI.repoList), // 추가 
  showNextRepoList: createAction(SHOW_NEXT_REPO_LIST, payload => payload), //  추가
};

const initialState = {
  list: [],
  pagingInfo: {
    currentPage: 1,
    perPage: {
      visible: 10,
      clicked: false,
    },
  },
  nextList: [], // 추가
};

const reducer = handleActions(
  {
    [CLICK_PER_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { clicked } = action.payload;
        draft.pagingInfo.perPage.clicked = clicked;
      });
    },
    [SELECT_PER_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { perPage } = action.payload;
        draft.pagingInfo.perPage.visible = perPage;
        draft.pagingInfo.perPage.clicked = false;
      });
    },
    [SET_PAGE]: (state, action) => {
      return produce(state, draft => {
        const { page } = action.payload;
        draft.pagingInfo.currentPage = page;
      });
    },
    [SHOW_NEXT_REPO_LIST]: (state, action) => { // 추가
      return produce(state, draft => {
        const { nextList } = action.payload;
        draft.list = nextList;
        draft.nextList = [];
      });
    },
  },
  initialState
);

export default applyPenders(reducer, [
  {
    type: REPO_LIST,
    onSuccess: (state, action) => {
      return produce(state, draft => {
        const { data: repoList } = action.payload;
        draft.list = repoList;
      });
    },
  },
  {
    type: NEXT_REPO_LIST, // 추가
    onSuccess: (state, action) => {
      return produce(state, draft => {
        const { data: repoList } = action.payload;
        draft.nextList = repoList;
      });
    },
  },
]);

UserPageContainer.jsx

getRepoList = async ({ page, perPage }) => {
    const { RepoActions } = this.props;
    try {
      await RepoActions.repoList({
        accessToken: localStorage.getItem('accessToken'),
        page,
        perPage,
      });
      this.nextRepoList({ page: page + 1, perPage }); // 추가
    } catch (e) {
      console.log(e);
    }
  };
 
  nextRepoList = async ({ page, perPage }) => { // 추가
    const { RepoActions } = this.props;
    try {
      await RepoActions.nextRepoList({
        accessToken: localStorage.getItem('accessToken'),
        page,
        perPage,
      });
    } catch (e) {
      console.log(e);
    }
  };

  showNextRepoList = () => {
    const {
      RepoActions,
      nextList,
      pagingInfo: {
        currentPage,
        perPage: { visible },
      },
    } = this.props;
    RepoActions.showNextRepoList({ nextList });
    this.nextRepoList({ page: currentPage + 1, perPage: visible });
  }; // 추가 
  
  componentDidUpdate(prevProps, prevState) {
    if (prevProps.pagingInfo.currentPage !== this.props.pagingInfo.currentPage) {
      if (prevProps.pagingInfo.currentPage > this.props.pagingInfo.currentPage) {
        this.getRepoList({ page: this.props.pagingInfo.currentPage, perPage: this.props.pagingInfo.perPage.visible }); 
      } else {
        this.showNextRepoList();
      }
    }

    if (prevProps.pagingInfo.perPage.visible !== this.props.pagingInfo.perPage.visible) {
      this.getRepoList({ page: 1, perPage: this.props.pagingInfo.perPage.visible });
      this.setPage({ page: 1 });
    }
  } // 추가된부분
  
  render() {
    const { repoList, pagingInfo, nextList } = this.props;
    const { handleClickPerPage, selectPerPage, setPage } = this;
    if (repoList.length === 0) return <LoadingSpinner />;
    return (
      <RepoListWrapper>
        <Title title="My repo list" />
        <RepoList list={repoList} />
        <Pager
          pagingInfo={pagingInfo}
          onClickPerPage={handleClickPerPage}
          onSelect={selectPerPage}
          setPage={setPage}
          nextList={nextList} // 추가 
        />
      </RepoListWrapper>
    );
  }

Pager.jsx

// added nextList props
const Pager = ({ pagingInfo, onClickPerPage, onSelect, setPage, nextList }) => {
  return (
    <div className={cx('Pager')}>
      <div
        className={cx('PagerButton', pagingInfo.currentPage === 1 && 'disabled')}
        onClick={debounce(e => pagingInfo.currentPage > 1 && setPage({ page: pagingInfo.currentPage - 1 }), 300)}
      >
        prev
      </div>
      <div className={cx('PerPage')}>
        <div className={cx('PerPageItems', pagingInfo.perPage.clicked && 'visible')}>
          <div className={cx('Item')} onClick={e => onSelect({ perPage: 10 })}>
            10
          </div>
          <div className={cx('Item')} onClick={e => onSelect({ perPage: 15 })}>
            15
          </div>
          <div className={cx('Item')} onClick={e => onSelect({ perPage: 20 })}>
            20
          </div>
        </div>
        <div
          className={cx('Selector')}
          onClick={e => {
            onClickPerPage({ clicked: !pagingInfo.perPage.clicked });
          }}
        >
          <div className={cx('Item', pagingInfo.perPage.visible === 10 && 'visible')}>10</div>
          <div className={cx('Item', pagingInfo.perPage.visible === 15 && 'visible')}>15</div>
          <div className={cx('Item', pagingInfo.perPage.visible === 20 && 'visible')}>20</div>
        </div>
      </div>
      <div
        className={cx('PagerButton', 'Next', nextList.length === 0 && 'disabled')} // nextList.length === 0 -> 다음페이지에서 받아올 자료가 더 없음 (따라서 마지막 페이지)
        onClick={debounce(e => nextList.length !== 0 && setPage({ page: pagingInfo.currentPage + 1 }), 300)} // nextList.length === 0이면 
      >
        next
      </div>
    </div>
  );
};

export default Pager;

11. 레포지토리 자세히 보기 페이지.

이제 자신의 레포지토리를 리스팅하고 페이징하는 작업을 완료했으니, 레포지토리아이템을 클릭하였을때, 레포지토리 상세 페이지를 만들어보겠습니다.

pages/RepoPage.jsx

import React from 'react';

const RepoPage = () => {
  return <div>RepoPage</div>;
};

export default RepoPage;

Router.jsx

import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import { LoginPage, UserPage, RepoPage, NotFoundPage } from 'pages';
import Base from 'containers/Base';

class Router extends Component {
  render() {
    return (
      <>
        <Switch>
          <Route path="/login" component={LoginPage} />
          <Route path="/mypage" component={UserPage} />
          <Route path="/:username/:reponame" component={RepoPage} /> // 추가 
          <Route component={NotFoundPage} />
        </Switch>
        <Base />
      </>
    );
  }
}

export default Router;

RepoItem.jsx를 다음과 같이 변경합니다. 링크를 달아주는 작업입니다.

RepoItem.jsx

import React from 'react';
import { Link } from 'react-router-dom';
import classnames from 'classnames/bind';
import styles from './RepoItem.scss';

const cx = classnames.bind(styles);

const RepoItem = ({ repo }) => {
  return (
    <Link to={`/${repo.owner.login}/${repo.name}`} className={cx('RepoItem')}>
      {repo.name}
    </Link>
  );
};

export default RepoItem;

이제 클릭 하면 RepoPage로 라우팅 될것입니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment